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
5 changes: 5 additions & 0 deletions .changeset/legacy-json-avoid-mutation.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions src/generators/legacy-json/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});

Expand All @@ -25,15 +28,17 @@ 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', () => {
const entries = [{ heading: { depth: 1 } }, { heading: { depth: 2 } }];
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', () => {
Expand All @@ -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);
});
});
46 changes: 17 additions & 29 deletions src/generators/legacy-json/utils/buildHierarchy.mjs
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
/**
* 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<import('../types.d.ts').HierarchizedEntry>} 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(
`Cannot find a suitable parent for entry at index ${startIdx + 1}`
);
}

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);
}

/**
Expand All @@ -37,41 +35,31 @@ 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<import('../../metadata/types').MetadataEntry>} entries
* @returns {Array<import('../types.d.ts').HierarchizedEntry>}
*/
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;
Expand Down
26 changes: 9 additions & 17 deletions src/generators/legacy-json/utils/buildSection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
};
Expand Down
Loading