Skip to content
Open
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
43 changes: 43 additions & 0 deletions packages/enscribe/src/master-document/assemble.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,47 @@ const DEFERRED_MARKERS = new Set(['toc', 'endnotes', 'endnote-list']);
// so its <library> sources join the project-wide citation registry (#190).
const CHILD_STRIP_APPARATUS = new Set(['meta', 'config']);

/**
* #472: strip nested child `<meta>`/`<config>` recursively.
*
* Top-level strip alone is not enough — config-discovery walks the whole tree
* (including `content` arrays), so a `<config>` nested inside a child's
* `<section>` 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 <config strict-mode> 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 <data> block, for the master-relative rewrite (below).
// Anchored to the opening tag and stopping at the first `|`, so a `src=` inside an inline
// `<library bibtex | …>` body (e.g. a bibtex `url={…src=…}`) is never matched.
Expand Down Expand Up @@ -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 };
Expand Down
72 changes: 72 additions & 0 deletions packages/enscribe/test/child-config-strip.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// #472 — nested child <config> 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 <config> nested inside a
// child's <section>.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 <config theme=tufte> 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 <config number-sections> 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 <config> is stripped recursively at assembly');
}
2 changes: 2 additions & 0 deletions packages/enscribe/test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down