diff --git a/README.md b/README.md index 8f02bf0..1b623e0 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 d53dddc..aab1424 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 a44763f..5bb7f0b 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 6c8c835..f787a2b 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 a2e3c5f..7bb67a2 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 0000000..7dbc8c5 --- /dev/null +++ b/src/generators/legacy-json-all/index.mjs @@ -0,0 +1,54 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * @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', + + async generate(input, { output }) { + /** + * @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]); + } + }); + }); + + 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 0000000..0748a31 --- /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 0000000..b999dea --- /dev/null +++ b/src/generators/legacy-json/constants.mjs @@ -0,0 +1,18 @@ +// 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 = /\((.+)\);?$/; diff --git a/src/generators/legacy-json/index.mjs b/src/generators/legacy-json/index.mjs new file mode 100644 index 0000000..d3b6c05 --- /dev/null +++ b/src/generators/legacy-json/index.mjs @@ -0,0 +1,67 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { groupNodesByModule } from '../../utils/generators.mjs'; +import buildSection 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', + + async generate(input, { output }) { + // 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 + 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 0000000..9518015 --- /dev/null +++ b/src/generators/legacy-json/types.d.ts @@ -0,0 +1,83 @@ +import { ListItem } from 'mdast'; + +export interface HierarchizedEntry extends ApiDocMetadataEntry { + hierarchyChildren: Array; +} + +export interface Meta { + changes: Array; + added?: Array; + napiVersion?: Array; + deprecated?: Array; + removed?: Array; +} + +export interface SectionBase { + type: string; + name: string; + textRaw: string; + displayName?: string; + desc: string; + shortDesc?: string; + stability?: number; + stabilityText?: string; + meta?: Meta; +} + +export interface ModuleSection extends SectionBase { + type: 'module'; + source: string; + miscs?: Array; + modules?: Array; + classes?: Array; + methods?: Array; + properties?: Array; + globals?: ModuleSection | { type: 'global' }; + signatures?: Array; +} + +export interface SignatureSection extends SectionBase { + type: 'class' | 'ctor' | 'classMethod' | 'method'; + signatures: Array; +} + +export type Section = + | SignatureSection + | PropertySection + | EventSection + | MiscSection; + +export interface Parameter { + name: string; + optional?: boolean; + default?: string; +} + +export interface MethodSignature { + params: Array; + return?: string; +} + +export interface PropertySection extends SectionBase { + type: 'property'; + [key: string]: string | undefined; +} + +export interface EventSection extends SectionBase { + type: 'event'; + params: Array; +} + +export interface MiscSection extends SectionBase { + type: 'misc'; + [key: string]: string | undefined; +} + +export interface List { + textRaw: string; + desc?: string; + name: string; + type?: string; + default?: string; + options?: List; +} diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/generators/legacy-json/utils/buildHierarchy.mjs new file mode 100644 index 0000000..340ad7e --- /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[]} entry + * @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 0000000..e142422 --- /dev/null +++ b/src/generators/legacy-json/utils/buildSection.mjs @@ -0,0 +1,314 @@ +import { + DEFAULT_EXPRESSION, + LEADING_HYPHEN, + NAME_EXPRESSION, + RETURN_EXPRESSION, + TYPE_EXPRESSION, +} from '../constants.mjs'; +import { buildHierarchy } from './buildHierarchy.mjs'; +import parseSignature from './parseSignature.mjs'; +import { getRemarkRehype } from '../../../utils/remark.mjs'; +import { transformNodesToString } from '../../../utils/unist.mjs'; + +const sectionTypePlurals = { + module: 'modules', + misc: 'miscs', + class: 'classes', + method: 'methods', + property: 'properties', + global: 'globals', + example: 'examples', + ctor: 'signatures', + classMethod: 'classMethods', + event: 'events', + var: 'vars', +}; + +/** + * 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]); + +/** + * 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. + */ +function createMeta(entry) { + const { + added_in = [], + n_api_version = [], + deprecated_in = [], + removed_in = [], + changes, + } = entry; + + return { + 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. + */ +function createSection(entry, head) { + return { + textRaw: transformNodesToString(head.children), + name: head.data.name, + type: head.data.type, + meta: createMeta(entry), + introduced_in: entry.introduced_in, + }; +} + +/** + * Parses a list item to extract properties. + * @param {import('mdast').ListItem} child - The list item node. + * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry containing raw content. + * @returns {import('../types.d.ts').List} The parsed list. + */ +function parseListItem(child, entry) { + const current = {}; + + /** + * Extracts raw content from a node based on its position. + * @param {import('mdast').BlockContent} node + * @returns {string} + */ + const getRawContent = node => + entry.rawContent.slice( + node.position.start.offset, + node.position.end.offset + ); + + /** + * Extracts a pattern from text and assigns it to the current object. + * @param {string} text + * @param {RegExp} pattern + * @param {string} key + * @returns {string} + */ + const extractPattern = (text, pattern, key) => { + const [, match] = text.match(pattern) || []; + if (match) { + current[key] = match.trim().replace(/\.$/, ''); + return text.replace(pattern, ''); + } + return text; + }; + + // Combine and clean text from child nodes, excluding nested lists + current.textRaw = child.children + .filter(node => node.type !== 'list') + .map(getRawContent) + .join('') + .replace(/\s+/g, ' ') + .replace(//gs, ''); + + let text = current.textRaw; + + // Determine if the current item is a return statement + if (RETURN_EXPRESSION.test(text)) { + current.name = 'return'; + text = text.replace(RETURN_EXPRESSION, ''); + } else { + text = extractPattern(text, NAME_EXPRESSION, 'name'); + } + + // Extract type and default values if present + text = extractPattern(text, TYPE_EXPRESSION, 'type'); + text = extractPattern(text, DEFAULT_EXPRESSION, 'default'); + + // Assign the remaining text as the description after removing leading hyphens + current.desc = text.replace(LEADING_HYPHEN, '').trim() || undefined; + + // Recursively parse nested options if a list is found within the list item + const optionsNode = child.children.find(child => child.type === 'list'); + if (optionsNode) { + current.options = optionsNode.children.map(child => + parseListItem(child, entry) + ); + } + + return current; +} + +/** + * Parses stability metadata and adds it to the section. + * @param {import('../types.d.ts').Section} section - The section to add stability to. + * @param {Array} nodes - The AST nodes. + */ +function parseStability(section, nodes) { + nodes.forEach((node, i) => { + if ( + node.type === 'blockquote' && + node.children.length === 1 && + node.children[0].type === 'paragraph' && + nodes.slice(0, i).every(n => n.type === 'list') + ) { + const text = transformNodesToString(node.children[0].children); + const stabilityMatch = /^Stability: ([0-5])(?:\s*-\s*)?(.*)$/s.exec(text); + if (stabilityMatch) { + section.stability = Number(stabilityMatch[1]); + section.stabilityText = stabilityMatch[2].replace(/\n/g, ' ').trim(); + nodes.splice(i, 1); // Remove the matched stability node to prevent further processing + } + } + }); +} + +/** + * Parses a list and updates the section accordingly. + * @param {import('../types.d.ts').Section} section - The section to update. + * @param {Array} nodes - The AST nodes. + * @param {import('../types.d.ts').HierarchizedEntry} entry - The associated entry. + */ +function parseList(section, nodes, entry) { + const list = nodes[0]?.type === 'list' ? nodes.shift() : null; + const values = list + ? list.children.map(child => parseListItem(child, entry)) + : []; + + switch (section.type) { + case 'ctor': + case 'classMethod': + case 'method': + section.signatures = [parseSignature(section.textRaw, values)]; + break; + case 'property': + if (values.length) { + const { type, ...rest } = values[0]; + if (type) section.propertySigType = type; + Object.assign(section, rest); + section.textRaw = `\`${section.name}\` ${section.textRaw}`; + } + break; + case 'event': + section.params = values; + break; + default: + if (list) nodes.unshift(list); // If the list wasn't processed, add it back for further processing + } +} + +/** + * Adds a description to the section. + * @param {import('../types.d.ts').Section} section - The section to add description to. + * @param {Array} nodes - The AST nodes. + */ +function addDescription(section, nodes) { + if (!nodes.length) return; + + if (section.desc) { + section.shortDesc = section.desc; + } + + const html = getRemarkRehype(); + const rendered = html.stringify( + html.runSync({ type: 'root', children: nodes }) + ); + 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} parentSection - The parent section. + * @param {import('../../types.d.ts').NodeWithData} headingNode - The heading node. + */ +function addAdditionalMetadata(section, parentSection, headingNode) { + if (!section.type) { + section.name = section.textRaw.toLowerCase().trim().replace(/\s+/g, '_'); + section.displayName = headingNode.data.name; + section.type = parentSection.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} parentSection - The parent section. + */ +function addToParent(section, parentSection) { + const pluralType = sectionTypePlurals[section.type]; + parentSection[pluralType] = parentSection[pluralType] || []; + parentSection[pluralType].push(section); +} + +/** + * Promotes children to top-level if the section type is 'misc'. + * @param {import('../types.d.ts').Section} section - The section to promote. + * @param {import('../types.d.ts').Section} parentSection - The parent section. + */ +const makeChildrenTopLevelIfMisc = (section, parentSection) => { + if (section.type !== 'misc' || parentSection.type === 'misc') { + return; + } + + Object.keys(section).forEach(key => { + if (['textRaw', 'name', 'type', 'desc', 'miscs'].includes(key)) { + return; + } + if (parentSection[key]) { + parentSection[key] = Array.isArray(parentSection[key]) + ? parentSection[key].concat(section[key]) + : section[key]; + } else { + parentSection[key] = section[key]; + } + }); +}; + +/** + * Handles an entry and updates the parent section. + * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry to handle. + * @param {import('../types.d.ts').Section} parentSection - The parent section. + */ +function handleEntry(entry, parentSection) { + const [headingNode, ...nodes] = structuredClone(entry.content.children); + const section = createSection(entry, headingNode); + + parseStability(section, nodes); + parseList(section, nodes, entry); + addDescription(section, nodes); + entry.hierarchyChildren?.forEach(child => handleEntry(child, section)); + addAdditionalMetadata(section, parentSection, headingNode); + addToParent(section, parentSection); + makeChildrenTopLevelIfMisc(section, parentSection); + + if (section.type === 'property') { + if (section.propertySigType) { + section.type = section.propertySigType; + delete section.propertySigType; + } else { + delete section.type; + } + } +} + +/** + * Builds the module section from head 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. + */ +export default (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/parseSignature.mjs b/src/generators/legacy-json/utils/parseSignature.mjs new file mode 100644 index 0000000..a28983f --- /dev/null +++ b/src/generators/legacy-json/utils/parseSignature.mjs @@ -0,0 +1,206 @@ +'use strict'; + +import { PARAM_EXPRESSION } from '../constants.mjs'; + +const OPTIONAL_LEVEL_CHANGES = { '[': 1, ']': -1, ' ': 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. + let startingIdx = 0; + for (; startingIdx < parameterName.length; startingIdx++) { + const levelChange = OPTIONAL_LEVEL_CHANGES[parameterName[startingIdx]]; + + if (!levelChange) { + break; + } + + optionalDepth += levelChange; + } + + const isParameterOptional = optionalDepth > 0; + + // Now let's check for any trailing brackets at the end of the parameter's + // name. This will tell us where the parameter's name ends. + let endingIdx = parameterName.length - 1; + for (; endingIdx >= 0; endingIdx--) { + const levelChange = OPTIONAL_LEVEL_CHANGES[parameterName[endingIdx]]; + if (!levelChange) { + break; + } + + optionalDepth += levelChange; + } + + return [ + parameterName.substring(startingIdx, endingIdx + 1), + 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) { + let parameter = markdownParameters[index]; + if (parameter && 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 markdownProperty of markdownParameters) { + if (markdownProperty.name === parameterName) { + // Found it + return markdownParameters; + } else if (markdownProperty.options) { + for (const option of markdownProperty.options) { + if (option.name === parameterName) { + // Found a matching one in the parameter's options + return Object.assign({}, option); + } + } + } + } + + // At this point, we couldn't find a shared signature + if (parameterName.startsWith('...')) { + return { name: parameterName }; + } else { + throw new Error(`Invalid param "${parameterName}"`); + } +} + +/** + * @param {string[]} declaredParameters + * @param {Array} parameters + */ +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 3535869..167aaf2 100644 --- a/src/metadata.mjs +++ b/src/metadata.mjs @@ -113,6 +113,7 @@ const createMetadata = slugger => { const { type, + introduced_in, added, deprecated, removed, @@ -137,11 +138,14 @@ const createMetadata = slugger => { internalMetadata.stability.toJSON = () => internalMetadata.stability.children.map(node => node.data); - // Returns the Metadata entry for the API doc - return { + /** + * @type {ApiDocMetadataEntry} + */ + const value = { api: apiDoc.stem, slug: sectionSlug, source_link, + api_doc_source: `doc/api/${apiDoc.basename}`, added_in: added, deprecated_in: deprecated, removed_in: removed, @@ -152,7 +156,15 @@ const createMetadata = slugger => { stability: internalMetadata.stability, content: section, tags, + rawContent: apiDoc.toString(), }; + + if (introduced_in) { + value.introduced_in = introduced_in; + } + + // Returns the Metadata entry for the API doc + return value; }, }; }; diff --git a/src/parser.mjs b/src/parser.mjs index 3c05b64..c4c3434 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 8937539..74185ef 100644 --- a/src/queries.mjs +++ b/src/queries.mjs @@ -1,7 +1,7 @@ 'use strict'; import { u as createTree } from 'unist-builder'; -import { SKIP } from 'unist-util-visit'; +import { SKIP, visit } from 'unist-util-visit'; import { DOC_API_STABILITY_SECTION_REF_URL } from './constants.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,24 @@ 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 updateTypeReference = (node, parent) => { const replacedTypes = node.value.replace( createQueries.QUERIES.normalizeTypes, transformTypeToReferenceLink ); - node.type = 'html'; - node.value = replacedTypes; + const { + children: [newNode], + } = remark.parse(replacedTypes); + const index = parent.children.indexOf(node); + const originalPosition = node.position; + visit(newNode, node => { + (node.position.start += originalPosition.start), + (node.position.end += originalPosition.end); + }); + parent.children.splice(index, 1, ...newNode.children); return [SKIP]; }; diff --git a/src/test/metadata.test.mjs b/src/test/metadata.test.mjs index 3612f12..b89d82b 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, + rawContent: '', 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 b8de7ca..398b190 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 1391750..e372cce 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) @@ -96,6 +98,8 @@ declare global { // Extra YAML section entries that are stringd and serve // to provide additional metadata about the API doc entry tags: Array; + // The raw file content + rawContent: string; } export interface ApiDocReleaseEntry { diff --git a/src/utils/parser.mjs b/src/utils/parser.mjs index dea0dbb..e0ad23d 100644 --- a/src/utils/parser.mjs +++ b/src/utils/parser.mjs @@ -123,10 +123,15 @@ 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, + name: matches.filter(Boolean).at(-1), + depth, + }; } }