diff --git a/components/MetaBar/index.jsx b/components/MetaBar/index.jsx index e0ea5c31..dca634bc 100644 --- a/components/MetaBar/index.jsx +++ b/components/MetaBar/index.jsx @@ -17,8 +17,9 @@ function MetaBarComponent({ metadata, headings = [], readingTime }) { .filter(sponsor => sponsor.monthly.tier === 'platinum') .sort((a, b) => b.monthly.value - a.monthly.value); + // Generated API pages have no editable source, unless they name one. const editThisPage = - metadata.source ?? editURL.replace('{path}', metadata.path); + metadata.source ?? editURL?.replace('{path}', metadata.path); const authors = metadata.authors?.split(',').map(id => ({ image: `https://avatars.githubusercontent.com/${id.trim()}`, url: `https://github.com/${id.trim()}`, @@ -36,12 +37,16 @@ function MetaBarComponent({ metadata, headings = [], readingTime }) { Authors: , } : {}), - Contribute: ( - <> - - Edit this page - - ), + ...(editThisPage + ? { + Contribute: ( + <> + + Edit this page + + ), + } + : {}), ...(platinumSponsors.length ? { [platinumSponsors.length > 1 diff --git a/package-lock.json b/package-lock.json index ce853e49..da8f4ae8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@doc-kit/cli": "^1.0.5", "@doc-kit/generator-react": "^0.5.0", + "@doc-kit/typedoc": "^0.0.0", "@tailwindcss/vite": "^4.3.3", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.10.1", @@ -18,7 +19,6 @@ "npm-run-all": "^4.1.5", "semver": "^7.8.5", "typedoc": "^0.28.20", - "typedoc-plugin-markdown": "^4.14.0", "webpack": "^5.112.0" }, "devDependencies": { @@ -47,6 +47,41 @@ "url": "https://opencollective.com/11ty" } }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -360,6 +395,70 @@ "vite": "~8.3.1" } }, + "node_modules/@doc-kit/typedoc": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@doc-kit/typedoc/-/typedoc-0.0.0.tgz", + "integrity": "sha512-zbVhmmHMT7I2vCE/IM0vba0Wj2KbBBJDdzbw+anuW8HXSc5suvmPMOeSSMdghU2fIMbb9Ay/3JOyjVZd0QSE/A==", + "license": "MIT", + "dependencies": { + "@doc-kit/core": "1.2.1", + "typedoc": "^0.28.20" + } + }, + "node_modules/@doc-kit/typedoc/node_modules/@doc-kit/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@doc-kit/core/-/core-1.2.1.tgz", + "integrity": "sha512-nzKO7hRMj4kYq1hPMtDtJ+Fs4A2I0OFcMlZy2/TbhAc/jeeYsEHgZU4FZmKIEhE59ebxorzoRyN52lJ16nuRLQ==", + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@node-core/rehype-shiki": "^1.5.0", + "@swc/html-wasm": "^1.16.1", + "@swc/wasm": "^1.15.46", + "acorn": "^8.17.0", + "cosmiconfig": "^9.0.2", + "dedent": "^1.7.2", + "github-slugger": "^2.0.0", + "glob-parent": "^6.0.2", + "hastscript": "^9.0.1", + "mdast-util-slice-markdown": "^2.0.1", + "mdast-util-to-string": "^4.0.0", + "piscina": "^5.3.1", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", + "semver": "^7.8.5", + "shiki": "^4.4.3", + "tinyglobby": "^0.2.17", + "unified": "^11.0.5", + "unist-builder": "^4.0.0", + "unist-util-find-after": "^5.0.0", + "unist-util-position": "^5.0.0", + "unist-util-remove": "^4.0.0", + "unist-util-select": "^5.1.0", + "unist-util-visit": "^5.1.0", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@doc-kit/generator-react": ">=0.1.0", + "@node-core/doc-kit": ">=2.0.0", + "@node-core/doc-kit-legacy": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@doc-kit/generator-react": { + "optional": true + }, + "@node-core/doc-kit": { + "optional": true + }, + "@node-core/doc-kit-legacy": { + "optional": true + } + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -10425,6 +10524,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/twoslash": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/twoslash/-/twoslash-0.3.9.tgz", @@ -10554,18 +10662,6 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/typedoc-plugin-markdown": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.14.0.tgz", - "integrity": "sha512-Y2a3oDV9ekrbkk6RBSGH8GyNkh0xIdemIOOTr3tiRN/33nvFGStOs9nw+6Q84gF1XFkcVZrgM4Ckcsf6e/YpKQ==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "typedoc": "0.28.x" - } - }, "node_modules/typedoc-plugin-missing-exports": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/typedoc-plugin-missing-exports/-/typedoc-plugin-missing-exports-4.1.4.tgz", @@ -10628,6 +10724,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/package.json b/package.json index 65c4ec8c..d9f4b66d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@doc-kit/cli": "^1.0.5", "@doc-kit/generator-react": "^0.5.0", + "@doc-kit/typedoc": "^0.0.0", "@tailwindcss/vite": "^4.3.3", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.10.1", @@ -35,7 +36,6 @@ "npm-run-all": "^4.1.5", "semver": "^7.8.5", "typedoc": "^0.28.20", - "typedoc-plugin-markdown": "^4.14.0", "webpack": "^5.112.0" }, "devDependencies": { diff --git a/plugins/processor/exportEquals.mjs b/plugins/processor/exportEquals.mjs deleted file mode 100644 index bf19537e..00000000 --- a/plugins/processor/exportEquals.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { ReflectionKind } from 'typedoc'; -import { setPublicName } from './metadata.mjs'; - -const EXPORT_EQUALS_NAME = 'export='; - -/** - * @param {import('typedoc').ProjectReflection} project - * @param {import('typedoc').Reflection} reflection - * @returns {string | undefined} - */ -const exportEqualsPublicName = (project, reflection) => { - const rawParts = String(reflection.getFullName?.() ?? reflection.name).split( - '.' - ); - - if (!rawParts.includes(EXPORT_EQUALS_NAME)) { - return; - } - - const parts = rawParts.filter(part => part && part !== EXPORT_EQUALS_NAME); - return parts[0] === project.name - ? parts.join('.') - : [project.name, ...parts].filter(Boolean).join('.'); -}; - -/** - * TypeDoc exposes CommonJS `export = webpack` containers and callables as - * `export=`. Normalize every such reflection while the processor still owns - * the reflection graph: containers are merged into their parent and remaining - * declarations get public webpack names. - * - * @param {import('typedoc').ProjectReflection} project - */ -export const applyExportEqualsReflections = project => { - project - .getReflectionsByKind(ReflectionKind.SomeModule) - .filter(ref => ref.name === EXPORT_EQUALS_NAME && ref.parent) - .forEach(namespace => - project.mergeReflections(namespace, namespace.parent) - ); - - for (const reflection of project.getReflectionsByKind(ReflectionKind.All)) { - const name = exportEqualsPublicName(project, reflection); - if (name) setPublicName(reflection, name || project.name); - } -}; diff --git a/plugins/processor/extractHooksGroup.mjs b/plugins/processor/extractHooksGroup.mjs deleted file mode 100644 index 4838b6db..00000000 --- a/plugins/processor/extractHooksGroup.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import { - ReflectionKind, - ReflectionGroup, - ParameterReflection, - SignatureReflection, - IntrinsicType, -} from 'typedoc'; - -function extractHooks(type) { - let hooks = []; - if (!type) return hooks; - - type.visit({ - reflection: t => { - if (t.declaration?.children) { - hooks.push(...t.declaration.children); - } - }, - reference: t => { - if (t.name === 'Readonly' && t.typeArguments?.length) { - hooks.push(...extractHooks(t.typeArguments[0])); - } else if (t.reflection?.children) { - hooks.push(...t.reflection.children); - } - }, - intersection: t => { - if (t.types) { - t.types.forEach(innerType => { - hooks.push(...extractHooks(innerType)); - }); - } - }, - }); - - return hooks; -} - -export function extractHooksGroup(reflection) { - if (!reflection.groups) return; - - const propsGroup = reflection.groups.find(g => g.title === 'Properties'); - if (!propsGroup) return; - - const hooksIndex = propsGroup.children.findIndex(p => p.name === 'hooks'); - if (hooksIndex === -1) return; - - const hooksProp = propsGroup.children[hooksIndex]; - - const individualHooks = extractHooks(hooksProp.type); - - if (individualHooks.length > 0) { - propsGroup.children.splice(hooksIndex, 1); - - individualHooks.forEach(hook => { - hook.kind = ReflectionKind.Method; - const sig = new SignatureReflection( - hook.name, - ReflectionKind.CallSignature, - hook - ); - - if (hook.type?.type === 'reference' && hook.type.typeArguments?.length) { - const argsTuple = hook.type.typeArguments[0]; - if (argsTuple.type === 'tuple' && argsTuple.elements) { - sig.parameters = argsTuple.elements.map((el, i) => { - const type = el.type === 'named-tuple-member' ? el.element : el; - let name = el.type === 'named-tuple-member' ? el.name : undefined; - if (!name) { - if (type.type === 'reference' && type.name) { - name = type.name.charAt(0).toLowerCase() + type.name.slice(1); - } else if (type.type === 'intrinsic') { - name = type.name; - } else { - name = `arg${i}`; - } - } - const param = new ParameterReflection( - name, - ReflectionKind.Parameter, - sig - ); - param.type = type; - return param; - }); - } - - const hookName = hook.type.name; - if (hookName.includes('Bail') && hook.type.typeArguments.length > 1) { - sig.type = hook.type.typeArguments[1]; - } else if ( - hookName.includes('Waterfall') && - argsTuple.type === 'tuple' && - argsTuple.elements?.length - ) { - const el = argsTuple.elements[0]; - sig.type = el.type === 'named-tuple-member' ? el.element : el; - } else { - sig.type = new IntrinsicType('void'); - } - } - - hook.signatures = [sig]; - }); - - const hooksGroup = new ReflectionGroup('Hooks', ReflectionKind.Method); - hooksGroup.children = individualHooks; - - const methodsIndex = reflection.groups.findIndex( - g => g.title === 'Methods' - ); - if (methodsIndex !== -1) { - reflection.groups.splice(methodsIndex + 1, 0, hooksGroup); - } else { - reflection.groups.push(hooksGroup); - } - } -} diff --git a/plugins/processor/index.mjs b/plugins/processor/index.mjs deleted file mode 100644 index 9a6f6b11..00000000 --- a/plugins/processor/index.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import { Converter, ReflectionKind, Renderer } from 'typedoc'; -import { MarkdownPageEvent } from 'typedoc-plugin-markdown'; -import { extractHooksGroup } from './extractHooksGroup.mjs'; -import { getSourceMetadata } from './metadata.mjs'; -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { applyExportEqualsReflections } from './exportEquals.mjs'; -import { applySourceMetadata } from './source.mjs'; -import { DocKitRouter } from './router.mjs'; -import { sidebar } from './site.mjs'; -import { createTypeMap } from './typeMap.mjs'; - -/** - * @param {import('typedoc-plugin-markdown').MarkdownApplication} app - */ -export function load(app) { - app.renderer.defineRouter('doc-kit', DocKitRouter); - - app.converter.on(Converter.EVENT_RESOLVE_BEGIN, context => { - // doc-kit has property metadata, not TypeDoc accessor metadata. - context.project - .getReflectionsByKind(ReflectionKind.Accessor) - .forEach(accessor => { - accessor.kind = ReflectionKind.Property; - if (accessor.getSignature) { - accessor.type = accessor.getSignature.type; - accessor.comment = accessor.getSignature.comment; - } else if (accessor.setSignature) { - accessor.type = accessor.setSignature.parameters?.[0]?.type; - accessor.comment = accessor.setSignature.comment; - } - }); - - // Reference reflections duplicate the real declaration entries and confuse - // both routing and the custom type map. - context.project - .getReflectionsByKind(ReflectionKind.Reference) - .forEach(ref => context.project.removeReflection(ref)); - - // Destructured parameters are not supported by TypeDoc, so we rename them to - // a more generic name. - context.project - .getReflectionsByKind(ReflectionKind.Parameter) - .forEach(param => { - if (param.name.startsWith('__namedParameters')) { - if ( - param.type?.type === 'reflection' && - param.type.declaration?.children - ) { - const destructuredKeys = param.type.declaration.children.map( - child => child.name - ); - param.name = `{ ${destructuredKeys.join(', ')} }`; - } else if (param.type?.type === 'reference' && param.type.name) { - const interfaceName = param.type.name; - param.name = - interfaceName[0].toLowerCase() + interfaceName.slice(1); - } else { - param.name = 'options'; - } - } - }); - - applyExportEqualsReflections(context.project); - applySourceMetadata(context.project); - }); - - app.converter.on(Converter.EVENT_RESOLVE_END, context => { - const project = context.project; - - const internalModule = project.children?.find(c => c.name === ''); - - if (internalModule) { - project.mergeReflections(internalModule, project); - } - }); - - app.renderer.on(MarkdownPageEvent.BEGIN, page => { - extractHooksGroup(page.model); - }); - - app.renderer.on(MarkdownPageEvent.END, page => { - const sourceMeta = getSourceMetadata(page.model); - - if (sourceMeta && sourceMeta.sourceRelativePath) { - const source = `https://github.com/webpack/webpack/edit/main/lib/${sourceMeta.sourceRelativePath}`; - const frontmatter = `---\n` + `source: ${source}\n` + `---\n\n`; - - page.contents = frontmatter + page.contents; - } - }); - - app.renderer.on(Renderer.EVENT_END, () => { - // doc-kit resolves custom type annotations from this map while generating - // HTML, so use the final router URLs instead of recomputing paths here. - const typeMap = createTypeMap(app.renderer.router); - - writeFileSync( - join(app.options.getValue('out'), 'type-map.json'), - JSON.stringify(typeMap, null, 2) - ); - - writeFileSync( - join(app.options.getValue('out'), 'site.json'), - JSON.stringify( - { - sidebar: sidebar( - app.renderer.router, - app.options.getValue('publicPath') - ), - }, - null, - 2 - ) - ); - }); -} diff --git a/plugins/processor/metadata.mjs b/plugins/processor/metadata.mjs deleted file mode 100644 index 63b4b8c2..00000000 --- a/plugins/processor/metadata.mjs +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @typedef {object} SourceMetadata - * @property {string} sourcePath Absolute source file path. - * @property {string} sourceRelativePath Path relative to webpack/lib. - * @property {string} fileBase Source file name without extension. - * @property {string | undefined} anchorName Preferred page anchor for members - * whose public name differs from the source file name. - * @property {string | undefined} publicPath Public webpack export path. - */ - -/** - * @typedef {object} TypePageMetadata - * @property {string} baseName Router base name for the synthetic page. - * @property {string} title Rendered page title. - */ - -const sourceMetadata = new WeakMap(); -const typePageMetadata = new WeakMap(); -const publicNames = new WeakMap(); - -/** - * Attach source-file metadata discovered from webpack/lib to a reflection. - * - * @param {import('typedoc').Reflection} reflection - * @param {SourceMetadata} metadata - */ -export const setSourceMetadata = (reflection, metadata) => { - sourceMetadata.set(reflection, metadata); -}; - -/** - * @param {import('typedoc').Reflection} reflection - * @returns {SourceMetadata | undefined} - */ -export const getSourceMetadata = reflection => sourceMetadata.get(reflection); - -/** - * Synthetic type pages are not TypeDoc source declarations, so their routing - * and title metadata lives beside other processor-owned overlays. - * - * @param {import('typedoc').Reflection} reflection - * @param {TypePageMetadata} metadata - */ -export const setTypePageMetadata = (reflection, metadata) => { - typePageMetadata.set(reflection, metadata); -}; - -/** - * @param {import('typedoc').Reflection} reflection - * @returns {string | undefined} - */ -export const getTypePageTitle = reflection => - typePageMetadata.get(reflection)?.title; - -/** @param {import('typedoc').Reflection} reflection */ -export const isTypePage = reflection => typePageMetadata.has(reflection); - -/** - * Some declaration-file shapes need processor-owned public names that differ - * from TypeDoc's internal reflection names. - * - * @param {import('typedoc').Reflection} reflection - * @param {string} name - */ -export const setPublicName = (reflection, name) => { - publicNames.set(reflection, name); -}; - -/** - * @param {import('typedoc').Reflection} reflection - * @returns {string | undefined} - */ -export const getPublicName = reflection => publicNames.get(reflection); diff --git a/plugins/processor/ownership.mjs b/plugins/processor/ownership.mjs deleted file mode 100644 index ed3bb0dd..00000000 --- a/plugins/processor/ownership.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import { ReflectionKind } from 'typedoc'; - -const HOST_KINDS = ReflectionKind.Class | ReflectionKind.Namespace; - -const collectReferences = (type, out, depth = 0) => { - if (!type || depth > 30) return; - - const next = []; - - switch (type.type) { - case 'reference': - if (type.reflection) out.add(type.reflection.id); - next.push(...(type.typeArguments ?? [])); - break; - case 'union': - case 'intersection': - next.push(...(type.types ?? [])); - break; - case 'array': - case 'optional': - case 'rest': - next.push(type.elementType); - break; - case 'tuple': - next.push(...(type.elements ?? [])); - break; - case 'namedTupleMember': - next.push(type.element); - break; - case 'indexedAccess': - next.push(type.objectType, type.indexType); - break; - case 'conditional': - next.push( - type.checkType, - type.extendsType, - type.trueType, - type.falseType - ); - break; - case 'predicate': - next.push(type.targetType); - break; - case 'query': - next.push(type.queryType); - break; - case 'mapped': - next.push(type.parameterType, type.templateType, type.nameType); - break; - case 'templateLiteral': - next.push(...(type.tail ?? []).map(([tailType]) => tailType)); - break; - case 'typeOperator': - next.push(type.target); - break; - default: - break; - } - - for (const child of next) collectReferences(child, out, depth + 1); -}; - -// The page that hosts a referring reflection: the nearest class or namespace, -// a root structural type (resolved transitively), or the project itself. -// Project-level referrers still count as owners so types used by root exports -// never look single-owned, but the project is never a co-location target. -const hostFor = (reflection, structuralIds) => { - let current = reflection; - - while (current) { - if (current.kindOf?.(HOST_KINDS)) return current; - if (structuralIds.has(current.id)) return current; - if (current.isProject?.()) return current; - current = current.parent; - } - - return undefined; -}; - -/** - * Map each root structural type to the single class or namespace page that - * references it, when exactly one exists. - */ -export const soleOwnerPages = (project, structuralTypes) => { - const structuralIds = new Set(structuralTypes.map(type => type.id)); - const owners = new Map(); - const structuralReferrers = new Map(); - - for (const type of structuralTypes) { - owners.set(type.id, new Set()); - structuralReferrers.set(type.id, new Set()); - } - - for (const reflection of Object.values(project.reflections)) { - const references = new Set(); - - collectReferences(reflection.type, references); - collectReferences(reflection.default, references); - for (const extended of reflection.extendedTypes ?? []) { - collectReferences(extended, references); - } - for (const implemented of reflection.implementedTypes ?? []) { - collectReferences(implemented, references); - } - - if (!references.size) continue; - - const host = hostFor(reflection, structuralIds); - if (!host) continue; - - for (const id of references) { - if (id === host.id || !owners.has(id)) continue; - - if (structuralIds.has(host.id)) { - structuralReferrers.get(id).add(host.id); - } else { - owners.get(id).add(host); - } - } - } - - let changed = true; - while (changed) { - changed = false; - - for (const [id, referrerIds] of structuralReferrers) { - const ownHosts = owners.get(id); - - for (const referrerId of referrerIds) { - for (const host of owners.get(referrerId)) { - if (!ownHosts.has(host)) { - ownHosts.add(host); - changed = true; - } - } - } - } - } - - const soleOwners = new Map(); - - for (const type of structuralTypes) { - const [owner, ...rest] = owners.get(type.id); - if (owner && !rest.length && owner.kindOf?.(HOST_KINDS)) { - soleOwners.set(type, owner); - } - } - - return soleOwners; -}; diff --git a/plugins/processor/router.mjs b/plugins/processor/router.mjs deleted file mode 100644 index 1adea3fc..00000000 --- a/plugins/processor/router.mjs +++ /dev/null @@ -1,258 +0,0 @@ -import createNodeSlugger from '@doc-kit/core/src/generators/metadata/utils/slugger.mjs'; -import { PageKind, Reflection, ReflectionKind } from 'typedoc'; -import { MemberRouter } from 'typedoc-plugin-markdown'; -import { categoryForReflection } from '../shared/categories.mjs'; -import { getConstructorTitle, getMemberTitle } from '../shared/titles.mjs'; -import { getSourceMetadata, isTypePage } from './metadata.mjs'; -import { createTypePages, TYPE_PAGE_HEADING_KINDS } from './synthetic.mjs'; -import { normalizeLink } from '../../utils/helpers/urls.mjs'; - -/** - * The router owns the public Markdown shape. It keeps one class per file, - * namespaces on index pages, namespace functions/constants as anchored entries, - * root plugin classes under plugins/, and structural types on category types - * pages instead of the root README. - */ -const sluggers = new Map(); - -const NON_HEADING_KINDS = - ReflectionKind.Property | - ReflectionKind.EnumMember | - ReflectionKind.SomeSignature | - ReflectionKind.TypeLiteral | - ReflectionKind.TypeParameter; - -const fullNameParts = reflection => reflection.getFullName().split('.'); - -const pagePath = parts => parts.join('/'); - -const categoryNameForReflection = reflection => - categoryForReflection(reflection)?.category; - -const rootExportBaseName = reflection => { - const category = categoryNameForReflection(reflection); - return category ? `${category}/${reflection.name}` : reflection.name; -}; - -const namespaceBaseName = reflection => { - if (!reflection.kindOf(ReflectionKind.Namespace)) { - return; - } - - const parts = fullNameParts(reflection); - - if (parts.length === 1) { - const category = categoryNameForReflection(reflection); - - if (category) { - return `${category}/${reflection.name}`; - } - - // Lowercase root namespaces behave like source directories: their classes - // can live beside an index page, while uppercase namespace-like objects - // (for example RuntimeGlobals) remain a single file. - return /^[a-z]/.test(reflection.name) - ? `${reflection.name}/index` - : reflection.name; - } - - return /^[a-z]/.test(reflection.name) - ? pagePath([...parts, 'index']) - : pagePath(parts); -}; - -const classBaseName = reflection => { - const parts = fullNameParts(reflection); - - if (parts.length === 1) { - return rootExportBaseName(reflection); - } - - return pagePath(parts); -}; - -export const sourcePageBaseName = reflection => { - if ( - !(reflection instanceof Reflection) || - !reflection.kindOf(ReflectionKind.Class | ReflectionKind.Namespace) - ) { - return; - } - - if (reflection.kindOf(ReflectionKind.Class)) { - return classBaseName(reflection); - } - - if (reflection.kindOf(ReflectionKind.Namespace)) { - return namespaceBaseName(reflection); - } - - return; -}; - -export const hasSourcePage = reflection => - Boolean(sourcePageBaseName(reflection)); - -export const sourceAnchorName = reflection => { - if ( - !(reflection instanceof Reflection) || - !reflection.kindOf(ReflectionKind.Function | ReflectionKind.Variable) - ) { - return; - } - - const anchorName = getSourceMetadata(reflection)?.anchorName; - if (!anchorName) { - return; - } - - const baseName = sourcePageBaseName(reflection.parent); - if (baseName?.split('/').at(-1) === reflection.name) { - return; - } - - return anchorName; -}; - -const rendersHeading = (target, pageTarget) => { - if (!target.isDeclaration() || target.kindOf(NON_HEADING_KINDS)) { - return false; - } - - return isTypePage(pageTarget) ? target.kindOf(TYPE_PAGE_HEADING_KINDS) : true; -}; - -const anchorTitle = (target, pageTarget) => - target.kindOf(ReflectionKind.Constructor) - ? getConstructorTitle(target) - : getMemberTitle(target, { local: isTypePage(pageTarget) }); - -export class DocKitRouter extends MemberRouter { - /** @param {import('typedoc').ProjectReflection} project */ - buildPages(project) { - const typePages = createTypePages(project); - const pages = super.buildPages(project); - - for (const { baseName, children, model } of typePages) { - const url = this.getFileName(baseName); - this.fullUrls.set(model, url); - pages.push({ kind: PageKind.Reflection, model, url }); - - for (const child of children) { - this.buildAnchors(child, model, { includeChildren: false }); - } - } - - return pages; - } - - /** - * @param {import('typedoc').DeclarationReflection} reflection - * @param {import('typedoc').MarkdownPageEvent[]} outPages - */ - buildChildPages(reflection, outPages) { - const kind = this.getPageKind(reflection); - - if (!kind) { - // Functions and constants are entries on their parent page, never files. - this.buildAnchors(reflection, reflection.parent); - return; - } - - if (hasSourcePage(reflection)) { - const shouldWritePage = this.shouldWritePage(reflection); - const idealName = this.getIdealBaseName(reflection); - const actualName = shouldWritePage - ? this.getFileName(idealName) - : `${idealName}${this.extension}`; - - this.fullUrls.set(reflection, actualName); - - if (shouldWritePage) { - outPages.push({ kind, model: reflection, url: actualName }); - } - } else if ( - !reflection.kindOf( - ReflectionKind.Module | - ReflectionKind.Namespace | - ReflectionKind.Document - ) - ) { - this.buildAnchors(reflection, reflection.parent); - } - - reflection.traverse(child => { - this.buildChildPages(child, outPages); - return true; - }); - } - - /** @param {import('typedoc').DeclarationReflection} reflection */ - getIdealBaseName(reflection) { - return sourcePageBaseName(reflection) ?? super.getIdealBaseName(reflection); - } - - /** @param {import('typedoc').RouterTarget} pageTarget */ - getSlugger(pageTarget) { - if (sluggers.has(pageTarget)) { - return sluggers.get(pageTarget); - } - - // Use doc-kit's slugger so type-map anchors match the parser's heading - // normalization instead of TypeDoc's default GitHub-style slugs. - const slugger = createNodeSlugger(); - sluggers.set(pageTarget, slugger); - return slugger; - } - - /** @param {import('typedoc').RouterTarget} target */ - getAnchoredURL(target) { - const fullUrl = this.getFullUrl(target); - const [page, routedAnchor] = fullUrl.split('#'); - const anchor = - sourceAnchorName(target) ?? routedAnchor ?? this.getAnchor(target); - const pageUrl = normalizeLink(page); - - return anchor ? `${pageUrl}#${anchor}` : pageUrl; - } - - /** - * @param {import('typedoc').RouterTarget} target - * @param {import('typedoc').RouterTarget} pageTarget - * @param {{ includeChildren?: boolean }} [options] - */ - buildAnchors(target, pageTarget, { includeChildren = true } = {}) { - if ( - !(target instanceof Reflection) || - !(pageTarget instanceof Reflection) - ) { - return; - } - - const pageUrl = this.fullUrls.get(pageTarget); - if (!pageUrl) return; - - // Only publish URLs for headings the custom theme actually renders. TypeDoc - // exposes signatures, type parameters, class properties, and type literals as - // reflections too, but in these docs they are signature bodies or typed list - // items rather than linkable headings. - if (!rendersHeading(target, pageTarget)) { - return; - } - - const title = anchorTitle(target, pageTarget); - const anchor = this.getSlugger(pageTarget).slug(title); - - this.fullUrls.set(target, `${normalizeLink(pageUrl)}#${anchor}`); - this.anchors.set(target, anchor); - - if (!includeChildren) { - return; - } - - target.traverse(child => { - this.buildAnchors(child, pageTarget); - return true; - }); - } -} diff --git a/plugins/processor/site.mjs b/plugins/processor/site.mjs deleted file mode 100644 index 48a695b0..00000000 --- a/plugins/processor/site.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { ReflectionKind } from 'typedoc'; -import { CATEGORY_RULES } from '../shared/categories.mjs'; -import { toPublicLink } from '../../utils/helpers/urls.mjs'; - -const SIDEBAR_KINDS = - ReflectionKind.Project | ReflectionKind.Namespace | ReflectionKind.Class; - -const SIDEBAR_GROUP_NAME = 'API'; - -const getFirstAtxHeading = text => text.match(/^#\s+(.+)$/m)?.[1]?.trim(); - -const getPathSegments = url => url.replace(/^\//, '').split('/'); - -const getFirstPathSegment = url => getPathSegments(url)[0]; - -const getPathDepth = url => getPathSegments(url).length; - -const defaultLabelFor = (target, url) => { - if (url.endsWith('/index.md')) { - return getPathDepth(url) === 2 ? 'Overview' : target.name; - } - if (url.endsWith('/types.md')) return 'Types'; - return target.name; -}; - -const isSidebarTarget = (router, target) => { - if (!target.kindOf?.(SIDEBAR_KINDS)) return false; - if (!router.hasOwnDocument(target)) return false; - - const url = router.getFullUrl(target); - return url.endsWith('.md') && !url.includes('#'); -}; - -export const sidebar = (router, basePath) => { - const categories = new Map(); - const seen = new Set(); - - for (const target of router.getLinkTargets()) { - if (!isSidebarTarget(router, target)) continue; - - const url = router.getFullUrl(target); - if (seen.has(url)) continue; - seen.add(url); - - const firstPathSegment = getFirstPathSegment(url); - const category = CATEGORY_RULES.find( - cat => cat.category === firstPathSegment - ); - - if (!category) continue; - - const headingName = getFirstAtxHeading( - target.comment?.summary?.[0]?.text ?? '' - ); - const label = headingName ?? defaultLabelFor(target, url); - - const group = categories.get(category.category) ?? { - label: category.label, - items: [], - }; - - group.items.push({ - link: toPublicLink(url, basePath), - label, - }); - - categories.set(category.category, group); - } - - return [ - { - groupName: SIDEBAR_GROUP_NAME, - items: [ - ...[...categories.values()].filter(category => category.items.length), - { - link: toPublicLink('options', basePath), - label: 'Options', - }, - ], - }, - ]; -}; diff --git a/plugins/processor/source.mjs b/plugins/processor/source.mjs deleted file mode 100644 index f4d809b4..00000000 --- a/plugins/processor/source.mjs +++ /dev/null @@ -1,737 +0,0 @@ -import ts from 'typescript'; -import { Comment, CommentTag, ReflectionKind, ReflectionType } from 'typedoc'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve } from 'node:path'; -import { getPublicName, setSourceMetadata } from './metadata.mjs'; - -/** - * Source metadata is intentionally a documentation-only overlay: - * 1. types.d.ts decides which symbols exist and what their signatures are. - * 2. webpack/lib/index.js maps those public symbols back to implementation files. - * 3. all JS files under webpack/lib contribute summaries, params, returns, - * and stability/deprecation tags when that source comment has real content. - */ -const DOC_BLOCK_TAGS = new Set([ - '@deprecated', - '@example', - '@legacy', - '@remarks', - '@see', - '@since', - '@throws', -]); - -const MODIFIER_TAGS = new Set([ - '@alpha', - '@beta', - '@experimental', - '@internal', -]); - -const textPart = text => [{ kind: 'text', text }]; - -const normalizeText = value => - String(value ?? '') - .replace(/\r\n?/g, '\n') - .trim(); - -const jsDocText = value => { - if (!value) return ''; - if (Array.isArray(value)) { - return normalizeText( - value.map(part => part.text ?? part.getText?.() ?? '').join('') - ); - } - return normalizeText(value); -}; - -const hasCommentContent = comment => - Boolean( - comment?.summary || - comment?.returns || - comment?.params?.size || - comment?.blockTags?.length || - comment?.modifierTags?.size - ); - -const hasRenderableCommentContent = comment => - Boolean( - comment?.summary || - comment?.returns || - [...(comment?.params?.values() ?? [])].some(Boolean) || - comment?.blockTags?.some(tag => tag.content) || - comment?.modifierTags?.size - ); - -const mergeComments = comments => { - const merged = { - summary: '', - params: new Map(), - returns: '', - blockTags: [], - modifierTags: new Set(), - }; - - for (const comment of comments.filter(Boolean)) { - if (comment.summary) merged.summary = comment.summary; - if (comment.returns) merged.returns = comment.returns; - for (const [name, value] of comment.params) { - if (value) merged.params.set(name, value); - } - for (const tag of comment.blockTags) { - if (tag.content) merged.blockTags.push(tag); - } - for (const tag of comment.modifierTags) { - merged.modifierTags.add(tag); - } - } - - return hasCommentContent(merged) ? merged : undefined; -}; - -// TypeScript exposes JSDoc differently depending on whether a comment belongs -// to a declaration, assignment, or export wrapper. Walk up a tiny amount so -// comments attached to CommonJS assignment statements are still discovered. -const getJsDocs = node => { - let current = node; - - while (current && !ts.isSourceFile(current)) { - if (current.jsDoc?.length) return current.jsDoc; - if (ts.isBlock(current) || ts.isClassDeclaration(current)) break; - current = current.parent; - } - - return []; -}; - -const getJsDocComment = node => { - const jsDocs = getJsDocs(node); - const comments = jsDocs.map(jsDoc => { - const comment = { - summary: jsDocText(jsDoc.comment), - params: new Map(), - returns: '', - blockTags: [], - modifierTags: new Set(), - }; - - for (const tag of jsDoc.tags ?? []) { - const tagName = `@${tag.tagName?.escapedText ?? ''}`; - const content = jsDocText(tag.comment); - - if (ts.isJSDocParameterTag(tag) && tag.name) { - comment.params.set(tag.name.getText(), content); - continue; - } - - if (ts.isJSDocReturnTag(tag)) { - comment.returns = content; - continue; - } - - if (MODIFIER_TAGS.has(tagName)) { - comment.modifierTags.add(tagName); - continue; - } - - if (DOC_BLOCK_TAGS.has(tagName)) { - comment.blockTags.push({ tag: tagName, content }); - } - } - - return hasCommentContent(comment) ? comment : undefined; - }); - - return mergeComments(comments); -}; - -const toTypedocComment = sourceComment => { - if (!sourceComment || !hasRenderableCommentContent(sourceComment)) { - return undefined; - } - - const blockTags = []; - if (sourceComment.returns) { - blockTags.push(new CommentTag('@returns', textPart(sourceComment.returns))); - } - - for (const { tag, content } of sourceComment.blockTags ?? []) { - blockTags.push(new CommentTag(tag, textPart(content))); - } - - return new Comment( - sourceComment.summary ? textPart(sourceComment.summary) : [], - blockTags, - new Set(sourceComment.modifierTags ?? []) - ); -}; - -const toParameterComment = text => { - const content = normalizeText(text); - return content ? new Comment(textPart(content)) : undefined; -}; - -const assignComment = (target, sourceComment) => { - const typedocComment = toTypedocComment(sourceComment); - if (typedocComment) target.comment = typedocComment; -}; - -const applySignatureComment = (signature, sourceComment) => { - if (!signature || !hasRenderableCommentContent(sourceComment)) return; - - assignComment(signature, sourceComment); - - const params = [...sourceComment.params.values()].filter(Boolean); - - for (const parameter of signature.parameters ?? []) { - const comment = - sourceComment.params.get(parameter.name) ?? - (parameter.name.startsWith('__') && params.length === 1 - ? params[0] - : undefined); - const parameterComment = toParameterComment(comment); - if (parameterComment) parameter.comment = parameterComment; - } -}; - -const propertyNameText = name => { - if (!name) return undefined; - if ( - ts.isIdentifier(name) || - ts.isStringLiteral(name) || - ts.isNumericLiteral(name) - ) { - return name.text; - } - return undefined; -}; - -const getAssignedProperty = expression => { - if (!ts.isPropertyAccessExpression(expression)) return undefined; - - const property = expression.name.text; - const owner = expression.expression; - - if ( - ts.isPropertyAccessExpression(owner) && - owner.expression.getText() === 'module' && - owner.name.text === 'exports' - ) { - return property; - } - - if (owner.getText() === 'exports') return property; - - return undefined; -}; - -const isModuleExports = expression => - ts.isPropertyAccessExpression(expression) && - expression.expression.getText() === 'module' && - expression.name.text === 'exports'; - -const expressionLocalName = expression => - ts.isIdentifier(expression) ? expression.text : undefined; - -const resolveSourceRequest = (fromFile, request) => { - if (!request.startsWith('.')) return undefined; - - const base = resolve(dirname(fromFile), request); - const candidates = [base, `${base}.js`, join(base, 'index.js')]; - return candidates.find(candidate => existsSync(candidate)); -}; - -const firstRequire = expression => { - let found; - - const visit = node => { - if (found) return; - - if ( - ts.isCallExpression(node) && - node.expression.getText() === 'require' && - ts.isStringLiteral(node.arguments[0]) - ) { - found = { - request: node.arguments[0].text, - property: - node.parent && ts.isPropertyAccessExpression(node.parent) - ? node.parent.name.text - : undefined, - }; - return; - } - - ts.forEachChild(node, visit); - }; - - visit(expression); - return found; -}; - -const listJsFiles = directory => { - const entries = readdirSync(directory, { withFileTypes: true }); - const files = []; - - for (const entry of entries) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - files.push(...listJsFiles(path)); - } else if (entry.isFile() && extname(entry.name) === '.js') { - files.push(path); - } - } - - return files; -}; - -const sourceFileFor = filePath => - ts.createSourceFile( - filePath, - readFileSync(filePath, 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.JS - ); - -const sourceFileBase = filePath => basename(filePath, extname(filePath)); - -const createFileMetadata = (filePath, libDir) => { - const sourceFile = sourceFileFor(filePath); - const symbols = new Map(); - const classes = new Map(); - const namedExports = new Map(); - let defaultExport; - - const rememberSymbol = (name, data) => { - if (!name) return; - const existing = symbols.get(name) ?? {}; - symbols.set(name, { - ...existing, - ...data, - comment: data.comment ?? existing.comment, - members: data.members ?? existing.members, - }); - }; - - // Keep per-class member comments separate from file-level exports. A public - // class comes from types.d.ts, but method/constructor prose usually lives in - // the implementation file next to the code. - const classMembers = node => { - const members = new Map(); - - for (const member of node.members ?? []) { - if (ts.isConstructorDeclaration(member)) { - const comment = getJsDocComment(member); - if (comment) members.set('constructor', comment); - continue; - } - - const name = propertyNameText(member.name); - if (!name) continue; - - const comment = getJsDocComment(member); - if (comment) members.set(name, comment); - } - - return members; - }; - - const visit = node => { - if (ts.isClassDeclaration(node) && node.name) { - const data = { - kind: 'class', - localName: node.name.text, - comment: getJsDocComment(node), - members: classMembers(node), - }; - symbols.set(node.name.text, data); - classes.set(node.name.text, data); - } - - if (ts.isFunctionDeclaration(node) && node.name) { - rememberSymbol(node.name.text, { - kind: 'function', - localName: node.name.text, - comment: getJsDocComment(node), - }); - } - - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { - const comment = getJsDocComment(node); - if (comment) { - rememberSymbol(node.name.text, { - kind: 'variable', - localName: node.name.text, - comment, - }); - } - } - - if ( - ts.isExpressionStatement(node) && - ts.isBinaryExpression(node.expression) - ) { - const { left, right } = node.expression; - const comment = getJsDocComment(node) ?? getJsDocComment(right); - - if (isModuleExports(left)) { - defaultExport = { - localName: expressionLocalName(right), - comment, - }; - } else { - const property = getAssignedProperty(left); - if (property) { - namedExports.set(property, { - localName: expressionLocalName(right), - comment, - }); - } - } - - if ( - ts.isPropertyAccessExpression(left) && - ts.isIdentifier(left.expression) && - classes.has(left.expression.text) && - comment - ) { - classes.get(left.expression.text).members.set(left.name.text, comment); - } - } - - ts.forEachChild(node, visit); - }; - - visit(sourceFile); - - return { - filePath, - fileBase: sourceFileBase(filePath), - relativePath: relative(libDir, filePath).replace(/\\/g, '/'), - symbols, - classes, - namedExports, - defaultExport, - }; -}; - -const getExportObject = sourceFile => { - let exportObject; - - const visit = node => { - if (exportObject) return; - - if ( - ts.isExpressionStatement(node) && - ts.isBinaryExpression(node.expression) && - isModuleExports(node.expression.left) && - ts.isCallExpression(node.expression.right) - ) { - const objectArg = [...node.expression.right.arguments].find( - ts.isObjectLiteralExpression - ); - if (objectArg) exportObject = objectArg; - } - - ts.forEachChild(node, visit); - }; - - visit(sourceFile); - return exportObject; -}; - -const buildExportMap = libDir => { - const indexPath = join(libDir, 'index.js'); - const sourceFile = sourceFileFor(indexPath); - const exportMap = new Map(); - const exportObject = getExportObject(sourceFile); - - const addExport = (parts, data) => { - exportMap.set(parts.join('.'), { - publicPath: parts.join('.'), - publicName: parts.at(-1), - ...data, - }); - }; - - // webpack's callable export is assigned outside lib/index.js, so seed the - // root namespace before walking the object tree of re-exported helpers. - addExport(['webpack'], { - sourcePath: join(libDir, 'webpack.js'), - localName: 'webpack', - }); - - const walkObject = (object, parts = []) => { - for (const property of object.properties) { - const name = propertyNameText(property.name); - if (!name) continue; - - if ( - ts.isPropertyAssignment(property) && - ts.isObjectLiteralExpression(property.initializer) - ) { - walkObject(property.initializer, [...parts, name]); - continue; - } - - const expression = ts.isGetAccessor(property) - ? property.body?.statements.filter(ts.isReturnStatement).at(-1) - ?.expression - : ts.isPropertyAssignment(property) - ? property.initializer - : undefined; - - if (!expression) continue; - - const requireCall = firstRequire(expression); - const sourcePath = requireCall?.request - ? resolveSourceRequest(indexPath, requireCall.request) - : undefined; - - addExport([...parts, name], { - sourcePath, - localName: requireCall?.property, - comment: getJsDocComment(property), - }); - } - }; - - if (exportObject) walkObject(exportObject); - return exportMap; -}; - -const buildSourceIndex = libDir => { - const sourceFiles = new Map(); - for (const file of listJsFiles(libDir)) { - sourceFiles.set(file, createFileMetadata(file, libDir)); - } - return sourceFiles; -}; - -const publicName = reflection => - getPublicName(reflection) ?? reflection.getFullName?.(); - -const exportEntryFor = (name, exportMap) => { - const exact = exportMap.get(name); - if (exact) return exact; - - const parts = name.split('.'); - for (let i = parts.length - 1; i > 0; i--) { - const parent = exportMap.get(parts.slice(0, i).join('.')); - if (!parent?.sourcePath) continue; - - const memberPath = parts.slice(i); - // If lib/index.js exports an object, types.d.ts may expose its properties as - // public members. Reuse the parent source file and match the leaf locally. - return { - ...parent, - publicPath: name, - publicName: memberPath.at(-1), - localName: memberPath.at(-1), - parentPublicPath: parent.publicPath, - memberPath, - }; - } -}; - -const sourceEntryFor = (entry, sourceFiles) => { - const file = entry.sourcePath ? sourceFiles.get(entry.sourcePath) : undefined; - if (!file) return undefined; - - const exported = entry.publicName - ? file.namedExports.get(entry.publicName) - : undefined; - const localName = - entry.localName ?? - exported?.localName ?? - file.defaultExport?.localName ?? - entry.publicName; - const symbol = - file.symbols.get(localName) ?? file.symbols.get(entry.publicName); - - return { - file, - localName, - comment: - symbol?.comment ?? - entry.comment ?? - exported?.comment ?? - file.defaultExport?.comment, - symbol, - }; -}; - -const commentForSourceEntry = sourceEntry => - sourceEntry?.comment ?? sourceEntry?.symbol?.comment; - -const memberSourceComment = (sourceEntry, memberName) => - sourceEntry?.symbol?.members?.get(memberName) ?? - sourceEntry?.file.namedExports.get(memberName)?.comment ?? - sourceEntry?.file.symbols.get(memberName)?.comment; - -const namedExportComment = (sourceEntry, memberName) => { - const exported = sourceEntry?.file.namedExports.get(memberName); - if (!exported) return undefined; - return ( - exported.comment ?? - sourceEntry.file.symbols.get(exported.localName)?.comment ?? - sourceEntry.file.symbols.get(memberName)?.comment - ); -}; - -const ownSignatures = reflection => [ - ...(reflection.signatures ?? []), - ...(reflection.type instanceof ReflectionType - ? (reflection.type.declaration.signatures ?? []) - : []), -]; - -const applyFunctionLikeComment = (reflection, sourceComment) => { - if (!hasRenderableCommentContent(sourceComment)) return; - - const signatures = ownSignatures(reflection); - if (signatures.length) { - signatures.forEach(signature => - applySignatureComment(signature, sourceComment) - ); - } else { - assignComment(reflection, sourceComment); - } -}; - -// Exported object literals (for example helper namespaces) often document their -// individual properties as named exports in the implementation file. -const applyObjectMembers = (reflection, sourceEntry) => { - const declaration = - reflection.type instanceof ReflectionType - ? reflection.type.declaration - : undefined; - - for (const child of declaration?.children ?? []) { - const sourceComment = - namedExportComment(sourceEntry, child.name) ?? - sourceEntry?.file.symbols.get(child.name)?.comment; - - if (!hasRenderableCommentContent(sourceComment)) continue; - - const signatures = ownSignatures(child); - if (signatures.length) { - signatures.forEach(signature => - applySignatureComment(signature, sourceComment) - ); - } else { - assignComment(child, sourceComment); - } - } -}; - -const applyNamespaceMembers = (reflection, sourceEntry) => { - for (const child of reflection.children ?? []) { - const sourceComment = - namedExportComment(sourceEntry, child.name) ?? - sourceEntry?.file.symbols.get(child.name)?.comment; - - if (!hasRenderableCommentContent(sourceComment)) continue; - - applyFunctionLikeComment(child, sourceComment); - } -}; - -const applyClassMembers = (reflection, sourceEntry) => { - assignComment(reflection, commentForSourceEntry(sourceEntry)); - - for (const child of reflection.children ?? []) { - if (child.kindOf(ReflectionKind.Constructor)) { - const comment = memberSourceComment(sourceEntry, 'constructor'); - child.signatures?.forEach(signature => - applySignatureComment(signature, comment) - ); - continue; - } - - const comment = memberSourceComment(sourceEntry, child.name); - if (!hasRenderableCommentContent(comment)) continue; - - if (child.signatures?.length) { - child.signatures.forEach(signature => - applySignatureComment(signature, comment) - ); - } else { - assignComment(child, comment); - } - } -}; - -const attachSourceMetadata = (reflection, sourceEntry, exportEntry) => { - if (!sourceEntry?.file) return; - - const leaf = publicName(reflection)?.split('.').at(-1); - const fileBase = sourceEntry.file.fileBase; - - setSourceMetadata(reflection, { - sourcePath: sourceEntry.file.filePath, - sourceRelativePath: sourceEntry.file.relativePath, - fileBase, - anchorName: fileBase !== leaf ? leaf : undefined, - publicPath: exportEntry.publicPath, - }); -}; - -const getWebpackPath = () => { - const versions = JSON.parse(readFileSync('./versions.json')); - return versions ? `./.cache/webpack/${versions[0]}/lib` : `./webpack/lib`; -}; - -/** - * Enrich declaration reflections with comments and source locations recovered - * from webpack/lib. The declaration file remains authoritative for public API - * shape; this pass only adds documentation content and routing hints. - * - * @param {import('typedoc').ProjectReflection} project - * @param {{ libDir?: string }} [options] - */ -export const applySourceMetadata = project => { - const absoluteLibDir = resolve(getWebpackPath()); - if (!existsSync(absoluteLibDir) || !statSync(absoluteLibDir).isDirectory()) { - return; - } - - const sourceFiles = buildSourceIndex(absoluteLibDir); - const exportMap = buildExportMap(absoluteLibDir); - - // The declaration file remains authoritative for shape. This pass only - // enriches public reflections that can be matched back to webpack/lib source. - for (const reflection of project.getReflectionsByKind(ReflectionKind.All)) { - if ( - !reflection.kindOf( - ReflectionKind.Class | - ReflectionKind.Namespace | - ReflectionKind.Function | - ReflectionKind.Variable - ) - ) { - continue; - } - - const name = publicName(reflection); - const exportEntry = name ? exportEntryFor(name, exportMap) : undefined; - if (!exportEntry?.sourcePath) continue; - - const sourceEntry = sourceEntryFor(exportEntry, sourceFiles); - attachSourceMetadata(reflection, sourceEntry, exportEntry); - - if (reflection.kindOf(ReflectionKind.Namespace)) { - applyNamespaceMembers(reflection, sourceEntry); - continue; - } - - if (reflection.kindOf(ReflectionKind.Class)) { - applyClassMembers(reflection, sourceEntry); - continue; - } - - applyFunctionLikeComment(reflection, commentForSourceEntry(sourceEntry)); - applyObjectMembers(reflection, sourceEntry); - } -}; diff --git a/plugins/processor/synthetic.mjs b/plugins/processor/synthetic.mjs deleted file mode 100644 index d6e68ebf..00000000 --- a/plugins/processor/synthetic.mjs +++ /dev/null @@ -1,142 +0,0 @@ -import { Comment, DeclarationReflection, ReflectionKind } from 'typedoc'; -import { categoryForReflection } from '../shared/categories.mjs'; -import { setTypePageMetadata } from './metadata.mjs'; -import { soleOwnerPages } from './ownership.mjs'; - -export const TYPE_PAGE_HEADING_KINDS = - ReflectionKind.Interface | ReflectionKind.TypeAlias; - -const TYPE_PAGE_NOTICE = - 'These types are not exported by webpack, but they are available to TypeScript consumers.'; - -const categoryNameForReflection = reflection => - categoryForReflection(reflection)?.category; - -const typePageBaseName = reflection => { - const category = categoryNameForReflection(reflection); - return category ? `${category}/types` : 'types'; -}; - -const typePageNamespace = baseName => - baseName === 'types' - ? 'webpack' - : `webpack.${baseName.replace(/\/types$/, '').replace(/\//g, '.')}`; - -const typePageTitle = baseName => `\`${typePageNamespace(baseName)}\` Types`; - -const typePageName = baseName => baseName.replace(/\//g, '.'); - -const typePageComment = () => - new Comment([ - { - kind: 'text', - text: TYPE_PAGE_NOTICE, - }, - ]); - -const attachToOwner = (owner, children) => { - for (const child of children) { - child.parent = owner; - owner.addChild(child); - } - - owner.groups = [...(owner.groups ?? []), { title: 'Types', children }]; -}; - -const removeChildren = (items, moved) => - items?.filter(item => !moved.has(item)); - -const removeFromGroups = (groups, moved) => - groups - ?.map(group => ({ - ...group, - children: group.children.filter(child => !moved.has(child)), - categories: removeFromGroups(group.categories, moved), - })) - .filter(group => group.children.length || group.categories?.length); - -const makeTypeGroup = (title, children) => - children.length ? { title, children } : undefined; - -const createTypePage = (project, baseName, children) => { - const page = new DeclarationReflection( - typePageName(baseName), - ReflectionKind.Namespace, - project - ); - const interfaces = children.filter(child => - child.kindOf(ReflectionKind.Interface) - ); - const typeAliases = children.filter(child => - child.kindOf(ReflectionKind.TypeAlias) - ); - - page.children = children; - page.childrenIncludingDocuments = children; - page.comment = typePageComment(); - page.groups = [ - makeTypeGroup('Interfaces', interfaces), - makeTypeGroup('Type Aliases', typeAliases), - ].filter(Boolean); - - setTypePageMetadata(page, { - baseName, - title: typePageTitle(baseName), - }); - - return page; -}; - -const compareByName = (a, b) => a.name.localeCompare(b.name); - -/** - * Move root interfaces and type aliases onto the page that owns them so they - * remain linkable without appearing as root API exports. - * - * @param {import('typedoc').ProjectReflection} project - */ -export const createTypePages = project => { - const movedTypes = (project.children ?? []) - .filter(child => child.kindOf(TYPE_PAGE_HEADING_KINDS)) - .sort(compareByName); - const movedSet = new Set(movedTypes); - const ownerPages = soleOwnerPages(project, movedTypes); - const byPage = new Map(); - const byOwner = new Map(); - - for (const reflection of movedTypes) { - const owner = ownerPages.get(reflection); - - if (owner) { - const siblings = byOwner.get(owner) ?? []; - siblings.push(reflection); - byOwner.set(owner, siblings); - continue; - } - - const baseName = typePageBaseName(reflection); - const group = byPage.get(baseName) ?? []; - group.push(reflection); - byPage.set(baseName, group); - } - - // Remove moved structural types from the project root before TypeDoc builds - // README.md; their URLs are rebuilt by the router against these pages. - project.children = removeChildren(project.children, movedSet); - project.childrenIncludingDocuments = removeChildren( - project.childrenIncludingDocuments, - movedSet - ); - project.groups = removeFromGroups(project.groups, movedSet); - project.categories = removeFromGroups(project.categories, movedSet); - - for (const [owner, children] of byOwner) { - attachToOwner(owner, children); - } - - return [...byPage].map(([baseName, children]) => ({ - baseName, - children, - model: createTypePage(project, baseName, children), - })); -}; diff --git a/plugins/processor/typeMap.mjs b/plugins/processor/typeMap.mjs deleted file mode 100644 index 12116c65..00000000 --- a/plugins/processor/typeMap.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { ReflectionKind } from 'typedoc'; -import { isTypePage } from './metadata.mjs'; -import { normalizeLink } from '../../utils/helpers/urls.mjs'; - -const TYPE_MAP_KINDS = - ReflectionKind.Class | - ReflectionKind.Interface | - ReflectionKind.TypeAlias | - ReflectionKind.Enum; - -const isTypeMapTarget = target => - !isTypePage(target) && target.kindOf?.(TYPE_MAP_KINDS); - -/** - * Build the annotation resolver map from the router's final public URLs. - * - * @param {import('typedoc-plugin-markdown').Router} router - */ -export const createTypeMap = router => { - const typeMap = new Map(); - - for (const target of router.getLinkTargets()) { - if (!isTypeMapTarget(target)) continue; - - const { name, escapedName } = target; - const url = normalizeLink(router.getAnchoredURL(target)); - - if (!typeMap.has(name)) { - typeMap.set(name, url); - } - - if (escapedName && !typeMap.has(escapedName)) { - typeMap.set(escapedName, url); - } - } - - return Object.fromEntries(typeMap); -}; diff --git a/plugins/shared/categories.mjs b/plugins/shared/categories.mjs deleted file mode 100644 index 7af9d9b5..00000000 --- a/plugins/shared/categories.mjs +++ /dev/null @@ -1,152 +0,0 @@ -import { ReflectionKind } from 'typedoc'; - -// First match wins. Keep more specific groups above broad API-family rules so -// adding a new category is usually a single RegExp entry rather than router code. -export const CATEGORY_RULES = [ - { - category: 'optimize', - label: 'Optimization', - match: reflection => /^optimize\./i.test(reflection.getFullName()), - }, - { - category: 'container', - label: 'Container', - }, - { - category: 'debug', - label: 'Debug', - }, - { - category: 'experiments', - label: 'Experiments', - }, - { - category: 'ids', - label: 'IDs', - }, - { - category: 'javascript', - label: 'JavaScript', - }, - { - category: 'plugins', - label: 'Plugins', - match: reflection => - reflection.kindOf(ReflectionKind.Class) && - reflection.name.endsWith('Plugin'), - pattern: /^WebpackPlugin/, - }, - { - category: 'cli', - label: 'Command-Line Interface', - pattern: /^(?:Argument|Colors|ColorsOptions|Problem)$/, - }, - { - category: 'assets', - label: 'Assets', - pattern: /^Asset|AssetInfo$/, - }, - { - category: 'cache', - label: 'Cache', - pattern: /Cache|Cached|Etag|ValueCache/, - }, - { - category: 'runtime', - label: 'Runtime', - pattern: /^Runtime.*/, - }, - { - category: 'stats', - label: 'Stats', - pattern: /^(?:Multi)?Stats/, - }, - { - category: 'errors', - label: 'Errors', - pattern: /(?:Error|ValidationError)$/, - }, - { - category: 'chunks', - label: 'Chunks', - pattern: /^(?:.*Chunk.*|Entrypoint)$/, - }, - { - category: 'compilation', - label: 'Compilation', - pattern: - /^(?:Compilation|Compiler|MultiCompiler|Watching|PathData|CodeGenerationResults?)$/, - }, - { - category: 'dependencies', - label: 'Dependencies', - pattern: /Dependency/, - }, - { - category: 'entries', - label: 'Entries', - pattern: /^Entry/, - }, - { - category: 'externals', - label: 'Externals', - pattern: /^External|Externals/, - }, - { - category: 'filesystem', - label: 'File System', - pattern: /FileSystem$/, - }, - { - category: 'library', - label: 'Library', - pattern: /Library/, - }, - { - category: 'loaders', - label: 'Loaders', - pattern: /Loader/, - }, - { - category: 'modules', - label: 'Modules', - pattern: - /^(?:AsyncDependenciesBlock|.*Dependency|.*Module.*|Generator|Parser)$/, - }, - { - category: 'resolvers', - label: 'Resolvers', - pattern: /^Resolve/, - }, - { - category: 'rules', - label: 'Rules', - pattern: /^RuleSet/, - }, - { - category: 'serialization', - label: 'Serialization', - pattern: /(?:Serializer|Deserializer)/, - }, - { - category: 'templates', - label: 'Templates', - pattern: /^(?:Template|RenderManifest)/, - }, - { - category: 'config', - label: 'Configuration', - pattern: - /^(?:Configuration|MultiConfiguration|.*Options(?:Normalized)?|validate(?:Schema)?|WebpackOptions.*)$/, - }, -]; - -export const categoryForReflection = reflection => { - for (const rule of CATEGORY_RULES) { - if (rule.match?.(reflection) || rule.pattern?.test(reflection.name)) { - return rule; - } - } - - return null; -}; diff --git a/plugins/shared/titles.mjs b/plugins/shared/titles.mjs deleted file mode 100644 index 1ba9d6e2..00000000 --- a/plugins/shared/titles.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { ReflectionKind } from 'typedoc'; -import { getPublicName } from '../processor/metadata.mjs'; - -// Heading text is also anchor input. Keep all programmatic names formatted here -// so the Markdown theme and router cannot drift into different slugs. -const KIND_PREFIX = { - [ReflectionKind.Class]: 'Class', - [ReflectionKind.Interface]: 'Interface', - [ReflectionKind.Enum]: 'Enum', - [ReflectionKind.TypeAlias]: 'Type', - [ReflectionKind.Namespace]: 'Namespace', - [ReflectionKind.Accessor]: 'Accessor', -}; - -const STATIC_PREFIX = { - [ReflectionKind.Method]: 'Static method', -}; - -const escapeCode = value => String(value).replace(/`/g, '\\`'); - -const hasQualifiedParent = model => { - let parent = model.parent; - - while (parent) { - if (parent.kindOf?.(ReflectionKind.Class | ReflectionKind.Namespace)) { - return true; - } - - if (parent.kindOf?.(ReflectionKind.Project)) { - return false; - } - - parent = parent.parent; - } - - return false; -}; - -const memberName = (model, { local = false } = {}) => { - return local || hasQualifiedParent(model) ? model.name : fullName(model); -}; - -export const fullName = model => { - const publicName = getPublicName(model); - if (publicName) return publicName; - - const name = model.getFullName?.() ?? model.name; - let root = model; - - while (root.parent) root = root.parent; - - if (model === root) { - return name; - } - - // TypeDoc omits the project name from nested full names. The generated docs - // treat the project as the public webpack namespace, so add it back whenever - // TypeDoc has not already included it. - if (!root.name || name.startsWith(`${root.name}.`)) { - return name; - } - - return `${root.name}.${name}`; -}; - -export const formatParams = (params = []) => { - let result = ''; - let openBrackets = 0; - - for (let i = 0; i < params.length; i++) { - const { name, flags } = params[i]; - const isRest = flags?.isRest ?? false; - const paramName = isRest ? `...${name}` : name; - - if (isRest || flags?.isOptional) { - result += (i ? '[, ' : '[') + paramName; - openBrackets++; - } else { - if (openBrackets) { - result += ']'.repeat(openBrackets); - openBrackets = 0; - } - result += (i ? ', ' : '') + paramName; - } - } - - return result + ']'.repeat(openBrackets); -}; - -export const signatureExpression = ( - model, - params = [], - name = fullName(model) -) => `${name}(${formatParams(params)})`; - -export const callableSignatures = model => - model.signatures ?? model.type?.declaration?.signatures ?? []; - -export const getConstructorTitle = (model, signature = model.signatures?.[0]) => - `\`new ${escapeCode(model.parent.name)}(${formatParams(signature?.parameters ?? [])})\``; - -export const getMemberPrefix = model => { - const prefix = model.flags?.isStatic - ? STATIC_PREFIX[model.kind] - : KIND_PREFIX[model.kind]; - - return prefix ? `${prefix}: ` : ''; -}; - -export const getMemberTitle = (model, options, specificSignature = null) => { - const prefix = getMemberPrefix(model); - const name = escapeCode(memberName(model, options)); - - const params = - specificSignature?.parameters ?? - model.parameters ?? - callableSignatures(model)[0]?.parameters; - if (params) { - return `${prefix}\`${escapeCode(signatureExpression(model, params, name))}\``; - } - - return `${prefix}\`${name}\``; -}; - -export const getPageTitle = model => { - const title = model.kindOf?.(ReflectionKind.Class) - ? `Class: \`${escapeCode(fullName(model))}\`` - : `\`${escapeCode(fullName(model))}\``; - - return title; -}; diff --git a/plugins/sources.mjs b/plugins/sources.mjs new file mode 100644 index 00000000..afba0162 --- /dev/null +++ b/plugins/sources.mjs @@ -0,0 +1,221 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { basename, join, relative } from 'node:path'; +import ts from 'typescript'; +import { Converter, ReflectionKind, SourceReference } from 'typedoc'; + +/** + * `@typedef {Type} Name` and `@callback Name`, but not the typedefs importing + * a type from another file (`@typedef {import("./Chunk")} Chunk`), which are + * not where it is declared. + */ +const TYPEDEF = /@(?:typedef\s+\{(?!import\().*\}|callback)\s+([\w$]+)/; +const PROPERTY = /@property\s+\{.*\}\s+\[?([\w$]+)/; + +/** @param {Map} map */ +const add = (map, name, entry) => + map.set(name, [...(map.get(name) ?? []), entry]); + +/** + * Where the members of a class are declared: methods, accessors and fields, + * then the `this.name = …` assignments of its constructor. + * + * @param {ts.ClassDeclaration} node + * @param {(node: ts.Node) => number} lineOf + */ +const classMembers = (node, lineOf) => { + const members = new Map(); + + for (const member of node.members) { + if (ts.isConstructorDeclaration(member)) { + members.set('constructor', lineOf(member)); + + for (const statement of member.body?.statements ?? []) { + const expression = ts.isExpressionStatement(statement) + ? statement.expression + : undefined; + + if ( + expression && + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(expression.left) && + expression.left.expression.kind === ts.SyntaxKind.ThisKeyword && + !members.has(expression.left.name.text) + ) { + members.set(expression.left.name.text, lineOf(statement)); + } + } + } else if (member.name && ts.isIdentifier(member.name)) { + if (!members.has(member.name.text)) { + members.set(member.name.text, lineOf(member)); + } + } + } + + return members; +}; + +/** + * Where webpack's `lib` declares its classes, top-level functions and + * constants, and JSDoc typedefs, by name. + * + * @param {string} root The webpack package + */ +const indexSources = root => { + const classes = new Map(); + const values = new Map(); + const typedefs = new Map(); + + const files = readdirSync(join(root, 'lib'), { recursive: true }) + .filter(file => file.endsWith('.js')) + .map(file => join(root, 'lib', file)); + + for (const path of files) { + const file = relative(root, path); + const text = readFileSync(path, 'utf8'); + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest); + const lineOf = node => + source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + + for (const statement of source.statements) { + if (ts.isClassDeclaration(statement) && statement.name) { + add(classes, statement.name.text, { + file, + line: lineOf(statement), + members: classMembers(statement, lineOf), + }); + } else if (ts.isFunctionDeclaration(statement) && statement.name) { + add(values, statement.name.text, { file, line: lineOf(statement) }); + } else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + add(values, declaration.name.text, { + file, + line: lineOf(statement), + }); + } + } + } + } + + // Typedefs are comments, so they are read line by line, with the + // `@property` lines of the comment each one is in. + const lines = text.split('\n'); + lines.forEach((content, index) => { + const name = content.match(TYPEDEF)?.[1]; + if (!name) return; + + const members = new Map(); + for (let next = index + 1; next < lines.length; next++) { + if (lines[next].includes('*/') || TYPEDEF.test(lines[next])) break; + + const property = lines[next].match(PROPERTY)?.[1]; + if (property && !members.has(property)) members.set(property, next + 1); + } + + add(typedefs, name, { file, line: index + 1, members }); + }); + } + + return { classes, values, typedefs }; +}; + +/** + * The one declaration of a name, or the one in a file named after it when + * several files declare it. + * + * @param {object[] | undefined} entries + * @param {string} name + */ +const pick = (entries = [], name) => { + if (entries.length === 1) return entries[0]; + + const named = entries.filter(({ file }) => basename(file, '.js') === name); + return named.length === 1 ? named[0] : undefined; +}; + +/** + * Points the source links at the JavaScript in webpack's `lib` that + * implements each declaration, rather than at `types.d.ts`, which only + * declares it. Declarations not found there get no source link. + * + * @param {import('typedoc').Application} app + */ +export const load = app => { + app.converter.on( + Converter.EVENT_RESOLVE_END, + ({ project }) => { + const { classes, values, typedefs } = indexSources( + app.options.getValue('basePath') + ); + + /** @type {Map} */ + const located = new Map(); + + /** @param {import('typedoc').Reflection} reflection */ + const ownerOf = reflection => { + const { parent } = reflection; + + if (parent?.kindOf(ReflectionKind.Class)) { + return pick(classes.get(parent.name), parent.name); + } + if ( + parent?.kindOf(ReflectionKind.Interface | ReflectionKind.TypeAlias) + ) { + return pick(typedefs.get(parent.name), parent.name); + } + }; + + /** @param {import('typedoc').Reflection} reflection */ + const find = reflection => { + if (reflection.isSignature?.()) return locate(reflection.parent); + + if (reflection.kindOf(ReflectionKind.Class)) { + return pick(classes.get(reflection.name), reflection.name); + } + if ( + reflection.kindOf(ReflectionKind.Function | ReflectionKind.Variable) + ) { + return pick(values.get(reflection.name), reflection.name); + } + if ( + reflection.kindOf(ReflectionKind.Interface | ReflectionKind.TypeAlias) + ) { + return pick(typedefs.get(reflection.name), reflection.name); + } + + // Inherited members are declared where they are inherited from + const inherited = reflection.inheritedFrom?.reflection; + if (inherited && inherited !== reflection) return locate(inherited); + + const owner = ownerOf(reflection); + const name = reflection.kindOf(ReflectionKind.Constructor) + ? 'constructor' + : reflection.name; + const line = owner?.members.get(name); + + return line ? { file: owner.file, line } : undefined; + }; + + /** @param {import('typedoc').Reflection} reflection */ + const locate = reflection => { + if (!located.has(reflection)) { + located.set(reflection, undefined); + located.set(reflection, find(reflection)); + } + return located.get(reflection); + }; + + for (const reflection of Object.values(project.reflections)) { + if (!reflection.sources) continue; + + const source = locate(reflection); + reflection.sources = source + ? [new SourceReference(source.file, source.line, 0)] + : undefined; + } + }, + // After `plugins/webpack.mjs` merges the declarations into the project + -100 + ); +}; diff --git a/plugins/theme/helpers/index.mjs b/plugins/theme/helpers/index.mjs deleted file mode 100644 index 8ff9fa50..00000000 --- a/plugins/theme/helpers/index.mjs +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @typedef {object} TypedListEntry - * @property {string} [label] - * @property {string} [name] - * @property {import("typedoc").SomeType | string} [type] - * @property {import("typedoc").Comment | import("typedoc").CommentTag} [comment] - */ - -const formatStability = (ctx, level, tag) => { - const message = tag.content?.length - ? ctx.helpers.getCommentParts(tag.content).trim() - : ''; - return message - ? `> Stability: ${level}: ${message.replace(/\n/g, '\n> ')}` - : `> Stability: ${level}`; -}; - -/** - * @param {import('typedoc-plugin-markdown').MarkdownThemeContext} ctx - * @returns {import('typedoc-plugin-markdown').MarkdownThemeContext['helpers']} - */ -export default ctx => ({ - ...ctx.helpers, - - typedListItem({ label, name, type, comment }) { - const namePart = label ? ` ${label}:` : name ? ` \`${name}\`` : ''; - - const typePart = type - ? ` ${typeof type === 'string' ? type : ctx.partials.someType(type)}` - : ''; - - const descPart = comment - ? ` ${ctx.helpers.getCommentParts(comment.summary ?? comment.content)}` - : ''; - - return `*${namePart}${typePart}${descPart}`; - }, - - typedList(entries) { - return entries.map(ctx.helpers.typedListItem).join('\n'); - }, - - examples(comment) { - const examples = - comment?.blockTags?.filter(t => t.tag === '@example') ?? []; - if (!examples.length) return null; - - // Source JSDoc examples in webpack are usually plain code snippets. The - // doc-kit spec expects code examples to be fenced, so wrap bare snippets - // while leaving already-fenced examples untouched. - return examples - .map((tag, i) => { - const body = ctx.helpers.getCommentParts(tag.content).trim(); - if (!body) return; - if (body.includes('```')) return body; - - const displayName = - examples.length > 1 ? ` displayName="Example ${i + 1}"` : ''; - return `\`\`\`js${displayName}\n${body}\n\`\`\``; - }) - .filter(Boolean) - .join('\n\n'); - }, - - stabilityBlockquote(comment) { - if (!comment) return null; - - const deprecated = comment.blockTags?.find(t => t.tag === '@deprecated'); - if (deprecated) return formatStability(ctx, '0 - Deprecated', deprecated); - - if ( - comment.modifierTags?.has('@experimental') || - comment.modifierTags?.has('@beta') - ) { - return '> Stability: 1 - Experimental'; - } - - const legacy = comment.blockTags?.find(t => t.tag === '@legacy'); - if (legacy) return formatStability(ctx, '3 - Legacy', legacy); - - return null; - }, -}); diff --git a/plugins/theme/index.mjs b/plugins/theme/index.mjs deleted file mode 100644 index 245434ea..00000000 --- a/plugins/theme/index.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { MarkdownTheme, MarkdownThemeContext } from 'typedoc-plugin-markdown'; -import helpers from './helpers/index.mjs'; -import partials from './partials/index.mjs'; - -export class DocKitTheme extends MarkdownTheme { - getRenderContext(page) { - return new DocKitThemeContext(this, page, this.application.options); - } -} - -export class DocKitThemeContext extends MarkdownThemeContext { - helpers = helpers(this); - - partials = partials(this); - - templates = { - ...this.templates, - }; -} - -/** - * @param {import('typedoc-plugin-markdown').MarkdownApplication} app - */ -export function load(app) { - app.renderer.defineTheme('doc-kit', DocKitTheme); -} diff --git a/plugins/theme/partials/index.mjs b/plugins/theme/partials/index.mjs deleted file mode 100644 index e3cad8b6..00000000 --- a/plugins/theme/partials/index.mjs +++ /dev/null @@ -1,395 +0,0 @@ -import { getTypePageTitle, isTypePage } from '../../processor/metadata.mjs'; -import { - ArrayType, - i18n, - IntersectionType, - ReflectionKind, - ReflectionType, - UnionType, -} from 'typedoc'; -import { - callableSignatures, - getConstructorTitle, - getMemberTitle, - getPageTitle, -} from '../../shared/titles.mjs'; -import * as typePartials from './types.mjs'; - -const heading = (level, title) => `${'#'.repeat(level)} ${title}`; - -// These partials deliberately trade TypeDoc's default blockquote signatures for -// doc-kit headings and typed lists. The router uses the same title helpers, so -// rendered anchors and type-map URLs stay in sync. -const inheritedComment = (model, options) => - options.multipleSignatures - ? model.comment - : model.comment || model.parent?.comment; - -const declarationType = model => - model.type ?? - model.getSignature?.type ?? - model.setSignature?.parameters?.[0]?.type; - -/** - * @param {import('typedoc-plugin-markdown').MarkdownThemeContext} ctx - * @returns {import('typedoc-plugin-markdown').MarkdownThemeContext['partials']} - */ -export default ctx => { - return { - ...ctx.partials, - ...typePartials, - - pageTitle() { - const model = ctx.page.model; - const typePageTitle = getTypePageTitle(model); - if (typePageTitle) return typePageTitle; - if (model.getFullName) return getPageTitle(model); - return ctx.partials.pageTitle(); - }, - - signature(model, options) { - const comment = inheritedComment(model, options); - const stability = ctx.helpers.stabilityBlockquote(comment); - - return [ - stability, - stability && '', - model.parameters?.length && - ctx.partials.parametersList(model.parameters), - ctx.helpers.typedListItem({ - label: 'Returns', - type: model.type ?? 'void', - comment: model.comment?.getTag('@returns'), - }), - '', - comment && - ctx.partials.comment(comment, { - headingLevel: options.headingLevel, - showTags: false, - }), - ctx.helpers.examples(comment, options), - ] - .filter(x => typeof x === 'string' || Boolean(x)) - .join('\n'); - }, - - signatures(model, options) { - const md = []; - const multipleSignatures = - model.signatures && model.signatures?.length > 1; - - model.signatures?.forEach(signature => { - if (multipleSignatures) { - md.push( - heading( - options.headingLevel, - getMemberTitle( - model, - { local: isTypePage(ctx.page.model) }, - signature - ) - ) - ); - } - md.push( - ctx.partials.signature(signature, { - headingLevel: multipleSignatures - ? options.headingLevel + 1 - : options.headingLevel, - nested: options.nested, - multipleSignatures: false, - }) - ); - }); - return md.join('\n\n'); - }, - - declaration(model, options) { - const opts = { headingLevel: 2, nested: false, ...options }; - const signatures = callableSignatures(model); - - if (signatures.length) { - return signatures - .map(signature => - ctx.partials.signature(signature, { - headingLevel: opts.headingLevel, - multipleSignatures: signatures.length > 1, - }) - ) - .join('\n\n'); - } - - const comment = model.comment; - const stability = ctx.helpers.stabilityBlockquote(comment); - const md = [ - stability, - stability && '', - ctx.partials.declarationTitle(model), - ]; - - if (comment) { - md.push( - '', - ctx.partials.comment(comment, { - headingLevel: opts.headingLevel, - showSummary: true, - showTags: false, - }) - ); - } - - let typeDeclaration = model.type?.declaration; - if ( - model.type instanceof ArrayType && - model.type.elementType instanceof ReflectionType - ) { - typeDeclaration = model.type.elementType.declaration; - } - - if (model.type instanceof IntersectionType) { - for (const intersectionType of model.type.types ?? []) { - if ( - intersectionType instanceof ReflectionType && - intersectionType.declaration.children && - !intersectionType.declaration.signatures - ) { - md.push(heading(opts.headingLevel, i18n.theme_type_declaration())); - md.push( - ctx.partials.typeDeclaration(intersectionType.declaration, { - headingLevel: opts.headingLevel, - }) - ); - } - } - } - - const hasUnionDetails = - model.type instanceof UnionType && - ctx.helpers.hasUsefulTypeDetails(model.type); - if (hasUnionDetails) { - md.push(heading(opts.headingLevel, i18n.theme_union_members())); - md.push(ctx.partials.typeDeclarationUnionContainer(model, opts)); - } else if (typeDeclaration) { - const useHeading = - typeDeclaration.children?.length && - (model.kind !== ReflectionKind.Property || - ctx.helpers.useTableFormat('properties')); - if (useHeading) { - md.push(heading(opts.headingLevel, i18n.theme_type_declaration())); - } - md.push( - ctx.partials.typeDeclarationContainer(model, typeDeclaration, opts) - ); - } - - md.push(ctx.helpers.examples(comment, opts)); - md.push( - ctx.partials.inheritance(model, { headingLevel: opts.headingLevel }) - ); - - return md.filter(x => typeof x === 'string' || Boolean(x)).join('\n\n'); - }, - - declarationTitle(model) { - return ctx.helpers.typedListItem({ - label: 'Type', - type: declarationType(model) ?? 'unknown', - }); - }, - - indexSignature(model) { - const params = model.parameters ?? []; - const name = params.length - ? `[${params - .map(param => `${param.name}: ${ctx.partials.someType(param.type)}`) - .join(', ')}]` - : 'index'; - - return ctx.helpers.typedListItem({ - name, - type: model.type, - comment: model.comment, - }); - }, - - memberContainer(model, options) { - const md = []; - - const isOverloadedMethod = - model.signatures && - model.signatures.length > 1 && - [ReflectionKind.Function, ReflectionKind.Method].includes(model.kind); - - if ( - !isOverloadedMethod && - !ctx.router.hasOwnDocument(model) && - model.kind !== ReflectionKind.Constructor - ) { - md.push(heading(options.headingLevel, ctx.partials.memberTitle(model))); - } - - md.push( - ctx.partials.member(model, { - headingLevel: options.headingLevel, - nested: options.nested, - }) - ); - return md.join('\n\n'); - }, - - memberWithGroups(model, options) { - const md = []; - const stability = ctx.helpers.stabilityBlockquote(model.comment); - - if (stability) md.push(stability); - - if (model.kind === ReflectionKind.TypeAlias) { - md.push(ctx.partials.declarationTitle(model)); - } - - if (model.comment) { - md.push( - ctx.partials.comment(model.comment, { - headingLevel: options.headingLevel, - showTags: false, - }) - ); - } - - if (model.typeHierarchy?.next && !model.typeHierarchy.isTarget) { - md.push( - ctx.helpers.typedListItem({ - label: 'Extends', - type: model.typeHierarchy.types[0], - }) - ); - } - - const signatures = callableSignatures(model); - const isCallable = signatures.length > 0; - - if (!isCallable && model.typeParameters?.length) { - const typeParametersContent = ctx.partials.typeParametersList( - model.typeParameters, - { - headingLevel: options.headingLevel, - } - ); - if (typeParametersContent) { - md.push( - heading( - options.headingLevel, - ReflectionKind.pluralString(ReflectionKind.TypeParameter) - ) - ); - md.push(typeParametersContent); - } - } - - if (model.implementedTypes?.length) { - md.push(heading(options.headingLevel, i18n.theme_implements())); - md.push( - model.implementedTypes - .map(type => `* ${ctx.partials.someType(type)}`) - .join('\n') - ); - } - - if (model.kind === ReflectionKind.Class && model.categories?.length) { - model.groups - ?.filter(group => group.title === i18n.kind_plural_constructor()) - .forEach(group => { - md.push( - heading(options.headingLevel, i18n.kind_plural_constructor()) - ); - group.children.forEach(child => { - md.push( - ctx.partials.constructor(child, { - headingLevel: options.headingLevel + 1, - }) - ); - }); - }); - } - - if (model.signatures?.length) { - const multipleSignatures = model.signatures.length > 1; - model.signatures.forEach(signature => { - if (multipleSignatures) { - md.push( - heading( - options.headingLevel, - getMemberTitle( - model, - { local: isTypePage(ctx.page.model) }, - signature - ) - ) - ); - } - md.push( - ctx.partials.signature(signature, { - headingLevel: multipleSignatures - ? options.headingLevel + 1 - : options.headingLevel, - multipleSignatures, - }) - ); - }); - } - - if (model.indexSignatures?.length) { - md.push(heading(options.headingLevel, i18n.theme_indexable())); - model.indexSignatures.forEach(indexSignature => { - md.push( - ctx.partials.indexSignature(indexSignature, { - headingLevel: options.headingLevel + 1, - }) - ); - }); - } - - md.push(ctx.partials.body(model, { headingLevel: options.headingLevel })); - return md.filter(x => typeof x === 'string' || Boolean(x)).join('\n\n'); - }, - - constructor(model, options) { - const md = []; - const heading = '#'.repeat(options.headingLevel); - - model.signatures?.forEach(signature => { - md.push(`${heading} ${getConstructorTitle(model, signature)}`); - md.push( - ctx.partials.signature(signature, { - headingLevel: options.headingLevel + 1, - hideTypeParameters: true, - }) - ); - }); - return md.join('\n\n'); - }, - - memberTitle: model => - getMemberTitle(model, { local: isTypePage(ctx.page.model) }), - parametersList(parameters) { - const flatList = []; - - parameters.forEach(param => { - if ( - param.type?.type === 'reflection' && - param.type.declaration?.children - ) { - flatList.push(...param.type.declaration.children); - } else { - flatList.push(param); - } - }); - - return ctx.helpers.typedList(flatList); - }, - typeDeclarationList: ctx.helpers.typedList, - propertiesTable: ctx.helpers.typedList, - - typeParametersList: () => '', - }; -}; diff --git a/plugins/theme/partials/types.mjs b/plugins/theme/partials/types.mjs deleted file mode 100644 index a638ddec..00000000 --- a/plugins/theme/partials/types.mjs +++ /dev/null @@ -1,20 +0,0 @@ -export const someType = model => `{${model ?? 'unknown'}}`; - -export const arrayType = someType, - conditionalType = someType, - indexAccessType = someType, - inferredType = someType, - intersectionType = someType, - intrinsicType = someType, - literalType = someType, - declarationType = someType, - functionType = someType, - namedTupleType = someType, - optionalType = someType, - queryType = someType, - referenceType = someType, - reflectionType = someType, - tupleType = someType, - typeOperatorType = someType, - unionType = someType, - unknownType = someType; diff --git a/plugins/webpack.mjs b/plugins/webpack.mjs new file mode 100644 index 00000000..07c3e3b1 --- /dev/null +++ b/plugins/webpack.mjs @@ -0,0 +1,37 @@ +import { Converter, ReflectionKind } from 'typedoc'; + +/** + * Containers TypeDoc puts webpack's declarations in, which `@doc-kit/typedoc` + * does not look into: it documents the children of the project (or of its + * entry-point modules). + * + * - `export=`: webpack's `types.d.ts` is a CommonJS declaration file, and + * `export = exports` parks the real exports in a namespace of that name. + * - ``: `typedoc-plugin-missing-exports` collects the unexported + * types the exports refer to in a module of that name. + */ +const CONTAINERS = new Set(['export=', '']); + +/** + * Merges both containers into the project, so their declarations get pages. + * The callable side of `export =` (a function named `export=`) is dropped: + * it is `webpack()`, which the `webpack` variable already documents. + * + * @param {import('typedoc').Application} app + */ +export const load = app => { + app.converter.on(Converter.EVENT_RESOLVE_END, ({ project }) => { + const containers = (project.children ?? []).filter( + child => + child.kindOf(ReflectionKind.SomeModule) && CONTAINERS.has(child.name) + ); + + for (const container of containers) { + project.mergeReflections(container, project); + } + + for (const child of [...(project.children ?? [])]) { + if (child.name === 'export=') project.removeReflection(child); + } + }); +}; diff --git a/scripts/html/doc-kit.config.mjs b/scripts/html/doc-kit.config.mjs index 2c4c48e7..51cee2b4 100644 --- a/scripts/html/doc-kit.config.mjs +++ b/scripts/html/doc-kit.config.mjs @@ -48,10 +48,9 @@ export default { : undefined, }, - 'jsx-ast': { - generateIndexPage: false, - generateAllPage: false, - }, + // Source links point at the published version's tag, whose line numbers + // the API reference was generated from. + 'jsx-ast': VERSION ? { ref: VERSION } : {}, 'llms-txt': { templatePath: join(ROOT, 'scripts/html/llms-template.txt'), @@ -59,11 +58,14 @@ export default { }, html: { + generateAllPage: false, useAbsoluteURLs: true, remoteConfigUrl: '/assets/banners.json', title: VERSION ? `Webpack ${MAJOR_VERSION} Documentation` : 'Webpack', - editURL: - 'https://github.com/webpack/webpack.js.org/blob/main/pages/{path}.md', + // The versioned API reference is generated, so it has nothing to edit. + editURL: VERSION + ? undefined + : 'https://github.com/webpack/webpack.js.org/blob/main/pages/{path}.md', head: { meta: [ { diff --git a/scripts/markdown/api/index.mjs b/scripts/markdown/api/index.mjs index 848a7af3..3c748a9e 100644 --- a/scripts/markdown/api/index.mjs +++ b/scripts/markdown/api/index.mjs @@ -1,6 +1,8 @@ +import { readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; import { Application } from 'typedoc'; import { major } from 'semver'; +import { buildIndex, buildSidebar } from './sidebar.mjs'; import { getSources, outputDir, @@ -8,18 +10,57 @@ import { typeDocConfig, } from './utils.mjs'; +const readJson = async file => JSON.parse(await readFile(file, 'utf8')); + +const writeJson = (file, value) => + writeFile(file, `${JSON.stringify(value, null, 2)}\n`); + +/** + * doc-kit resolves type-map entries relative to the root of its input, which + * for a versioned build is the version's directory rather than the site root + * the plugin's paths start from. Strip the base path from the entries. + */ +const localizeTypeMap = async (out, basePath) => { + const file = join(out, 'type-map.json'); + const prefix = `${basePath.slice(1)}/`; + const typeMap = await readJson(file); + + for (const [name, url] of Object.entries(typeMap)) { + if (url.startsWith(prefix)) typeMap[name] = url.slice(prefix.length); + } + + await writeJson(file, typeMap); +}; + const generate = async packageDir => { const { version } = await getPackageFile(packageDir); + const out = join(outputDir, `v${major(version)}.x`); + const basePath = `/docs/api/v${major(version)}.x`; + + // The plugin doesn't empty its output, so pages of removed exports linger. + await rm(out, { recursive: true, force: true }); const app = await Application.bootstrapWithPlugins({ ...typeDocConfig, entryPoints: [join(packageDir, 'types.d.ts')], - out: join(outputDir, `v${major(version)}.x`), - publicPath: `/docs/api/v${major(version)}.x/`, + // Source links are relative to this: the root of webpack's repository + basePath: packageDir, + docKit: out, + docKitBasePath: basePath, }); const project = await app.convert(); + if (!project) throw new Error(`TypeDoc failed to convert ${packageDir}`); + await app.generateOutputs(project); + + const pages = await readJson(join(out, 'pages.json')); + await writeJson(join(out, 'site.json'), { + sidebar: buildSidebar(pages, basePath), + }); + // The plugin leaves the project's own page to the site. + await writeFile(join(out, 'index.md'), buildIndex(pages, basePath, version)); + await localizeTypeMap(out, basePath); }; const sources = await getSources(); diff --git a/scripts/markdown/api/sidebar.mjs b/scripts/markdown/api/sidebar.mjs new file mode 100644 index 00000000..d81decab --- /dev/null +++ b/scripts/markdown/api/sidebar.mjs @@ -0,0 +1,171 @@ +/** The groups of the API reference, with the page kinds they list. */ +const GROUPS = [ + { label: 'Namespaces', kinds: ['Namespace', 'Module'] }, + { label: 'Classes', kinds: ['Class'] }, + { label: 'Functions', kinds: ['Function'] }, + { label: 'Variables', kinds: ['Variable'] }, + { label: 'Interfaces', kinds: ['Interface'] }, + { label: 'Enums', kinds: ['Enum'] }, + { label: 'Type Aliases', kinds: ['TypeAlias'] }, + { label: 'Members', kinds: ['Property', 'Method', 'Accessor'] }, +]; + +/** + * The pages of each group, leaving out groups without pages. + * + * @param {Array} pages + * @param {typeof GROUPS} groups + */ +const groupPages = (pages, groups) => + groups + .map(({ label, kinds }) => ({ + label, + pages: pages.filter(page => kinds.includes(page.kind)), + })) + .filter(group => group.pages.length); + +/** + * Every page of the reference embeds the sidebar, so it only lists these + * kinds; the others are linked from the index page. + */ +const SIDEBAR_KINDS = ['Namespace', 'Module', 'Class', 'Function']; + +const NAMESPACE_KINDS = ['Namespace', 'Module']; + +const byLabel = (a, b) => a.label.localeCompare(b.label); + +/** + * The pages directly in a namespace, split into its namespaces and the rest. + * + * @param {Array} pages + * @param {string} prefix The namespace's full name and a dot, or `''` for the + * top level + */ +const childrenOf = (pages, prefix) => { + const own = pages.filter( + page => + page.name.startsWith(prefix) && + !page.name.slice(prefix.length).includes('.') + ); + + return { + namespaces: own.filter(page => NAMESPACE_KINDS.includes(page.kind)), + others: own.filter(page => !NAMESPACE_KINDS.includes(page.kind)), + }; +}; + +/** + * Sidebar links to pages, labelled by their names within the namespace. + * + * @param {Array} entries + * @param {string} prefix + */ +const links = (entries, prefix) => + entries + .map(page => ({ link: page.url, label: page.name.slice(prefix.length) })) + .sort(byLabel); + +/** + * A namespace collapsed into a sub-group: its own page, its namespaces, then + * its classes and functions. A namespace exporting none of these is a link. + * + * @param {Array} pages + * @param {import('@doc-kit/typedoc/src/types').PageEntry} namespace + * @param {string} prefix The prefix of the namespace's own name + */ +const namespaceItem = (pages, namespace, prefix) => { + const label = namespace.name.slice(prefix.length); + const items = namespaceItems(pages, `${namespace.name}.`); + + return items.length + ? { label, items: [{ link: namespace.url, label: 'Overview' }, ...items] } + : { link: namespace.url, label }; +}; + +/** + * @param {Array} pages + * @param {string} prefix + */ +const namespaceItems = (pages, prefix) => { + const { namespaces, others } = childrenOf(pages, prefix); + + return [ + ...namespaces + .map(namespace => namespaceItem(pages, namespace, prefix)) + .sort(byLabel), + ...links(others, prefix), + ]; +}; + +/** + * The sidebar of a versioned API reference, from the page list written by + * `@doc-kit/typedoc`: the overview, the namespaces (collapsed into sub-groups + * of what they export), the top-level classes and functions, then the + * configuration options page. + * + * @param {Array} pages + * @param {string} basePath The URL path the reference is served under + */ +export const buildSidebar = (pages, basePath) => { + const listed = pages.filter(page => SIDEBAR_KINDS.includes(page.kind)); + const { namespaces, others } = childrenOf(listed, ''); + + const groups = [ + { + label: 'Namespaces', + items: namespaces + .map(namespace => namespaceItem(listed, namespace, '')) + .sort(byLabel), + }, + { + label: 'Classes', + items: links( + others.filter(page => page.kind === 'Class'), + '' + ), + }, + { + label: 'Functions', + items: links( + others.filter(page => page.kind === 'Function'), + '' + ), + }, + ].filter(group => group.items.length); + + return [ + { + groupName: 'API', + items: [ + { link: basePath, label: 'Overview' }, + ...groups, + { link: `${basePath}/options`, label: 'Options' }, + ], + }, + ]; +}; + +/** + * The index page of a versioned API reference, linking to every page. + * + * @param {Array} pages + * @param {string} basePath The URL path the reference is served under + * @param {string} version The documented webpack version + */ +export const buildIndex = (pages, basePath, version) => { + const lines = [ + '# API', + '', + `The API of webpack ${version}, generated from its type declarations. ` + + 'Its configuration is documented in [Configuration Options](options.md).', + ]; + + for (const { label, pages: entries } of groupPages(pages, GROUPS)) { + lines.push('', `## ${label}`, ''); + for (const { name, url } of entries) { + lines.push(`* [\`${name}\`](${url.slice(basePath.length + 1)}.md)`); + } + } + + return `${lines.join('\n')}\n`; +}; diff --git a/scripts/markdown/api/utils.mjs b/scripts/markdown/api/utils.mjs index e854cc62..44295eee 100644 --- a/scripts/markdown/api/utils.mjs +++ b/scripts/markdown/api/utils.mjs @@ -1,5 +1,6 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; +import { load } from '@doc-kit/typedoc'; const [packageDir] = process.argv.slice(2); const cacheDir = join('.', '.cache', 'webpack'); @@ -19,24 +20,13 @@ export const getPackageFile = async (packageDir, file = 'package.json') => export const typeDocConfig = { plugin: [ - 'typedoc-plugin-markdown', + load, 'typedoc-plugin-missing-exports', - './plugins/processor/index.mjs', - './plugins/theme/index.mjs', + './plugins/webpack.mjs', + './plugins/sources.mjs', ], - theme: 'doc-kit', - router: 'doc-kit', - hideGroupHeadings: true, - hideBreadcrumbs: true, - hidePageHeader: true, readme: 'none', - disableSources: true, - propertiesFormat: 'table', - membersWithOwnFile: ['Class'], - - modulesFileName: 'index', - entryFileName: 'index', tsconfig: 'tsconfig.json', excludeExternals: true, }; diff --git a/tests/sidebar.test.mjs b/tests/sidebar.test.mjs new file mode 100644 index 00000000..d8a898bf --- /dev/null +++ b/tests/sidebar.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildIndex, buildSidebar } from '../scripts/markdown/api/sidebar.mjs'; + +const BASE = '/docs/api/v5.x'; + +const pages = [ + { + name: 'optimize.SplitChunksPlugin', + kind: 'Class', + url: `${BASE}/classes/optimize.SplitChunksPlugin`, + }, + { name: 'Compiler', kind: 'Class', url: `${BASE}/classes/Compiler` }, + { name: 'optimize', kind: 'Namespace', url: `${BASE}/modules/optimize` }, + { name: 'webpack', kind: 'Function', url: `${BASE}/functions/webpack` }, + { name: 'version', kind: 'Variable', url: `${BASE}/variables/version` }, + { + name: 'Configuration', + kind: 'Interface', + url: `${BASE}/interfaces/Configuration`, + }, + { + name: 'Configuration.output', + kind: 'Property', + url: `${BASE}/interfaces/Configuration.output`, + }, +]; + +test('lists namespaces, classes and functions between the overview and the options page', () => { + assert.deepEqual(buildSidebar(pages, BASE), [ + { + groupName: 'API', + items: [ + { link: BASE, label: 'Overview' }, + { + label: 'Namespaces', + items: [ + { + label: 'optimize', + items: [ + { link: `${BASE}/modules/optimize`, label: 'Overview' }, + { + link: `${BASE}/classes/optimize.SplitChunksPlugin`, + label: 'SplitChunksPlugin', + }, + ], + }, + ], + }, + { + label: 'Classes', + items: [{ link: `${BASE}/classes/Compiler`, label: 'Compiler' }], + }, + { + label: 'Functions', + items: [{ link: `${BASE}/functions/webpack`, label: 'webpack' }], + }, + { link: `${BASE}/options`, label: 'Options' }, + ], + }, + ]); +}); + +test('nests namespaces in namespaces, and links those exporting nothing listed', () => { + const nested = [ + { name: 'util', kind: 'Namespace', url: `${BASE}/modules/util` }, + { + name: 'util.runtime', + kind: 'Namespace', + url: `${BASE}/modules/util.runtime`, + }, + { + name: 'util.runtime.forEachRuntime', + kind: 'Function', + url: `${BASE}/functions/util.runtime.forEachRuntime`, + }, + { + name: 'RuntimeGlobals', + kind: 'Namespace', + url: `${BASE}/modules/RuntimeGlobals`, + }, + ]; + + assert.deepEqual(buildSidebar(nested, BASE)[0].items[1], { + label: 'Namespaces', + items: [ + { link: `${BASE}/modules/RuntimeGlobals`, label: 'RuntimeGlobals' }, + { + label: 'util', + items: [ + { link: `${BASE}/modules/util`, label: 'Overview' }, + { + label: 'runtime', + items: [ + { link: `${BASE}/modules/util.runtime`, label: 'Overview' }, + { + link: `${BASE}/functions/util.runtime.forEachRuntime`, + label: 'forEachRuntime', + }, + ], + }, + ], + }, + ], + }); +}); + +test('leaves out the groups of kinds without pages', () => { + assert.deepEqual(buildSidebar([], BASE), [ + { + groupName: 'API', + items: [ + { link: BASE, label: 'Overview' }, + { link: `${BASE}/options`, label: 'Options' }, + ], + }, + ]); +}); + +test('links every page from the index, by kind', () => { + const index = buildIndex(pages, BASE, '5.0.0'); + + assert.match(index, /^# API\n/); + assert.match( + index, + /## Variables\n\n\* \[`version`\]\(variables\/version\.md\)/ + ); + assert.match( + index, + /## Members\n\n\* \[`Configuration.output`\]\(interfaces\/Configuration\.output\.md\)/ + ); + assert.doesNotMatch(index, /## Enums/); +}); diff --git a/tests/theme/fixtures/input.js b/tests/theme/fixtures/input.js deleted file mode 100644 index a8b922f9..00000000 --- a/tests/theme/fixtures/input.js +++ /dev/null @@ -1,218 +0,0 @@ -/* eslint-disable */ - -/** - * Class demonstrating Webpack as a parent class. - */ -export class WebpackTest {} - -/** - * Interface demonstrating Webpack as an interface. - * @interface - */ -export class WebpackInterface {} - -/** - * Class demonstrating overload signatures. - */ -export class OverloadTest { - /** - * First overload signature. - * @overload - * @param {string} name A simple string parameter - * @param {number} count A simple number parameter - * @returns {void} - */ - /** - * Second overload signature. - * @overload - * @param {boolean} isEnabled A simple boolean parameter - * @returns {string} - */ - /** - * Third overload signature. - * @overload - * @param {number} id A simple string parameter - * @param {boolean} isEnabled A simple boolean parameter - * @returns {void} - */ - overloadMethod(id, isEnabled) {} -} - -/** - * Class demonstrating stability for deprecated, experimental, and legacy APIs. - */ -export class StabilityTest { - /** - * @deprecated Use the new API instead. - */ - deprecatedMethod() {} - - /** - * @experimental - */ - experimentalMethod() {} - - /** - * @legacy This is a legacy Webpack v4 API. - */ - legacyMethod() {} -} - -/** - * Class demonstrating the formatting of JSDoc example blocks. - */ -export class ExamplesTest { - /** - * A sample method for the ExamplesTest class. - * @param {string} name A simple string parameter - * @returns {void} - * @example - * exampleMethod('example-1'); - * - * @example - * ```ts - * exampleMethod('example-2'); - * ``` - */ - exampleMethod(name) {} -} - -/** - * Class demonstrating properties and typed lists. - */ -export class TypedListTest extends WebpackTest { - /** - * A sample property for the TypedListTest class. - * @type {string} - */ - typedListProperty; - - /** - * A sample method for the TypedListTest class. - * @param {string} name The name of the instance - * @param {number} count The count of items - * @returns {boolean} A boolean indicating success - */ - typedListMethod(name, count) {} -} - -/** - * Complex array type. - * @typedef {Array<{ name: string, age: number }>} ComplexArrayType - */ - -/** - * Simple array type. - * @typedef {Array} SimpleArrayType - */ - -/** - * Complex union type. - * @typedef {string | { advanced: boolean, timeout: number }} ComplexUnionType - */ - -/** - * Simple union type. - * @typedef {string | number} SimpleUnionType - */ - -/** - * Complex intersection type. - * @typedef {{ id: string } & { metadata: object }} ComplexIntersectionType - */ - -/** - * Simple intersection type. - * @typedef {number & string} SimpleIntersectionType - */ - -/** - * A standalone constant demonstrating declarationTitle formatting and variable rendering. - * @type {string} - */ -export const CONSTANT_AND_DECLARATION_TITLE_TEST = 'pass me'; - -/** - * Class demonstrating indexSignature rendering. - */ -export class IndexSignatureTest { - /** - * An object property to trigger indexSignature. - * @type {{ [key: string]: number }} - */ - dictionaryProperty; -} - -/** - * Class demonstrating Implements and Constructor grouping. - * @implements {WebpackInterface} - */ -export class MemberGroupsTest { - /** - * The constructor for MemberGroupsTest. - * @param {any} data - */ - constructor(data) {} - - /** - * A categorized method. - * @category Lifecycle - */ - init() {} -} - -/** - * A simple enum demonstrating enum rendering. - * @enum {number} - */ -export const EnumTest = { - /** The first option */ - ONE: 1, - /** The second option */ - TWO: 2, -}; - -/** - * A standalone function demonstrating generics outside a class. - * @template T - * @param {T} value The input value - * @returns {T} The same value - */ -export function standAloneWithGenericsTest(value) { - return value; -} - -/** - * Class demonstrating Getters and Setters (Accessors). - */ -export class AccessorTest { - /** - * Gets the current configuration name. - * @returns {string} - */ - get configName() { - return ''; - } - - /** - * Sets the configuration name. - * @param {string} val The new name - */ - set configName(val) {} -} - -/** - * A namespace demonstrating nested members. - * @namespace - */ -export const WebpackUtils = { - /** - * Merges two configurations. - * @param {object} a First config - * @param {object} b Second config - * @returns {object} Merged config - */ - merge(a, b) { - return Object.assign({}, a, b); - }, -}; diff --git a/tests/theme/fixtures/tsconfig.json b/tests/theme/fixtures/tsconfig.json deleted file mode 100644 index ff8f0ace..00000000 --- a/tests/theme/fixtures/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "include": ["**/*.js"], - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "../.temp/types", - "skipLibCheck": true - } -} diff --git a/tests/theme/theme.test.mjs b/tests/theme/theme.test.mjs deleted file mode 100644 index 93e67bef..00000000 --- a/tests/theme/theme.test.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { test } from 'node:test'; -import { Application } from 'typedoc'; -import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path/posix'; -import { execSync } from 'node:child_process'; -import { typeDocConfig } from '../../scripts/markdown/api/utils.mjs'; - -const fixturesDir = './tests/theme/fixtures'; -const outputDir = './tests/theme/.temp'; -const typesDir = './tests/theme/.temp/types'; -const tsconfig = { include: ['**/*.d.ts'] }; - -test('TypeDoc Theme - Edge Cases Fixture', async t => { - execSync(`npx tsc -p ${join(fixturesDir, 'tsconfig.json')}`, { - stdio: 'inherit', - }); - writeFileSync(join(typesDir, 'tsconfig.json'), JSON.stringify(tsconfig)); - - const app = await Application.bootstrapWithPlugins({ - ...typeDocConfig, - entryPoints: [join(typesDir, 'input.d.ts')], - out: outputDir, - publicPath: '/', - tsconfig: join(typesDir, 'tsconfig.json'), - }); - - const project = await app.convert(); - await app.generateOutputs(project); - - const mdFiles = readdirSync(outputDir, { recursive: true }).filter(f => - f.endsWith('.md') - ); - - if (mdFiles.length === 0) throw new Error('No markdown file generated'); - - mdFiles.sort(); - - const actualMarkdown = mdFiles - .map(f => readFileSync(join(outputDir, f), 'utf-8')) - .join('\n\n---\n\n'); - - rmSync(outputDir, { recursive: true, force: true }); - - t.assert.snapshot(actualMarkdown, { serializers: [val => val] }); -}); diff --git a/tests/theme/theme.test.mjs.snapshot b/tests/theme/theme.test.mjs.snapshot deleted file mode 100644 index 4280cdec..00000000 --- a/tests/theme/theme.test.mjs.snapshot +++ /dev/null @@ -1,363 +0,0 @@ -exports[`TypeDoc Theme - Edge Cases Fixture 1`] = ` -# Class: \`webpack.js.org.AccessorTest\` - -Class demonstrating Getters and Setters (Accessors). - -## Constructors - -### \`new AccessorTest()\` - -* Returns: {AccessorTest} - -## Properties - -* \`configName\` {string} Gets the current configuration name. - - ---- - -# \`webpack.js.org.EnumTest\` - -## \`ONE\` - -* Type: {number} - -*** - -## \`TWO\` - -* Type: {number} - - ---- - -# Class: \`webpack.js.org.ExamplesTest\` - -Class demonstrating the formatting of JSDoc example blocks. - -## Constructors - -### \`new ExamplesTest()\` - -* Returns: {ExamplesTest} - -## Methods - -### \`exampleMethod(name)\` - -* \`name\` {string} A simple string parameter -* Returns: {void} - -A sample method for the ExamplesTest class. -\`\`\`ts -exampleMethod('example-1'); -\`\`\` - -\`\`\`ts -exampleMethod('example-2'); -\`\`\` - - ---- - -# Class: \`webpack.js.org.IndexSignatureTest\` - -Class demonstrating indexSignature rendering. - -## Constructors - -### \`new IndexSignatureTest()\` - -* Returns: {IndexSignatureTest} - -## Properties - -* \`dictionaryProperty\` {{ [key: string]: number }} An object property to trigger indexSignature. - - ---- - -# Class: \`webpack.js.org.MemberGroupsTest\` - -Class demonstrating Implements and Constructor grouping. - -## Implements - -* {WebpackInterface} - -## Constructors - -### \`new MemberGroupsTest(data)\` - -* \`data\` {any} -* Returns: {MemberGroupsTest} - -The constructor for MemberGroupsTest. - -## Lifecycle - -### \`init()\` - -* Returns: {void} - -A categorized method. - - ---- - -# Class: \`webpack.js.org.OverloadTest\` - -Class demonstrating overload signatures. - -## Constructors - -### \`new OverloadTest()\` - -* Returns: {OverloadTest} - -## Methods - -### \`overloadMethod(name, count)\` - -* \`name\` {string} A simple string parameter -* \`count\` {number} A simple number parameter -* Returns: {void} - -First overload signature. - -### \`overloadMethod(isEnabled)\` - -* \`isEnabled\` {boolean} A simple boolean parameter -* Returns: {string} - -Second overload signature. - -### \`overloadMethod(id, isEnabled)\` - -* \`id\` {number} A simple string parameter -* \`isEnabled\` {boolean} A simple boolean parameter -* Returns: {void} - -Third overload signature. - - ---- - -# Class: \`webpack.js.org.StabilityTest\` - -Class demonstrating stability for deprecated, experimental, and legacy APIs. - -## Constructors - -### \`new StabilityTest()\` - -* Returns: {StabilityTest} - -## Methods - -### \`deprecatedMethod()\` - -> Stability: 0 - Deprecated: Use the new API instead. - -* Returns: {void} - -*** - -### \`experimentalMethod()\` - -> Stability: 1 - Experimental - -* Returns: {void} - -**\`Experimental\`** - -*** - -### \`legacyMethod()\` - -> Stability: 3 - Legacy: This is a legacy Webpack v4 API. - -* Returns: {void} - - ---- - -# Class: \`webpack.js.org.TypedListTest\` - -Class demonstrating properties and typed lists. - -* Extends: {WebpackTest} - -## Constructors - -### \`new TypedListTest()\` - -* Returns: {TypedListTest} - -## Properties - -* \`typedListProperty\` {string} A sample property for the TypedListTest class. - -## Methods - -### \`typedListMethod(name, count)\` - -* \`name\` {string} The name of the instance -* \`count\` {number} The count of items -* Returns: {boolean} A boolean indicating success - -A sample method for the TypedListTest class. - - ---- - -# Class: \`webpack.js.org.WebpackInterface\` - -**\`Interface\`** - -Interface demonstrating Webpack as an interface. - -## Constructors - -### \`new WebpackInterface()\` - -* Returns: {WebpackInterface} - - ---- - -# Class: \`webpack.js.org.WebpackTest\` - -Class demonstrating Webpack as a parent class. - -## Constructors - -### \`new WebpackTest()\` - -* Returns: {WebpackTest} - - ---- - -# \`webpack.js.org.WebpackUtils\` - -## \`merge(a, b)\` - -* \`a\` {object} First config -* \`b\` {object} Second config -* Returns: {object} Merged config - -Merges two configurations. - - ---- - -# \`webpack.js.org\` - -## Namespaces - -- [EnumTest](/EnumTest.md) -- [WebpackUtils](/WebpackUtils.md) - -## Classes - -- [AccessorTest](/AccessorTest.md) -- [ExamplesTest](/ExamplesTest.md) -- [IndexSignatureTest](/IndexSignatureTest.md) -- [MemberGroupsTest](/MemberGroupsTest.md) -- [OverloadTest](/OverloadTest.md) -- [StabilityTest](/StabilityTest.md) -- [TypedListTest](/TypedListTest.md) -- [WebpackInterface](/WebpackInterface.md) -- [WebpackTest](/WebpackTest.md) - -## \`webpack.js.org.CONSTANT_AND_DECLARATION_TITLE_TEST\` - -* Type: {string} - -A standalone constant demonstrating declarationTitle formatting and variable rendering. - -*** - -## \`webpack.js.org.standAloneWithGenericsTest(value)\` - -* \`value\` {T} The input value -* Returns: {T} The same value - -A standalone function demonstrating generics outside a class. - - ---- - -# \`webpack\` Types - -These types are not exported by webpack, but they are available to TypeScript consumers. - -## Type: \`ComplexArrayType\` - -* Type: {{ age: number; name: string }[]} - -Complex array type. - -### Type Declaration - -* \`age\` {number} -* \`name\` {string} - -*** - -## Type: \`ComplexIntersectionType\` - -* Type: {{ id: string } & { metadata: object }} - -Complex intersection type. - -### Type Declaration - -* \`id\` {string} - -### Type Declaration - -* \`metadata\` {object} - -*** - -## Type: \`ComplexUnionType\` - -* Type: {string | { advanced: boolean; timeout: number }} - -Complex union type. - -*** - -## Type: \`EnumTest\` - -* Type: {number} - -A simple enum demonstrating enum rendering. - -*** - -## Type: \`SimpleArrayType\` - -* Type: {string[]} - -Simple array type. - -*** - -## Type: \`SimpleIntersectionType\` - -* Type: {number & string} - -Simple intersection type. - -*** - -## Type: \`SimpleUnionType\` - -* Type: {string | number} - -Simple union type. - -`;