diff --git a/README.md b/README.md index 8f02bf02..1b623e04 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,6 @@ Options: -o, --output Specify the relative or absolute output directory -v, --version Specify the target version of Node.js, semver compliant (default: "v22.6.0") -c, --changelog Specify the path (file: or https://) to the CHANGELOG.md file (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") - -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page") + -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page", "legacy-json", "legacy-json-all") -h, --help display help for command ``` diff --git a/shiki.config.mjs b/shiki.config.mjs index d53dddcc..aab14243 100644 --- a/shiki.config.mjs +++ b/shiki.config.mjs @@ -30,7 +30,7 @@ export default { // Only register the languages that the API docs use // and override the JavaScript language with the aliases langs: [ - { ...javaScriptLanguage[0], aliases: ['mjs', 'cjs', 'js'] }, + ...httpLanguage, ...jsonLanguage, ...typeScriptLanguage, ...shellScriptLanguage, @@ -40,7 +40,7 @@ export default { ...diffLanguage, ...cLanguage, ...cPlusPlusLanguage, - ...httpLanguage, ...coffeeScriptLanguage, + { ...javaScriptLanguage[0], aliases: ['mjs', 'cjs', 'js'] }, ], }; diff --git a/src/constants.mjs b/src/constants.mjs index a44763f5..5bb7f0b9 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -58,7 +58,14 @@ export const DOC_API_SLUGS_REPLACEMENTS = [ // is a specific type of API Doc entry (e.g., Event, Class, Method, etc) // and to extract the inner content of said Heading to be used as the API doc entry name export const DOC_API_HEADING_TYPES = [ - { type: 'method', regex: /^`?([A-Z]\w+(?:\.[A-Z]\w+)*\.\w+)\([^)]*\)`?$/i }, + { + type: 'method', + regex: + // Group 1: foo[bar]() + // Group 2: foo.bar() + // Group 3: foobar() + /^`?(?:\w*(?:(\[[^\]]+\])|(?:\.(\w+)))|(\w+))\([^)]*\)`?$/i, + }, { type: 'event', regex: /^Event: +`?['"]?([^'"]+)['"]?`?$/i }, { type: 'class', @@ -71,11 +78,13 @@ export const DOC_API_HEADING_TYPES = [ }, { type: 'classMethod', - regex: /^Static method: +`?([A-Z]\w+(?:\.[A-Z]\w+)*\.\w+)\([^)]*\)`?$/i, + regex: + /^Static method: +`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))\([^)]*\)`?$/i, }, { type: 'property', - regex: /^(?:Class property: +)?`?([A-Z]\w+(?:\.[A-Z]\w+)*\.\w+)`?$/i, + regex: + /^(?:Class property: +)?`?[A-Z]\w+(?:\.[A-Z]\w+)*(?:(\[\w+\.\w+\])|\.(\w+))`?$/i, }, ]; diff --git a/src/generators/index.mjs b/src/generators/index.mjs index 6c8c835e..f787a2b2 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -4,10 +4,14 @@ import jsonSimple from './json-simple/index.mjs'; import legacyHtml from './legacy-html/index.mjs'; import legacyHtmlAll from './legacy-html-all/index.mjs'; import manPage from './man-page/index.mjs'; +import legacyJson from './legacy-json/index.mjs'; +import legacyJsonAll from './legacy-json-all/index.mjs'; export default { 'json-simple': jsonSimple, 'legacy-html': legacyHtml, 'legacy-html-all': legacyHtmlAll, 'man-page': manPage, + 'legacy-json': legacyJson, + 'legacy-json-all': legacyJsonAll, }; diff --git a/src/generators/legacy-html/assets/api.js b/src/generators/legacy-html/assets/api.js index a2e3c5fb..7bb67a21 100644 --- a/src/generators/legacy-html/assets/api.js +++ b/src/generators/legacy-html/assets/api.js @@ -165,8 +165,6 @@ let code = ''; - console.log(parentNode); - if (flavorToggle) { if (flavorToggle.checked) { code = parentNode.querySelector('.mjs').textContent; diff --git a/src/generators/legacy-json-all/index.mjs b/src/generators/legacy-json-all/index.mjs new file mode 100644 index 00000000..9bb36310 --- /dev/null +++ b/src/generators/legacy-json-all/index.mjs @@ -0,0 +1,68 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * This generator consolidates data from the `legacy-json` generator into a single + * JSON file (`all.json`). + * + * @typedef {Array} Input + * + * @type {import('../types.d.ts').GeneratorMetadata} + */ +export default { + name: 'legacy-json-all', + + version: '1.0.0', + + description: + 'Generates the `all.json` file from the `legacy-json` generator, which includes all the modules in one single file.', + + dependsOn: 'legacy-json', + + /** + * Generates the legacy JSON `all.json` file. + * + * @param {Input} input + * @param {Partial} options + */ + async generate(input, { output }) { + /** + * The consolidated output object that will contain + * combined data from all sections in the input. + * + * @type {import('./types.d.ts').Output} + */ + const generatedValue = { + miscs: [], + modules: [], + classes: [], + globals: [], + methods: [], + }; + + const propertiesToCopy = [ + 'miscs', + 'modules', + 'classes', + 'globals', + 'methods', + ]; + + input.forEach(section => { + // Copy the relevant properties from each section into our output + propertiesToCopy.forEach(property => { + if (section[property]) { + generatedValue[property].push(...section[property]); + } + }); + }); + + if (output) { + await writeFile(join(output, 'all.json'), JSON.stringify(generatedValue)); + } + + return generatedValue; + }, +}; diff --git a/src/generators/legacy-json-all/types.d.ts b/src/generators/legacy-json-all/types.d.ts new file mode 100644 index 00000000..0748a319 --- /dev/null +++ b/src/generators/legacy-json-all/types.d.ts @@ -0,0 +1,14 @@ +import { + MiscSection, + Section, + SignatureSection, + ModuleSection, +} from '../legacy-json/types'; + +export interface Output { + miscs: Array; + modules: Array
; + classes: Array; + globals: Array; + methods: Array; +} diff --git a/src/generators/legacy-json/constants.mjs b/src/generators/legacy-json/constants.mjs new file mode 100644 index 00000000..cdfd3647 --- /dev/null +++ b/src/generators/legacy-json/constants.mjs @@ -0,0 +1,36 @@ +// Grabs a method's return value +export const RETURN_EXPRESSION = /^returns?\s*:?\s*/i; + +// Grabs a method's name +export const NAME_EXPRESSION = /^['`"]?([^'`": {]+)['`"]?\s*:?\s*/; + +// Denotes a method's type +export const TYPE_EXPRESSION = /^\{([^}]+)\}\s*/; + +// Checks if there's a leading hyphen +export const LEADING_HYPHEN = /^-\s*/; + +// Grabs the default value if present +export const DEFAULT_EXPRESSION = /\s*\*\*Default:\*\*\s*([^]+)$/i; + +// Grabs the parameters from a method's signature +// ex/ 'new buffer.Blob([sources[, options]])'.match(PARAM_EXPRESSION) === ['([sources[, options]])', '[sources[, options]]'] +export const PARAM_EXPRESSION = /\((.+)\);?$/; + +// The plurals associated with each section type. +export const SECTION_TYPE_PLURALS = { + module: 'modules', + misc: 'miscs', + class: 'classes', + method: 'methods', + property: 'properties', + global: 'globals', + example: 'examples', + ctor: 'signatures', + classMethod: 'classMethods', + event: 'events', + var: 'vars', +}; + +// The keys to not promote when promoting children. +export const UNPROMOTED_KEYS = ['textRaw', 'name', 'type', 'desc', 'miscs']; diff --git a/src/generators/legacy-json/index.mjs b/src/generators/legacy-json/index.mjs new file mode 100644 index 00000000..8cf70e88 --- /dev/null +++ b/src/generators/legacy-json/index.mjs @@ -0,0 +1,77 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { groupNodesByModule } from '../../utils/generators.mjs'; +import { createSectionBuilder } from './utils/buildSection.mjs'; + +/** + * This generator is responsible for generating the legacy JSON files for the + * legacy API docs for retro-compatibility. It is to be replaced while we work + * on the new schema for this file. + * + * This is a top-level generator, intaking the raw AST tree of the api docs. + * It generates JSON files to the specified output directory given by the + * config. + * + * @typedef {Array} Input + * + * @type {import('../types.d.ts').GeneratorMetadata} + */ +export default { + name: 'legacy-json', + + version: '1.0.0', + + description: 'Generates the legacy version of the JSON API docs.', + + dependsOn: 'ast', + + /** + * Generates a legacy JSON file. + * + * @param {Input} input + * @param {Partial} options + */ + async generate(input, { output }) { + const buildSection = createSectionBuilder(); + + // This array holds all the generated values for each module + const generatedValues = []; + + const groupedModules = groupNodesByModule(input); + + // Gets the first nodes of each module, which is considered the "head" + const headNodes = input.filter(node => node.heading.depth === 1); + + /** + * @param {ApiDocMetadataEntry} head + * @returns {import('./types.d.ts').ModuleSection} + */ + const processModuleNodes = head => { + const nodes = groupedModules.get(head.api); + + const section = buildSection(head, nodes); + generatedValues.push(section); + + return section; + }; + + await Promise.all( + headNodes.map(async node => { + // Get the json for the node's section + const section = processModuleNodes(node); + + // Write it to the output file + if (output) { + await writeFile( + join(output, `${node.api}.json`), + JSON.stringify(section) + ); + } + }) + ); + + return generatedValues; + }, +}; diff --git a/src/generators/legacy-json/types.d.ts b/src/generators/legacy-json/types.d.ts new file mode 100644 index 00000000..85f83d75 --- /dev/null +++ b/src/generators/legacy-json/types.d.ts @@ -0,0 +1,275 @@ +import { ListItem } from 'mdast'; + +/** + * Represents an entry in a hierarchical structure, extending from ApiDocMetadataEntry. + * It includes children entries organized in a hierarchy. + */ +export interface HierarchizedEntry extends ApiDocMetadataEntry { + /** + * List of child entries that are part of this entry's hierarchy. + */ + hierarchyChildren: ApiDocMetadataEntry[]; +} + +/** + * Contains metadata related to changes, additions, removals, and deprecated statuses of an entry. + */ +export interface Meta { + /** + * A list of changes associated with the entry. + */ + changes: ApiDocMetadataChange[]; + + /** + * A list of added versions or entities for the entry. + */ + added: string[]; + + /** + * A list of NAPI (Node API) versions related to the entry. + */ + napiVersion: string[]; + + /** + * A list of versions where the entry was deprecated. + */ + deprecated: string[]; + + /** + * A list of versions where the entry was removed. + */ + removed: string[]; +} + +/** + * Base interface for sections in the API documentation, representing common properties. + */ +export interface SectionBase { + /** + * The type of section (e.g., 'module', 'method', 'property'). + */ + type: string; + + /** + * The name of the section. + */ + name: string; + + /** + * Raw text content associated with the section. + */ + textRaw: string; + + /** + * Display name of the section. + */ + displayName?: string; + + /** + * A detailed description of the section. + */ + desc: string; + + /** + * A brief description of the section. + */ + shortDesc?: string; + + /** + * Stability index of the section. + */ + stability?: number; + + /** + * Descriptive text related to the stability of the section (E.G. "Experimental"). + */ + stabilityText?: string; + + /** + * Metadata associated with the section. + */ + meta: Meta; +} + +/** + * Represents a module section, which can contain other modules, classes, methods, properties, and other sections. + */ +export interface ModuleSection extends SectionBase { + /** + * The type of section. Always 'module' for this interface. + */ + type: 'module'; + + /** + * Source of the module (File path). + */ + source: string; + + /** + * Miscellaneous sections associated with the module. + */ + miscs?: MiscSection[]; + + /** + * Submodules within this module. + */ + modules?: ModuleSection[]; + + /** + * Classes within this module. + */ + classes?: SignatureSection[]; + + /** + * Methods within this module. + */ + methods?: MethodSignature[]; + + /** + * Properties within this module. + */ + properties?: PropertySection[]; + + /** + * Global definitions associated with the module. + */ + globals?: ModuleSection | { type: 'global' }; + + /** + * Signatures (e.g., functions, methods) associated with this module. + */ + signatures?: SignatureSection[]; +} + +/** + * Represents a signature section for methods, constructors, or classes. + */ +export interface SignatureSection extends SectionBase { + /** + * The type of section. It can be one of 'class', 'ctor' (constructor), 'classMethod', or 'method'. + */ + type: 'class' | 'ctor' | 'classMethod' | 'method'; + + /** + * A list of method signatures within this section. + */ + signatures: MethodSignature[]; +} + +/** + * All possible types of sections. + */ +export type Section = + | SignatureSection + | PropertySection + | EventSection + | MiscSection; + +/** + * Represents a parameter for methods or functions. + */ +export interface Parameter { + /** + * The name of the parameter. + */ + name: string; + + /** + * Indicates if the parameter is optional. + */ + optional?: boolean; + + /** + * The default value for the parameter. + */ + default?: string; +} + +/** + * Represents a method signature, including its parameters and return type. + */ +export interface MethodSignature { + /** + * A list of parameters for the method. + */ + params: Parameter[]; + + /** + * The return type of the method. + */ + return?: string; +} + +/** + * Represents a property section in the API documentation. + */ +export interface PropertySection extends SectionBase { + /** + * The type of section. Always 'property' for this interface. + */ + type: 'property'; + + /** + * Arbitrary key-value pairs for the property. + */ + [key: string]: string | undefined; +} + +/** + * Represents an event section, typically containing event parameters. + */ +export interface EventSection extends SectionBase { + /** + * The type of section. Always 'event' for this interface. + */ + type: 'event'; + + /** + * A list of parameters associated with the event. + */ + params: ListItem[]; +} + +/** + * Represents a miscellaneous section with arbitrary content. + */ +export interface MiscSection extends SectionBase { + /** + * The type of section. Always 'misc' for this interface. + */ + type: 'misc'; + + [key: string]: string | undefined; +} + +/** + * Represents a list of parameters. + */ +export interface ParameterList { + /** + * Raw parameter description + */ + textRaw: string; + + /** + * A short description of the parameter. + */ + desc?: string; + + /** + * The name of the parameter. + */ + name: string; + + /** + * The type of the parameter (E.G. string, boolean). + */ + type?: string; + + /** + * The default value. + */ + default?: string; + + options?: ParameterList; +} diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/generators/legacy-json/utils/buildHierarchy.mjs new file mode 100644 index 00000000..ed2b4143 --- /dev/null +++ b/src/generators/legacy-json/utils/buildHierarchy.mjs @@ -0,0 +1,78 @@ +/** + * Recursively finds the most suitable parent entry for a given `entry` based on heading depth. + * + * @param {ApiDocMetadataEntry} entry + * @param {ApiDocMetadataEntry[]} entries + * @param {number} startIdx + * @returns {import('../types.d.ts').HierarchizedEntry} + */ +function findParent(entry, entries, 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; + + // If we find a suitable parent, return it. + if (candidateDepth < entry.heading.depth) { + candidateParent.hierarchyChildren ??= []; + return candidateParent; + } + + // Recurse upwards to find a suitable parent. + return findParent(entry, entries, startIdx - 1); +} + +/** + * We need the files to be in a hierarchy based off of depth, but they're + * given to us flattened. So, let's fix that. + * + * Assuming that {@link entries} is in the same order as the elements are in + * the markdown, we can use the entry's depth property to reassemble the + * hierarchy. + * + * 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. + * + * @param {Array} entries + * @returns {Array} + */ +export function buildHierarchy(entries) { + const roots = []; + + // Main loop to construct the hierarchy. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const currentDepth = entry.heading.depth; + + // Top-level entries are added directly to roots. + if (currentDepth <= 1) { + roots.push(entry); + 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); + } + } + + return roots; +} diff --git a/src/generators/legacy-json/utils/buildSection.mjs b/src/generators/legacy-json/utils/buildSection.mjs new file mode 100644 index 00000000..c284286b --- /dev/null +++ b/src/generators/legacy-json/utils/buildSection.mjs @@ -0,0 +1,175 @@ +import { buildHierarchy } from './buildHierarchy.mjs'; +import { getRemarkRehype } from '../../../utils/remark.mjs'; +import { transformNodesToString } from '../../../utils/unist.mjs'; +import { parseList } from './parseList.mjs'; +import { SECTION_TYPE_PLURALS, UNPROMOTED_KEYS } from '../constants.mjs'; + +/** + * Converts a value to an array. + * @template T + * @param {T | T[]} val - The value to convert. + * @returns {T[]} The value as an array. + */ +const enforceArray = val => (Array.isArray(val) ? val : [val]); + +/** + * + */ +export const createSectionBuilder = () => { + const html = getRemarkRehype(); + + /** + * Creates metadata from a hierarchized entry. + * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry to create metadata from. + * @returns {import('../types.d.ts').Meta} The created metadata. + */ + const createMeta = ({ + added_in = [], + n_api_version = [], + deprecated_in = [], + removed_in = [], + changes, + }) => ({ + changes, + added: enforceArray(added_in), + napiVersion: enforceArray(n_api_version), + deprecated: enforceArray(deprecated_in), + removed: enforceArray(removed_in), + }); + + /** + * Creates a section from an entry and its heading. + * @param {import('../types.d.ts').HierarchizedEntry} entry - The AST entry. + * @param {HeadingMetadataParent} head - The head node of the entry. + * @returns {import('../types.d.ts').Section} The created section. + */ + const createSection = (entry, head) => ({ + textRaw: transformNodesToString(head.children), + name: head.data.name, + type: head.data.type, + meta: createMeta(entry), + introduced_in: entry.introduced_in, + }); + + /** + * 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. + */ + const parseStability = (section, nodes, { stability }) => { + const stabilityInfo = stability.toJSON()?.[0]; + + if (stabilityInfo) { + section.stability = stabilityInfo.index; + section.stabilityText = stabilityInfo.description; + nodes.shift(); // Remove stability node from processing + } + }; + + /** + * Adds a description to the section. + * @param {import('../types.d.ts').Section} section - The section to update. + * @param {Array} nodes - The remaining AST nodes. + */ + const addDescription = (section, nodes) => { + if (!nodes.length) { + return; + } + + const rendered = html.stringify( + html.runSync({ type: 'root', children: nodes }) + ); + + section.shortDesc = section.desc || undefined; + section.desc = rendered || undefined; + }; + + /** + * Adds additional metadata to the section based on its type. + * @param {import('../types.d.ts').Section} section - The section to update. + * @param {import('../types.d.ts').Section} parent - The parent section. + * @param {import('../../types.d.ts').NodeWithData} heading - The heading node of the section. + */ + const addAdditionalMetadata = (section, parent, heading) => { + if (!section.type) { + section.name = section.textRaw.toLowerCase().trim().replace(/\s+/g, '_'); + section.displayName = heading.data.name; + section.type = parent.type === 'misc' ? 'misc' : 'module'; + } + }; + + /** + * Adds the section to its parent section. + * @param {import('../types.d.ts').Section} section - The section to add. + * @param {import('../types.d.ts').Section} parent - The parent section. + */ + const addToParent = (section, parent) => { + const key = SECTION_TYPE_PLURALS[section.type] || 'miscs'; + + parent[key] ??= []; + parent[key].push(section); + }; + + /** + * Promotes children properties to the parent level if the section type is 'misc'. + * @param {import('../types.d.ts').Section} section - The section to promote. + * @param {import('../types.d.ts').Section} parent - The parent section. + */ + const promoteMiscChildren = (section, parent) => { + // Only promote if the current section is of type 'misc' and the parent is not 'misc' + if (section.type === 'misc' && parent.type !== 'misc') { + Object.entries(section).forEach(([key, value]) => { + // Only promote certain keys + if (!UNPROMOTED_KEYS.includes(key)) { + // Merge the section's properties into the parent section + parent[key] = parent[key] + ? // If the parent already has this key, concatenate the values + [].concat(parent[key], value) + : // Otherwise, directly assign the section's value to the parent + []; + } + }); + } + }; + + /** + * 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. + * @param {import('../types.d.ts').Section} parent - The parent section. + */ + const handleEntry = (entry, parent) => { + const [headingNode, ...nodes] = structuredClone(entry.content.children); + const section = createSection(entry, headingNode); + + parseStability(section, nodes, entry); + parseList(section, nodes); + addDescription(section, nodes); + handleChildren(entry, section); + addAdditionalMetadata(section, parent, headingNode); + addToParent(section, parent); + promoteMiscChildren(section, parent); + }; + + /** + * Builds the module section from head metadata and entries. + * @param {ApiDocMetadataEntry} head - The head metadata entry. + * @param {Array} entries - The list of metadata entries. + * @returns {import('../types.d.ts').ModuleSection} The constructed module section. + */ + return (head, entries) => { + const rootModule = { type: 'module', source: head.api_doc_source }; + + buildHierarchy(entries).forEach(entry => handleEntry(entry, rootModule)); + + return rootModule; + }; +}; diff --git a/src/generators/legacy-json/utils/parseList.mjs b/src/generators/legacy-json/utils/parseList.mjs new file mode 100644 index 00000000..c4884ff0 --- /dev/null +++ b/src/generators/legacy-json/utils/parseList.mjs @@ -0,0 +1,122 @@ +import { + DEFAULT_EXPRESSION, + LEADING_HYPHEN, + NAME_EXPRESSION, + RETURN_EXPRESSION, + TYPE_EXPRESSION, +} from '../constants.mjs'; +import parseSignature from './parseSignature.mjs'; +import { transformNodesToString } from '../../../utils/unist.mjs'; + +/** + * Modifies type references in a string by replacing template syntax (`<...>`) with curly braces `{...}` + * and normalizing formatting. + * @param {string} string + * @returns {string} + */ +function transformTypeReferences(string) { + return string.replace(/`<([^>]+)>`/g, '{$1}').replaceAll('} | {', '|'); +} + +/** + * Extracts and removes a specific pattern from a text string while storing the result in a key of the `current` object. + * @param {string} text + * @param {RegExp} pattern + * @param {string} key + * @param {Object} current + * @returns {string} + */ +const extractPattern = (text, pattern, key, current) => { + const match = text.match(pattern)?.[1]?.trim().replace(/\.$/, ''); + + if (!match) { + return text; + } + + current[key] = match; + return text.replace(pattern, ''); +}; + +/** + * Parses an individual list item node to extract its properties + * + * @param {import('mdast').ListItem} child + * @returns {import('../types').ParameterList} + */ +function parseListItem(child) { + const current = {}; + + // Extract and clean raw text from the node, excluding nested lists + current.textRaw = transformTypeReferences( + transformNodesToString(child.children.filter(node => node.type !== 'list')) + .replace(/\s+/g, ' ') + .replace(//gs, '') + ); + + let text = current.textRaw; + + // Identify return items or extract key properties (name, type, default) from the text + if (RETURN_EXPRESSION.test(text)) { + current.name = 'return'; + text = text.replace(RETURN_EXPRESSION, ''); + } else { + text = extractPattern(text, NAME_EXPRESSION, 'name', current); + } + + text = extractPattern(text, TYPE_EXPRESSION, 'type', current); + text = extractPattern(text, DEFAULT_EXPRESSION, 'default', current); + + // Set the remaining text as the description, removing any leading hyphen + current.desc = text.replace(LEADING_HYPHEN, '').trim() || undefined; + + // Parse nested lists (options) recursively if present + const optionsNode = child.children.find(node => node.type === 'list'); + if (optionsNode) { + current.options = optionsNode.children.map(parseListItem); + } + + return current; +} + +/** + * Parses a list of nodes and updates the corresponding section object with the extracted information. + * Handles different section types such as methods, properties, and events differently. + * @param {import('../types').Section} section + * @param {import('mdast').RootContent[]} nodes + */ +export function parseList(section, nodes) { + const list = nodes[0]?.type === 'list' ? nodes.shift() : null; + + const values = list ? list.children.map(parseListItem) : []; + + // Update the section based on its type and parsed values + switch (section.type) { + case 'ctor': + case 'classMethod': + case 'method': + // For methods and constructors, parse and attach signatures + section.signatures = [parseSignature(section.textRaw, values)]; + break; + + case 'property': + // For properties, update type and other details if values exist + if (values.length) { + const { type, ...rest } = values[0]; + section.type = type; + Object.assign(section, rest); + section.textRaw = `\`${section.name}\` ${section.textRaw}`; + } + break; + + case 'event': + // For events, assign parsed values as parameters + section.params = values; + break; + + default: + // If no specific handling, re-add the list for further processing + if (list) { + nodes.unshift(list); + } + } +} diff --git a/src/generators/legacy-json/utils/parseSignature.mjs b/src/generators/legacy-json/utils/parseSignature.mjs new file mode 100644 index 00000000..9c17289b --- /dev/null +++ b/src/generators/legacy-json/utils/parseSignature.mjs @@ -0,0 +1,207 @@ +'use strict'; + +import { PARAM_EXPRESSION } from '../constants.mjs'; + +const OPTIONAL_LEVEL_CHANGES = { '[': 1, ']': -1, ' ': 0 }; + +/** + * @param {String} char + * @param {Number} depth + * @returns {Number} + */ +const updateDepth = (char, depth) => + depth + (OPTIONAL_LEVEL_CHANGES[char] || 0); + +/** + * @param {string} parameterName + * @param {number} optionalDepth + * @returns {[string, number, boolean]} + */ +function parseNameAndOptionalStatus(parameterName, optionalDepth) { + // Let's check if the parameter is optional & grab its name at the same time. + // We need to see if there's any leading brackets in front of the parameter + // name. While we're doing that, we can also get the index where the + // parameter's name actually starts at. + + // Find the starting index where the name begins + const startingIdx = [...parameterName].findIndex( + char => !OPTIONAL_LEVEL_CHANGES[char] + ); + + // Update optionalDepth based on leading brackets + optionalDepth = [...parameterName.slice(0, startingIdx)].reduce( + updateDepth, + optionalDepth + ); + + // Find the ending index where the name ends + const endingIdx = [...parameterName].findLastIndex( + char => !OPTIONAL_LEVEL_CHANGES[char] + ); + + // Update optionalDepth based on trailing brackets + optionalDepth = [...parameterName.slice(endingIdx + 1)].reduce( + updateDepth, + optionalDepth + ); + + // Extract the actual parameter name + const actualName = parameterName.slice(startingIdx, endingIdx + 1); + const isParameterOptional = optionalDepth > 0; + + return [actualName, optionalDepth, isParameterOptional]; +} + +/** + * @param {string} parameterName + * @returns {[string, string | undefined]} + */ +function parseDefaultValue(parameterName) { + /** + * @type {string | undefined} + */ + let defaultValue; + const equalSignPos = parameterName.indexOf('='); + + if (equalSignPos !== -1) { + // We do have a default value, let's extract it + defaultValue = parameterName.substring(equalSignPos).trim(); + + // Let's remove the default value from the parameter name + parameterName = parameterName.substring(0, equalSignPos); + } + + return [parameterName, defaultValue]; +} + +/** + * @param {string} parameterName + * @param {number} index + * @param {Array} markdownParameters + * @returns {import('../types.d.ts').Parameter} + */ +function findParameter(parameterName, index, markdownParameters) { + const parameter = markdownParameters[index]; + if (parameter?.name === parameterName) { + return parameter; + } + + // Method likely has multiple signatures, something like + // `new Console(stdout[, stderr][, ignoreErrors])` and `new Console(options)` + // Try to find the parameter that this is being shared with + for (const property of markdownParameters) { + if (property.name === parameterName) { + return property; + } + + const matchingOption = property.options?.find( + option => option.name === parameterName + ); + if (matchingOption) { + return { ...matchingOption }; + } + } + + // Default return if no matches are found + return { name: parameterName }; +} + +/** + * @param {string[]} declaredParameters + * @param {Array} markdownParameters + */ +function parseParameters(declaredParameters, markdownParameters) { + /** + * @type {Array} + */ + let parameters = []; + + let optionalDepth = 0; + + declaredParameters.forEach((parameterName, i) => { + /** + * @example 'length]]' + * @example 'arrayBuffer[' + * @example '[sources[' + * @example 'end' + */ + parameterName = parameterName.trim(); + + // We need to do three things here: + // 1. Determine the declared parameters' name + // 2. Determine if the parameter is optional + // 3. Determine if the parameter has a default value + + /** + * This will handle the first and second thing for us + * @type {boolean} + */ + let isParameterOptional; + [parameterName, optionalDepth, isParameterOptional] = + parseNameAndOptionalStatus(parameterName, optionalDepth); + + /** + * Now let's work on the third thing + * @type {string | undefined} + */ + let defaultValue; + [parameterName, defaultValue] = parseDefaultValue(parameterName); + + const parameter = findParameter(parameterName, i, markdownParameters); + + if (isParameterOptional) { + parameter.optional = true; + } + + if (defaultValue) { + parameter.default = defaultValue; + } + + parameters.push(parameter); + }); + + return parameters; +} + +/** + * @param {string} textRaw Something like `new buffer.Blob([sources[, options]])` + * @param {Array { + /** + * @type {import('../types.d.ts').MethodSignature} + */ + const signature = { params: [] }; + + // Find the return value & filter it out + markdownParameters = markdownParameters.filter(value => { + if (value.name === 'return') { + signature.return = value; + return false; + } + + return true; + }); + + /** + * Extract the parameters from the method's declaration + * @example `[sources[, options]]` + */ + let [, declaredParameters] = + textRaw.substring(1, textRaw.length - 1).match(PARAM_EXPRESSION) || []; + + if (!declaredParameters) { + return signature; + } + + /** + * @type {string[]} + * @example ['sources[,', 'options]]'] + */ + declaredParameters = declaredParameters.split(','); + + signature.params = parseParameters(declaredParameters, markdownParameters); + + return signature; +}; diff --git a/src/metadata.mjs b/src/metadata.mjs index e29733bc..579061f1 100644 --- a/src/metadata.mjs +++ b/src/metadata.mjs @@ -113,6 +113,7 @@ const createMetadata = slugger => { const { type, + introduced_in, added, deprecated, removed, @@ -146,6 +147,7 @@ const createMetadata = slugger => { api: apiDoc.stem, slug: sectionSlug, source_link, + api_doc_source: `doc/api/${apiDoc.basename}`, added_in: added, deprecated_in: deprecated, removed_in: removed, @@ -156,6 +158,7 @@ const createMetadata = slugger => { stability: internalMetadata.stability, content: section, tags, + introduced_in, }; }, }; diff --git a/src/parser.mjs b/src/parser.mjs index 3c05b64f..c4c34347 100644 --- a/src/parser.mjs +++ b/src/parser.mjs @@ -140,8 +140,8 @@ const createParser = () => { // Visits all Text nodes from the current subtree and if there's any that matches // any API doc type reference and then updates the type reference to be a Markdown link - visit(subTree, createQueries.UNIST.isTextWithType, node => - updateTypeReference(node) + visit(subTree, createQueries.UNIST.isTextWithType, (node, _, parent) => + updateTypeReference(node, parent) ); // Removes already parsed items from the subtree so that they aren't included in the final content diff --git a/src/queries.mjs b/src/queries.mjs index 33979a20..6ef59e11 100644 --- a/src/queries.mjs +++ b/src/queries.mjs @@ -12,12 +12,14 @@ import { parseYAMLIntoMetadata, transformTypeToReferenceLink, } from './utils/parser.mjs'; +import { getRemark } from './utils/remark.mjs'; /** * Creates an instance of the Query Manager, which allows to do multiple sort * of metadata and content metadata manipulation within an API Doc */ const createQueries = () => { + const remark = getRemark(); /** * Sanitizes the YAML source by returning the inner YAML content * and then parsing it into an API Metadata object and updating the current Metadata @@ -71,15 +73,30 @@ const createQueries = () => { * into a Markdown link referencing to the correct API docs * * @param {import('mdast').Text} node A Markdown link node + * @param {import('mdast').Parent} parent The parent node */ - const updateTypeReference = node => { - const replacedTypes = node.value.replace( - createQueries.QUERIES.normalizeTypes, - transformTypeToReferenceLink - ); - - node.type = 'html'; - node.value = replacedTypes; + const updateTypeReference = (node, parent) => { + const replacedTypes = node.value + .replace( + createQueries.QUERIES.normalizeTypes, + transformTypeToReferenceLink + ) + // Remark doesn't handle leading / trailing spaces, so replace them with + // HTML entities. + .replace(/^\s/, ' ') + .replace(/\s$/, ' '); + + // This changes the type into a link by splitting it up into several nodes, + // and adding those nodes to the parent. + const { + children: [newNode], + } = remark.parse(replacedTypes); + + // Find the index of the original node in the parent + const index = parent.children.indexOf(node); + + // Replace the original node with the new node(s) + parent.children.splice(index, 1, ...newNode.children); return [SKIP]; }; diff --git a/src/test/metadata.test.mjs b/src/test/metadata.test.mjs index 3612f126..96c20819 100644 --- a/src/test/metadata.test.mjs +++ b/src/test/metadata.test.mjs @@ -66,11 +66,13 @@ describe('createMetadata', () => { const expected = { added_in: undefined, api: 'test', + api_doc_source: 'doc/api/test.md', changes: [], content: section, deprecated_in: undefined, heading, n_api_version: undefined, + introduced_in: undefined, removed_in: undefined, slug: 'test-heading', source_link: 'test.com', diff --git a/src/test/queries.test.mjs b/src/test/queries.test.mjs index b8de7ca2..398b190d 100644 --- a/src/test/queries.test.mjs +++ b/src/test/queries.test.mjs @@ -17,21 +17,32 @@ describe('createQueries', () => { // valid type it('should update type to reference correctly', () => { const queries = createQueries(); - const node = { value: 'This is a {string} type.' }; - queries.updateTypeReference(node); - strictEqual(node.type, 'html'); - strictEqual( - node.value, - 'This is a [``](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) type.' + const node = { + value: 'This is a {string} type.', + position: { start: 0, end: 0 }, + }; + const parent = { children: [node] }; + queries.updateTypeReference(node, parent); + deepStrictEqual( + parent.children.map(c => c.value), + [ + 'This is a ', + undefined, // link + ' type.', + ] ); }); it('should update type to reference not correctly if no match', () => { const queries = createQueries(); - const node = { value: 'This is a {test} type.' }; - queries.updateTypeReference(node); - strictEqual(node.type, 'html'); - strictEqual(node.value, 'This is a {test} type.'); + const node = { + value: 'This is a {test} type.', + position: { start: 0, end: 0 }, + }; + const parent = { children: [node] }; + queries.updateTypeReference(node, parent); + strictEqual(parent.children[0].type, 'text'); + strictEqual(parent.children[0].value, 'This is a {test} type.'); }); it('should add heading metadata correctly', () => { diff --git a/src/types.d.ts b/src/types.d.ts index 1391750b..f40eb7fe 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -70,6 +70,8 @@ declare global { slug: string; // The GitHub URL to the source of the API entry source_link: string | Array | undefined; + // Path to the api doc file relative to the root of the nodejs repo root (ex/ `doc/api/addons.md`) + api_doc_source: string; // When a said API section got added (in which version(s) of Node.js) added_in: string | Array | undefined; // When a said API section got removed (in which version(s) of Node.js) diff --git a/src/utils/parser.mjs b/src/utils/parser.mjs index dea0dbb1..42458064 100644 --- a/src/utils/parser.mjs +++ b/src/utils/parser.mjs @@ -123,10 +123,16 @@ export const parseHeadingIntoMetadata = (heading, depth) => { // Attempts to get a match from one of the heading types, if a match is found // we use that type as the heading type, and extract the regex expression match group // which should be the inner "plain" heading content (or the title of the heading for navigation) - const [, innerHeading] = heading.match(regex) ?? []; - - if (innerHeading && innerHeading.length) { - return { text: heading, type, name: innerHeading, depth }; + const [, ...matches] = heading.match(regex) ?? []; + + if (matches?.length) { + return { + text: heading, + type, + // The highest match group should be used. + name: matches.filter(Boolean).at(-1), + depth, + }; } }