From 50526653cd38c0d155b1f695891ba1b6bbdc348e Mon Sep 17 00:00:00 2001 From: Devil1716 Date: Sat, 18 Jul 2026 13:02:47 +0530 Subject: [PATCH] fix: strip nested child config recursively at assembly Top-level CHILD_STRIP_APPARATUS left nested config inside section.content intact, and config-discovery still read it as document config. Recurse the strip so the master-only rule holds everywhere. Fixes #472. Co-authored-by: Cursor --- .../enscribe/src/master-document/assemble.js | 43 +++++++++++ .../enscribe/test/child-config-strip.test.js | 72 +++++++++++++++++++ packages/enscribe/test/run.js | 2 + 3 files changed, 117 insertions(+) create mode 100644 packages/enscribe/test/child-config-strip.test.js diff --git a/packages/enscribe/src/master-document/assemble.js b/packages/enscribe/src/master-document/assemble.js index 808d370e..4f373bc9 100644 --- a/packages/enscribe/src/master-document/assemble.js +++ b/packages/enscribe/src/master-document/assemble.js @@ -125,6 +125,47 @@ const DEFERRED_MARKERS = new Set(['toc', 'endnotes', 'endnote-list']); // so its sources join the project-wide citation registry (#190). const CHILD_STRIP_APPARATUS = new Set(['meta', 'config']); +/** + * #472: strip nested child ``/`` recursively. + * + * Top-level strip alone is not enough — config-discovery walks the whole tree + * (including `content` arrays), so a `` nested inside a child's + * `
` would otherwise survive assembly and become document config. + * Recursing makes the top-level CHILD_STRIP_APPARATUS rule the whole rule. + * Preserves #460: a nested `strict-mode` declaration still emits the visible + * override marker + warn instead of a silent drop. + */ +function stripNestedChildApparatus(node, src, ctx) { + if (!node || typeof node !== 'object') return node; + + const scrubList = (list) => { + if (!Array.isArray(list)) return list; + const out = []; + for (const n of list) { + if (isEnscribeTag(n, 'config') && n.kwargs && n.kwargs['strict-mode'] != null) { + const childMode = String(n.kwargs['strict-mode']); + ctx.warn(`strict-mode is document-wide: child "${src}" declares strict-mode="${childMode}", which is ignored — the master's governs (a child cannot override)`); + out.push(strictChildOverrideNode(src, childMode)); + continue; + } + if (isEnscribeTag(n) && CHILD_STRIP_APPARATUS.has(n.tagname)) continue; + out.push(stripNestedChildApparatus(n, src, ctx)); + } + return out; + }; + + let next = node; + if (Array.isArray(node.content)) { + const content = scrubList(node.content); + if (content !== node.content) next = { ...next, content }; + } + if (Array.isArray(node.children)) { + const children = scrubList(node.children); + if (children !== node.children) next = next === node ? { ...next, children } : { ...next, children }; + } + return next; +} + // Src-bearing apparatus inside a block, for the master-relative rewrite (below). // Anchored to the opening tag and stopping at the first `|`, so a `src=` inside an inline // `` body (e.g. a bibtex `url={…src=…}`) is never matched. @@ -274,6 +315,8 @@ function loadFileBody(src, chain, ctx, { chapterScopeBibs = false } = {}) { // file-relative — the universal including-file rule. `src` is already // master-relative here (composed up the chain), so the stamp is too. A node // spliced from a DEEPER include already carries its own (deeper) stamp — keep it. + // #472: also scrub nested meta/config inside this node (section.content, etc.). + node = stripNestedChildApparatus(node, src, ctx); body.push(srcDir && node._srcDir === undefined ? { ...node, _srcDir: srcDir } : node); } return { body, metaTitle }; diff --git a/packages/enscribe/test/child-config-strip.test.js b/packages/enscribe/test/child-config-strip.test.js new file mode 100644 index 00000000..cbb87e67 --- /dev/null +++ b/packages/enscribe/test/child-config-strip.test.js @@ -0,0 +1,72 @@ +// #472 — nested child must be stripped recursively at assembly. +// +// Top-level CHILD_STRIP_APPARATUS only skipped root kids; config-discovery walks +// the whole tree (including content arrays — PG-9). A nested inside a +// child's
.content would otherwise survive assembly and become document +// config. This pins the recursive strip on that nested-content shape. + +import assert from 'node:assert'; +import { assembleMasterDocument } from '../src/master-document/assemble.js'; + +function walkConfigs(node, out = []) { + if (!node || typeof node !== 'object') return out; + if (node.type === 'enscribeTag' && node.tagname === 'config') out.push(node); + if (Array.isArray(node.content)) for (const c of node.content) walkConfigs(c, out); + if (Array.isArray(node.children)) for (const c of node.children) walkConfigs(c, out); + return out; +} + +export function run() { + const masterTree = { + type: 'root', + children: [ + { type: 'enscribeTag', tagname: 'meta', kwargs: { type: 'book' }, content: null }, + { type: 'enscribeTag', tagname: 'config', kwargs: { 'number-sections': 'true' }, content: null }, + { type: 'enscribeTag', tagname: 'chapter', kwargs: { src: 'ch.emd' }, content: 'One' }, + ], + }; + const childTree = { + type: 'root', + children: [ + { type: 'enscribeTag', tagname: 'meta', kwargs: { title: 'One' }, content: null }, + { + type: 'enscribeTag', + tagname: 'section', + kwargs: {}, + content: [ + { type: 'paragraph', children: [{ type: 'text', value: 'before' }] }, + { type: 'enscribeTag', tagname: 'config', kwargs: { theme: 'tufte' }, content: null }, + { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, + ], + }, + ], + }; + + let parseCalls = 0; + const tree = assembleMasterDocument({ + source: 'master', + readFile: () => 'child', + resolve: (rel) => rel, + parse: (s) => { + parseCalls += 1; + return s === 'master' ? structuredClone(masterTree) : structuredClone(childTree); + }, + }); + + assert.ok(parseCalls >= 2, 'master and child were both parsed'); + + const nested = walkConfigs(tree).filter((n) => n.kwargs?.theme === 'tufte'); + assert.equal(nested.length, 0, + '#472: nested inside a child section.content must not survive assembly'); + + const masterConfigs = walkConfigs(tree).filter((n) => n.kwargs && 'number-sections' in n.kwargs); + assert.equal(masterConfigs.length, 1, 'master still survives assembly'); + + // Body around the stripped config is preserved. + const section = tree.children.find((n) => n.type === 'enscribeTag' && n.tagname === 'section'); + assert.ok(section && Array.isArray(section.content), 'child section body is present'); + assert.equal(section.content.length, 2, 'only the nested config was removed from section.content'); + assert.ok(section.content.every((n) => n.type === 'paragraph'), 'remaining content is the surrounding paragraphs'); + + console.log('PASS: #472 — nested child is stripped recursively at assembly'); +} diff --git a/packages/enscribe/test/run.js b/packages/enscribe/test/run.js index 8893d17e..840fd66c 100644 --- a/packages/enscribe/test/run.js +++ b/packages/enscribe/test/run.js @@ -79,6 +79,7 @@ import { run as runCoverOffFront } from './cover-off-front.test.js'; import { run as runEditLayout } from './edit-layout.test.js'; import { run as runThemeVariantMultipage } from './theme-variant-multipage.test.js'; import { run as runThemeConsumption } from './theme-consumption.test.js'; +import { run as runChildConfigStrip } from './child-config-strip.test.js'; import { run as runLiveBookParity } from './live-book-parity.test.js'; import { run as runBookNavConfig } from './book-nav-config.test.js'; import { run as runMasterBookLive } from './master-book-live.test.js'; @@ -191,6 +192,7 @@ const suites = [ ['edit-layout', runEditLayout], ['theme-variant-multipage', runThemeVariantMultipage], ['theme-consumption', runThemeConsumption], + ['child-config-strip', runChildConfigStrip], ['live-book-parity', runLiveBookParity], ['book-nav-config', runBookNavConfig], ['master-book-live', runMasterBookLive],