Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/guide/yaml-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ Notice how this page's sidebar colorscheme has [changed to green]{.greenery}? Vi
>[!tip] Using in HTML templates
> You can reference the YAML frontmatter config from [[html-template]]. See [here](https://github.com/srid/emanote/discussions/131#discussioncomment-1382189) for details.

## Cascade merge semantics

When a child route's frontmatter or YAML overlaps with values from parent YAMLs, Emanote merges them along three rules:

- **Objects** are deep-merged by key — the child overrides individual nested fields without touching siblings.
- **Arrays** concatenate (with deduplication) — `tags: [team-doc]` in `folder.yaml` plus `tags: [internal-note]` on a child note yields `[team-doc, internal-note]`. The same applies to other list-valued fields like `pandoc.filters`.
- **Scalars** right-win — the most-specific value (the leaf) overrides ancestors.

The array rule is what makes a parent YAML's `tags` survive to its children even when those children declare their own.

## Special properties

- `page.image`: The image to use for the page. This is used for the [[ogp]] meta tag `og:image` meta tag. If not specified, the first image in the page is used. Relative URLs are automatically rewritten to absolute URLs if `page.siteUrl` is non-empty.
Expand Down
1 change: 1 addition & 0 deletions emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

**Bug fixes**

- Cascade-declared `tags` no longer disappear from a child note that declares any of its own. The metadata-cascade merger previously inherited `aeson-extra`'s `lodashMerge`, which aligns arrays by index — `tags: [team-doc]` in `folder.yaml` plus `tags: [internal-note]` in `folder/note.md` produced `[internal-note]`, dropping the cascaded entry both from the per-page chip strip and from the `#352` global tag index. The merger now unions cascade arrays; see [yaml-config](docs/guide/yaml-config.md) for the full contract. `aeson-extra` is no longer a dependency (closes [#697](https://github.com/srid/emanote/issues/697)).
- Wiki link custom titles now render HTML entities like ` ` the same way regular Markdown link labels do. Previously `[[note|Spivak (2014)]]` rendered the entity text literally as ` ` (closes [#441](https://github.com/srid/emanote/issues/441)).
- Atom feed: a feed query that matches no notes no longer crashes the build; an empty-but-valid Atom document is emitted instead. Configuration errors (missing/invalid query block, missing `page.siteUrl`) still fail loudly ([#490](https://github.com/srid/emanote/issues/490), [#650](https://github.com/srid/emanote/pull/650))
- Markdown links to a static `.xml` asset (e.g. `[Test](./test.xml)`) now resolve to the file. Previously a `.xml` URL was always interpreted as the Atom feed of a same-named note, leaving asset links broken when no such feed-enabled note existed. The missing-link page now also tailors its "you may create…" hint to the URL extension instead of always suggesting `<url>.md` / `<url>.org` (closes [#547](https://github.com/srid/emanote/issues/547))
Expand Down
3 changes: 2 additions & 1 deletion emanote/emanote.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ common library-common
-- TODO: We could use the ghcid flag trick in neuron.cabal to avoid rebuilds.
build-depends:
, aeson
, aeson-extra
, aeson-optics
, async
, base >=4.14 && <5
Expand Down Expand Up @@ -153,6 +152,7 @@ common library-common
, uri-encode
, url-slug
, uuid
, vector
, warp
, which
, with-utf8
Expand Down Expand Up @@ -256,6 +256,7 @@ test-suite test
other-modules:
Emanote.Model.Link.RelSpec
Emanote.Model.QuerySpec
Emanote.Model.SDataSpec
Emanote.Model.TocSpec
Emanote.Pandoc.BuiltinFiltersSpec
Emanote.Pandoc.ExternalLinkSpec
Expand Down
20 changes: 18 additions & 2 deletions emanote/src/Emanote/Model/SData.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
module Emanote.Model.SData where

import Data.Aeson qualified as Aeson
import Data.Aeson.Extra.Merge qualified as AesonMerge
import Data.Aeson.KeyMap qualified as KM
import Data.Data (Data)
import Data.IxSet.Typed (Indexable (..), IxSet, ixGen, ixList)
import Data.List.NonEmpty qualified as NE
import Data.Vector qualified as V
import Data.Yaml qualified as Yaml
import Emanote.Route qualified as R
import Optics.TH (makeLenses)
Expand Down Expand Up @@ -54,8 +54,24 @@ mergeAesons :: NonEmpty Aeson.Value -> Aeson.Value
mergeAesons =
last . NE.scanl1 mergeAeson

{- | Deep-merge two YAML-shaped Aeson values. The contract applies
uniformly to every caller (cascade and non-cascade alike):

* Objects merge by key, recursively.
* Arrays concatenate, then deduplicate (left order preserved).
* Scalars right-win.

The array clause is the deliberate divergence from @aeson-extra@'s
@lodashMerge@: lodash aligns arrays by index, which for list-valued
fields like @tags@ silently clobbers cascade contributions the moment
a child note declares any of its own — see issue #697.
-}
mergeAeson :: Aeson.Value -> Aeson.Value -> Aeson.Value
mergeAeson = AesonMerge.lodashMerge
mergeAeson (Aeson.Object a) (Aeson.Object b) =
Aeson.Object $ KM.unionWith mergeAeson a b
mergeAeson (Aeson.Array a) (Aeson.Array b) =
Aeson.Array $ V.fromList $ ordNub $ V.toList a <> V.toList b
mergeAeson _ b = b

-- TODO: Use https://hackage.haskell.org/package/lens-aeson
lookupAeson :: forall a. (Aeson.FromJSON a) => a -> NonEmpty Text -> Aeson.Value -> a
Expand Down
63 changes: 63 additions & 0 deletions emanote/test/Emanote/Model/SDataSpec.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
module Emanote.Model.SDataSpec where

import Data.Aeson ((.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson
import Emanote.Model.SData qualified as SData
import Relude
import Test.Hspec

obj :: [Aeson.Pair] -> Aeson.Value
obj = Aeson.object

arr :: [Text] -> Aeson.Value
arr = Aeson.toJSON

spec :: Spec
spec = do
describe "mergeAeson cascade semantics" $ do
it "right wins on scalar conflict"
$ SData.mergeAeson (obj ["name" .= ("old" :: Text)]) (obj ["name" .= ("new" :: Text)])
`shouldBe` obj ["name" .= ("new" :: Text)]
it "deep-merges objects by key"
$ SData.mergeAeson
(obj ["template" .= obj ["theme" .= ("blue" :: Text), "name" .= ("old" :: Text)]])
(obj ["template" .= obj ["theme" .= ("green" :: Text)]])
`shouldBe` obj ["template" .= obj ["theme" .= ("green" :: Text), "name" .= ("old" :: Text)]]
it "concatenates arrays instead of aligning by index (issue #697)"
$ SData.mergeAeson
(obj ["tags" .= arr ["team-doc"]])
(obj ["tags" .= arr ["internal-note"]])
`shouldBe` obj ["tags" .= arr ["team-doc", "internal-note"]]
it "deduplicates repeated array elements after concatenation"
$ SData.mergeAeson
(obj ["tags" .= arr ["team-doc", "shared"]])
(obj ["tags" .= arr ["shared", "internal-note"]])
`shouldBe` obj ["tags" .= arr ["team-doc", "shared", "internal-note"]]
it "preserves single-side array values when only one side declares the key"
$ SData.mergeAeson
(obj ["tags" .= arr ["team-doc"]])
(obj ["title" .= ("child" :: Text)])
`shouldBe` obj ["tags" .= arr ["team-doc"], "title" .= ("child" :: Text)]
it "carries array semantics through nested objects (e.g. pandoc.filters)"
$ SData.mergeAeson
(obj ["pandoc" .= obj ["filters" .= arr ["site-wide.lua"]]])
(obj ["pandoc" .= obj ["filters" .= arr ["page-local.lua"]]])
`shouldBe` obj ["pandoc" .= obj ["filters" .= arr ["site-wide.lua", "page-local.lua"]]]
describe "mergeAesons folds left-to-right with right-most winning at scalars" $ do
it "later cascade entries override earlier scalar values"
$ SData.mergeAesons
( obj ["template" .= obj ["theme" .= ("blue" :: Text)]]
:| [ obj ["template" .= obj ["theme" .= ("green" :: Text)]]
, obj ["template" .= obj ["theme" .= ("red" :: Text)]]
]
)
`shouldBe` obj ["template" .= obj ["theme" .= ("red" :: Text)]]
it "unions tags across the whole cascade"
$ SData.mergeAesons
( obj ["tags" .= arr ["root"]]
:| [ obj ["tags" .= arr ["mid"]]
, obj ["tags" .= arr ["leaf"]]
]
)
`shouldBe` obj ["tags" .= arr ["root", "mid", "leaf"]]
10 changes: 10 additions & 0 deletions tests/features/smoke.feature
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,13 @@ Feature: Smoke
Scenario: Tag declared in sibling folder YAML appears on the root tag-index page (regression: #352)
When I fetch "/-/tags.html"
Then the response body contains "-/tags/issue-352-cascaded.html"

Scenario: Cascaded YAML tag survives when the child note declares its own tags (regression: #697)
When I open "/issue-697/note.html"
Then the metadata tag chip with text "issue-697-cascaded" has href containing "-/tags/issue-697-cascaded.html"
And the metadata tag chip with text "issue-697-own" has href containing "-/tags/issue-697-own.html"
And the metadata tag chip with text "issue-697-inline" has href containing "-/tags/issue-697-inline.html"

Scenario: Cascaded YAML tag still indexes a child note that declares its own tags (regression: #697)
When I fetch "/-/tags/issue-697-cascaded.html"
Then the response body contains "issue-697/note"
10 changes: 0 additions & 10 deletions tests/fixtures/notebook/issue-352/note.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
<!--
Fixture maintenance note (kept out of the rendered body so it doesn't
itself become a regression vector):

Do NOT add inline hash-tag syntax to this fixture. Body-extracted
tags lodash-merge with cascade tags by array index, and a same-index
collision would clobber the cascaded element — see issue srid/emanote#697.
The whole point of this fixture is to assert the cascade survives.
-->

# Cascaded-tag fixture

This note declares no tags in its own frontmatter. The tag
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/notebook/issue-697.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tags:
- issue-697-cascaded
14 changes: 14 additions & 0 deletions tests/fixtures/notebook/issue-697/note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
tags:
- issue-697-own
---

# Cascade-survives-own-tags fixture

This note declares `issue-697-own` in its frontmatter, and an inline
#issue-697-inline tag in the body. The sibling `issue-697.yaml`
contributes `issue-697-cascaded` via the data cascade.

All three must reach the page chip strip and the global tag index;
prior to #697 the cascaded tag was clobbered the moment the leaf
note declared any tags of its own.
Loading