diff --git a/.changeset/legacy-json-avoid-mutation.md b/.changeset/legacy-json-avoid-mutation.md new file mode 100644 index 00000000..3d5d3dc2 --- /dev/null +++ b/.changeset/legacy-json-avoid-mutation.md @@ -0,0 +1,5 @@ +--- +'@node-core/doc-kit': patch +--- + +Avoids directly mutating the AST in `legacy-json`, as to ensure future generators do not run with a input different than they expect. diff --git a/src/generators/legacy-json/types.d.ts b/src/generators/legacy-json/types.d.ts index ed92c6ce..2d9a664d 100644 --- a/src/generators/legacy-json/types.d.ts +++ b/src/generators/legacy-json/types.d.ts @@ -2,14 +2,18 @@ import { ListItem } from '@types/mdast'; import { MetadataEntry } from '../metadata/types'; /** - * Represents an entry in a hierarchical structure, extending from MetadataEntry. - * It includes children entries organized in a hierarchy. + * A node in the entry hierarchy. */ -export interface HierarchizedEntry extends MetadataEntry { +export interface HierarchizedEntry { /** - * List of child entries that are part of this entry's hierarchy. + * The metadata entry this node wraps. */ - hierarchyChildren: MetadataEntry[]; + entry: MetadataEntry; + + /** + * Child nodes nested under this entry, based on heading depth. + */ + children: HierarchizedEntry[]; } /** diff --git a/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs b/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs index d6c6441a..efd936b2 100644 --- a/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs +++ b/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs @@ -5,14 +5,17 @@ import { findParent, buildHierarchy } from '../buildHierarchy.mjs'; describe('findParent', () => { it('finds parent with lower depth', () => { - const entries = [{ heading: { depth: 1 } }, { heading: { depth: 2 } }]; - const parent = findParent(entries[1], entries, 0); - assert.equal(parent, entries[0]); + const nodes = [ + { entry: { heading: { depth: 1 } }, children: [] }, + { entry: { heading: { depth: 2 } }, children: [] }, + ]; + const parent = findParent(nodes[1].entry, nodes, 0); + assert.equal(parent, nodes[0]); }); it('throws when no parent exists', () => { - const entries = [{ heading: { depth: 2 } }]; - assert.throws(() => findParent(entries[0], entries, -1)); + const nodes = [{ entry: { heading: { depth: 2 } }, children: [] }]; + assert.throws(() => findParent(nodes[0].entry, nodes, -1)); }); }); @@ -25,6 +28,8 @@ describe('buildHierarchy', () => { const entries = [{ heading: { depth: 1 } }, { heading: { depth: 1 } }]; const result = buildHierarchy(entries); assert.equal(result.length, 2); + assert.equal(result[0].entry, entries[0]); + assert.equal(result[1].entry, entries[1]); }); it('nests children under parents', () => { @@ -32,8 +37,8 @@ describe('buildHierarchy', () => { const result = buildHierarchy(entries); assert.equal(result.length, 1); - assert.equal(result[0].hierarchyChildren.length, 1); - assert.equal(result[0].hierarchyChildren[0], entries[1]); + assert.equal(result[0].children.length, 1); + assert.equal(result[0].children[0].entry, entries[1]); }); it('handles multiple levels', () => { @@ -45,6 +50,6 @@ describe('buildHierarchy', () => { const result = buildHierarchy(entries); assert.equal(result.length, 1); - assert.equal(result[0].hierarchyChildren[0].hierarchyChildren.length, 1); + assert.equal(result[0].children[0].children.length, 1); }); }); diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/generators/legacy-json/utils/buildHierarchy.mjs index 0f79125a..1b17edec 100644 --- a/src/generators/legacy-json/utils/buildHierarchy.mjs +++ b/src/generators/legacy-json/utils/buildHierarchy.mjs @@ -1,12 +1,12 @@ /** - * Recursively finds the most suitable parent entry for a given `entry` based on heading depth. + * Recursively finds the most suitable parent node for a given `entry` based on heading depth. * * @param {import('../../metadata/types').MetadataEntry} entry - * @param {import('../../metadata/types').MetadataEntry[]} entries + * @param {Array} nodes * @param {number} startIdx * @returns {import('../types.d.ts').HierarchizedEntry} */ -export function findParent(entry, entries, startIdx) { +export function findParent(entry, nodes, startIdx) { // Base case: if we're at the beginning of the list, no valid parent exists. if (startIdx < 0) { throw new Error( @@ -14,17 +14,15 @@ export function findParent(entry, entries, startIdx) { ); } - const candidateParent = entries[startIdx]; - const candidateDepth = candidateParent.heading.depth; + const candidateParent = nodes[startIdx]; // If we find a suitable parent, return it. - if (candidateDepth < entry.heading.depth) { - candidateParent.hierarchyChildren ??= []; + if (candidateParent.entry.heading.depth < entry.heading.depth) { return candidateParent; } // Recurse upwards to find a suitable parent. - return findParent(entry, entries, startIdx - 1); + return findParent(entry, nodes, startIdx - 1); } /** @@ -37,11 +35,9 @@ export function findParent(entry, entries, startIdx) { * * If depth <= 1, it's a top-level element (aka a root). * - * If it's depth is greater than the previous entry's depth, it's a child of - * the previous entry. Otherwise (if it's less than or equal to the previous - * entry's depth), we need to find the entry that it was the greater than. We - * can do this by just looping through entries in reverse starting at the - * current index - 1. + * Otherwise, its parent is the nearest earlier entry with a lower depth, + * found by looping through entries in reverse starting at the current + * index - 1. * * @param {Array} entries * @returns {Array} @@ -49,29 +45,21 @@ export function findParent(entry, entries, startIdx) { export function buildHierarchy(entries) { const roots = []; + // Wrapper nodes, index-aligned with `entries`. + const nodes = entries.map(entry => ({ entry, children: [] })); + // Main loop to construct the hierarchy. - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - const currentDepth = entry.heading.depth; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; // Top-level entries are added directly to roots. - if (currentDepth <= 1) { - roots.push(entry); + if (node.entry.heading.depth <= 1) { + roots.push(node); continue; } // For non-root entries, find the appropriate parent. - const previousEntry = entries[i - 1]; - const previousDepth = previousEntry.heading.depth; - - if (currentDepth > previousDepth) { - previousEntry.hierarchyChildren ??= []; - previousEntry.hierarchyChildren.push(entry); - } else { - // Use recursive helper to find the nearest valid parent. - const parent = findParent(entry, entries, i - 2); - parent.hierarchyChildren.push(entry); - } + findParent(node.entry, nodes, i - 1).children.push(node); } return roots; diff --git a/src/generators/legacy-json/utils/buildSection.mjs b/src/generators/legacy-json/utils/buildSection.mjs index cf4992b2..c891cf16 100644 --- a/src/generators/legacy-json/utils/buildSection.mjs +++ b/src/generators/legacy-json/utils/buildSection.mjs @@ -32,8 +32,8 @@ export const promoteMiscChildren = (section, parent) => { */ export const createSectionBuilder = () => { /** - * Creates metadata from a hierarchized entry. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry to create metadata from. + * Creates metadata from a metadata entry. + * @param {import('../../metadata/types').MetadataEntry} entry - The entry to create metadata from. * @returns {import('../types.d.ts').Meta | undefined} The created metadata, or undefined if all fields are empty. */ const createMeta = ({ @@ -73,7 +73,7 @@ export const createSectionBuilder = () => { /** * Creates a section from an entry and its heading. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The AST entry. + * @param {import('../../metadata/types').MetadataEntry} entry - The AST entry. * @param {import('../../metadata/types').HeadingNode} head - The head node of the entry. * @returns {import('../types.d.ts').Section} The created section. */ @@ -98,7 +98,7 @@ export const createSectionBuilder = () => { * Parses stability metadata and adds it to the section. * @param {import('../types.d.ts').Section} section - The section to update. * @param {Array} nodes - The remaining AST nodes. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry providing stability information. + * @param {import('../../metadata/types').MetadataEntry} entry - The entry providing stability information. */ const parseStability = (section, nodes, { stability, content }) => { if (stability) { @@ -161,26 +161,18 @@ export const createSectionBuilder = () => { }; /** - * Processes children of a given entry and updates the section. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The current entry. - * @param {import('../types.d.ts').Section} section - The current section. - */ - const handleChildren = ({ hierarchyChildren }, section) => - hierarchyChildren?.forEach(child => handleEntry(child, section)); - - /** - * Handles an entry and updates the parent section. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry to process. + * Handles a hierarchy node and updates the parent section. + * @param {import('../types.d.ts').HierarchizedEntry} node - The hierarchy node to process. * @param {import('../types.d.ts').Section} parent - The parent section. */ - const handleEntry = (entry, parent) => { + const handleEntry = ({ entry, children }, parent) => { const [headingNode, ...nodes] = entry.content.children; const section = createSection(entry, headingNode); parseStability(section, nodes, entry); parseList(section, nodes); addDescription(section, nodes); - handleChildren(entry, section); + children.forEach(child => handleEntry(child, section)); addAdditionalMetadata(section, parent, headingNode); addToParent(section, parent); promoteMiscChildren(section, parent); @@ -200,7 +192,7 @@ export const createSectionBuilder = () => { source: `doc/api/${head.api}.md`, }; - buildHierarchy(entries).forEach(entry => handleEntry(entry, rootModule)); + buildHierarchy(entries).forEach(node => handleEntry(node, rootModule)); return rootModule; };