diff --git a/docs/guide/yaml-config.md b/docs/guide/yaml-config.md index e9909634b..e94999957 100644 --- a/docs/guide/yaml-config.md +++ b/docs/guide/yaml-config.md @@ -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. diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index cfdfff16d..9330f90c3 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -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 `.md` / `.org` (closes [#547](https://github.com/srid/emanote/issues/547)) diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index ef4a8f267..29e9c5a11 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -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 @@ -153,6 +152,7 @@ common library-common , uri-encode , url-slug , uuid + , vector , warp , which , with-utf8 @@ -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 diff --git a/emanote/src/Emanote/Model/SData.hs b/emanote/src/Emanote/Model/SData.hs index 947f0fe7d..befe89607 100644 --- a/emanote/src/Emanote/Model/SData.hs +++ b/emanote/src/Emanote/Model/SData.hs @@ -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) @@ -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 diff --git a/emanote/test/Emanote/Model/SDataSpec.hs b/emanote/test/Emanote/Model/SDataSpec.hs new file mode 100644 index 000000000..f135c4dbb --- /dev/null +++ b/emanote/test/Emanote/Model/SDataSpec.hs @@ -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"]] diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature index 9b04f233d..956be0fb6 100644 --- a/tests/features/smoke.feature +++ b/tests/features/smoke.feature @@ -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" diff --git a/tests/fixtures/notebook/issue-352/note.md b/tests/fixtures/notebook/issue-352/note.md index 472e7e090..a11cd86a0 100644 --- a/tests/fixtures/notebook/issue-352/note.md +++ b/tests/fixtures/notebook/issue-352/note.md @@ -1,13 +1,3 @@ - - # Cascaded-tag fixture This note declares no tags in its own frontmatter. The tag diff --git a/tests/fixtures/notebook/issue-697.yaml b/tests/fixtures/notebook/issue-697.yaml new file mode 100644 index 000000000..eef6ebec9 --- /dev/null +++ b/tests/fixtures/notebook/issue-697.yaml @@ -0,0 +1,2 @@ +tags: + - issue-697-cascaded diff --git a/tests/fixtures/notebook/issue-697/note.md b/tests/fixtures/notebook/issue-697/note.md new file mode 100644 index 000000000..c6e04fdc5 --- /dev/null +++ b/tests/fixtures/notebook/issue-697/note.md @@ -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.