import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, extname, join, relative, resolve } from 'node:path'; import ts from 'typescript'; import { Application, BaseRouter, DefaultTheme, JSX, PageEvent, PageKind, ReflectionKind, RendererEvent, } from 'typedoc'; import { readWebsiteLocales, toPosixPath, workspaceRoot } from './package-utils.mjs'; const root = workspaceRoot; const rendererDeclaration = resolve(root, 'packages/renderer/dist/index.d.ts'); const tempOutputDir = resolve(root, 'node_modules/.cache/aholo-api-docs'); const contentRoot = resolve(root, 'website/.generated/api'); const locales = readWebsiteLocales(); const apiKindByReflectionKind = new Map([ [ReflectionKind.Class, 'class'], [ReflectionKind.Enum, 'enumeration'], [ReflectionKind.Function, 'function'], [ReflectionKind.Interface, 'interface'], [ReflectionKind.TypeAlias, 'type'], [ReflectionKind.Variable, 'variable'], ]); const folderByKind = { class: 'classes', enumeration: 'enumerations', function: 'functions', interface: 'interfaces', type: 'type-aliases', variable: 'variables', }; const kindLabels = { class: 'Class', enumeration: 'Enumeration', function: 'Function', interface: 'Interface', type: 'Type Alias', variable: 'Variable', }; const namespaceOrder = ['core', 'events', 'animation', 'splat-loader', 'draco-loader', 'gltf-loader', 'splat-utils']; const materialAliasDocs = new Map([ ['MeshBasicMaterial', { base: 'BaseMeshBasicMaterial', parameters: 'MeshBasicMaterialParameters' }], ['MeshPhongMaterial', { base: 'BaseMeshPhongMaterial', parameters: 'MeshPhongMaterialParameters' }], ['SpriteMaterial', { base: 'BaseSpriteMaterial', parameters: 'SpriteMaterialParameters' }], ]); await rm(tempOutputDir, { recursive: true, force: true }); await mkdir(tempOutputDir, { recursive: true }); await mkdir(contentRoot, { recursive: true }); const apiNamespaces = await createApiNamespacesFromRendererDeclaration(); const namespaceBySlug = new Map(apiNamespaces.map(namespace => [namespace.slug, namespace])); const categoryLabels = Object.fromEntries( apiNamespaces.map(({ category, categoryLabel }) => [category, categoryLabel]), ); const renderedPages = new Map(); const typedocPageOrder = new Map(); class AholoApiRouter extends BaseRouter { getPageKind(target) { if (target?.kindOf?.(ReflectionKind.SomeModule)) { return PageKind.Reflection; } return getApiKind(target) ? PageKind.Reflection : undefined; } getIdealBaseName(reflection) { const apiKind = getApiKind(reflection); const namespace = getReflectionNamespace(reflection); if (!apiKind || !namespace) { return toKebabCase(reflection.name); } const folder = isMaterialAliasDocReflection(reflection) ? folderByKind.variable : folderByKind[apiKind]; return [namespace.slug, folder, toKebabCase(reflection.name)].join('/'); } } class AholoApiFragmentTheme extends DefaultTheme { render(page) { if (!page.isReflectionEvent()) { return ''; } const template = { [PageKind.Reflection]: this.reflectionTemplate, [PageKind.Document]: this.documentTemplate, [PageKind.Index]: this.indexTemplate, [PageKind.Hierarchy]: this.hierarchyTemplate, }[page.pageKind]; return template ? JSX.renderElement(template(page)) : ''; } } const app = await Application.bootstrapWithPlugins({ entryPoints: [toPosixPath(rendererDeclaration)], out: toPosixPath(tempOutputDir), readme: 'none', router: 'aholo-api', theme: 'aholo-api-fragment', disableSources: true, excludeInternal: true, excludePrivate: true, excludeProtected: true, skipErrorChecking: true, sort: ['source-order'], validation: false, hideGenerator: true, includeHierarchySummary: false, }); app.renderer.defineRouter('aholo-api', AholoApiRouter); app.renderer.defineTheme('aholo-api-fragment', AholoApiFragmentTheme); app.renderer.on(RendererEvent.BEGIN, event => { event.pages.forEach((page, index) => { typedocPageOrder.set(toPosixPath(page.url), index + 1); }); }); app.renderer.on(PageEvent.END, event => { const sourcePath = toPosixPath(relative(tempOutputDir, event.filename)); const metadata = createMetadata(event, sourcePath); if (!metadata) { return; } renderedPages.set(sourcePath, metadata); }); const project = await app.convert(); if (!project) { throw new Error('TypeDoc failed to convert the renderer entry.'); } await app.generateOutputs(project); const entries = [...renderedPages.values()].sort(compareEntries).map((entry, index) => ({ ...entry, order: index + 1, })); const targetPaths = new Map(entries.map(entry => [entry.sourcePath, `${entry.slug}.html`])); const expectedOutputPaths = new Set(); await writeApiManifest(entries); for (const entry of entries) { for (const locale of locales) { const outputPath = resolve(contentRoot, locale, `${entry.slug}.html`); const content = `${rewriteHtmlLinks(entry.html, entry.sourcePath, targetPaths, locale).trim()}\n`; expectedOutputPaths.add(resolve(outputPath)); await writeFileIfChanged(outputPath, content); } } await removeStaleGeneratedFiles(expectedOutputPaths); await pruneEmptyDirectories(contentRoot); await rm(tempOutputDir, { recursive: true, force: true }); console.log(`[api-docs] Generated ${entries.length} API HTML pages for ${locales.join(', ')}.`); async function createApiNamespacesFromRendererDeclaration() { let declarationSource; try { declarationSource = await readFile(rendererDeclaration, 'utf8'); } catch (error) { if (error.code === 'ENOENT') { throw new Error( `Renderer declaration not found at ${toPosixPath(relative(root, rendererDeclaration))}. Run pnpm build:renderer first.`, ); } throw error; } const sourceFile = ts.createSourceFile(rendererDeclaration, declarationSource, ts.ScriptTarget.Latest, true); const declaredNamespaces = collectDeclaredNamespaces(sourceFile); const namespaces = [ { slug: 'core', name: 'Core', category: 'core', categoryLabel: 'Core', namespaceLabel: 'Core', }, ]; const knownSlugs = new Set(namespaces.map(namespace => namespace.slug)); for (const namespaceLabel of getExportedNamespaceLabels(sourceFile, declaredNamespaces)) { const namespace = createNamespaceEntry(namespaceLabel); if (knownSlugs.has(namespace.slug)) { continue; } namespaces.push(namespace); knownSlugs.add(namespace.slug); } return orderApiNamespaces(namespaces); } function collectDeclaredNamespaces(sourceFile) { const namespaces = new Set(); for (const statement of sourceFile.statements) { if (ts.isModuleDeclaration(statement) && ts.isIdentifier(statement.name)) { namespaces.add(statement.name.text); } } return namespaces; } function getExportedNamespaceLabels(sourceFile, declaredNamespaces) { const labels = []; for (const statement of sourceFile.statements) { if (ts.isModuleDeclaration(statement) && hasExportModifier(statement) && ts.isIdentifier(statement.name)) { labels.push(statement.name.text); continue; } if ( !ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause) ) { continue; } for (const element of statement.exportClause.elements) { const localName = element.propertyName?.text ?? element.name.text; if (declaredNamespaces.has(localName)) { labels.push(element.name.text); } } } return labels; } function orderApiNamespaces(namespaces) { const orderBySlug = new Map(namespaceOrder.map((slug, index) => [slug, index])); return namespaces .map((namespace, sourceIndex) => ({ namespace, sourceIndex })) .sort((left, right) => { const leftOrder = orderBySlug.get(left.namespace.slug) ?? Number.POSITIVE_INFINITY; const rightOrder = orderBySlug.get(right.namespace.slug) ?? Number.POSITIVE_INFINITY; return leftOrder - rightOrder || left.sourceIndex - right.sourceIndex; }) .map(({ namespace }) => namespace); } function createNamespaceEntry(namespaceLabel) { return { slug: toKebabCase(namespaceLabel), name: namespaceLabel, category: toKebabCase(namespaceLabel), categoryLabel: namespaceLabel, namespaceLabel, }; } function hasExportModifier(node) { return Boolean(node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)); } async function writeFileIfChanged(filePath, content) { try { if ((await readFile(filePath, 'utf8')) === content) { return; } } catch (error) { if (error.code !== 'ENOENT') { throw error; } } await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, content); } async function listGeneratedContentFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }); const files = []; for (const entry of entries) { const filePath = join(directory, entry.name); if (entry.isDirectory()) { files.push(...(await listGeneratedContentFiles(filePath))); continue; } if (entry.isFile() && ['.html', '.md'].includes(extname(entry.name))) { files.push(filePath); } } return files; } async function removeStaleGeneratedFiles(expectedOutputPaths) { for (const filePath of await listGeneratedContentFiles(contentRoot)) { if (!expectedOutputPaths.has(resolve(filePath))) { await rm(filePath, { force: true }); } } } async function pruneEmptyDirectories(directory) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { await pruneEmptyDirectories(join(directory, entry.name)); } } if (resolve(directory) === resolve(contentRoot)) { return; } if ((await readdir(directory)).length === 0) { await rm(directory, { recursive: true, force: true }); } } async function writeApiManifest(entries) { const categories = []; const categoriesBySlug = new Map(); const namespaces = []; const namespaceKeys = new Set(); for (const namespace of apiNamespaces) { if (!categoriesBySlug.has(namespace.category)) { const category = { category: namespace.category, label: namespace.categoryLabel, namespaces: [], }; categoriesBySlug.set(namespace.category, category); categories.push(category); } const namespaceKey = `${namespace.category}:${namespace.name}`; if (namespaceKeys.has(namespaceKey)) { continue; } namespaceKeys.add(namespaceKey); const namespaceEntry = { namespace: namespace.name, label: namespace.namespaceLabel, category: namespace.category, slug: namespace.slug, }; namespaces.push(namespaceEntry); categoriesBySlug.get(namespace.category)?.namespaces.push(namespaceEntry); } const kinds = [...new Set(entries.map(entry => entry.kind))].sort(); const manifest = { categories, namespaces, kinds, entries: entries.map(({ html, sourcePath, sourceOrder, ...entry }) => entry), }; const content = [ 'export const apiManifest = ', JSON.stringify(manifest, null, 2), ' as const;\n\n', 'export type ApiCategory = (typeof apiManifest.categories)[number]["category"];\n', 'export type ApiNamespace = (typeof apiManifest.namespaces)[number]["namespace"];\n', 'export type ApiKind = (typeof apiManifest.kinds)[number];\n', 'export type ApiManifestEntry = (typeof apiManifest.entries)[number];\n', 'export type ApiHeading = ApiManifestEntry["headings"][number];\n', ].join(''); await writeFileIfChanged(resolve(contentRoot, 'manifest.ts'), content); } function createMetadata(event, sourcePath) { const reflection = event.model; const kind = getApiKind(reflection); const namespace = getReflectionNamespace(reflection); const html = cleanTypeDocHtml(event.contents ?? ''); if (!kind || !namespace || !sourcePath.endsWith('.html')) { return undefined; } const category = namespace.category; const title = reflection.name; const slug = sourcePath.replace(/\.html$/, ''); return { sourcePath, slug, title, description: getReflectionDescription(reflection) || `${namespace.name}.${title} exported from @manycore/aholo-viewer.`, sourceOrder: typedocPageOrder.get(sourcePath) ?? Number.POSITIVE_INFINITY, kind, kindLabel: kindLabels[kind], namespace: namespace.name, namespaceLabel: namespace.namespaceLabel, category, categoryLabel: categoryLabels[category], signature: getReflectionSignature(reflection, kind), headings: getApiHtmlHeadings(html, event), html, }; } function getApiKind(reflection) { for (const [reflectionKind, apiKind] of apiKindByReflectionKind) { if (reflection?.kindOf?.(reflectionKind)) { return apiKind; } } return undefined; } function isMaterialAliasDocReflection(reflection) { return reflection?.kindOf?.(ReflectionKind.Class) && materialAliasDocs.has(reflection.name); } function getReflectionNamespace(reflection) { let topLevel = reflection; while (topLevel?.parent && !topLevel.parent.isProject?.()) { topLevel = topLevel.parent; } const slug = toKebabCase(topLevel?.name ?? ''); return namespaceBySlug.get(slug) ?? namespaceBySlug.get('core'); } function getReflectionDescription(reflection) { return ( displayPartsToText(reflection.comment?.summary) || displayPartsToText(getPrimarySignature(reflection)?.comment?.summary) || undefined ); } function getReflectionSignature(reflection, kind) { const signature = kind === 'class' ? getConstructorSignature(reflection) : getPrimarySignature(reflection); if (signature) { const params = (signature.parameters ?? []) .map( parameter => `${parameter.name}${parameter.flags?.isOptional ? '?' : ''}${ parameter.type ? `: ${parameter.type.toString()}` : '' }`, ) .join(', '); const returnType = signature.type?.toString(); const name = kind === 'class' ? `new ${reflection.name}` : reflection.name; return `${name}(${params})${returnType ? `: ${returnType}` : ''}`; } if (kind === 'class') { return `class ${reflection.name}`; } if (kind === 'enumeration') { return `enum ${reflection.name}`; } if (kind === 'interface') { return `interface ${reflection.name}`; } if (kind === 'type') { return `type ${reflection.name}${reflection.type ? ` = ${reflection.type.toString()}` : ''}`; } if (kind === 'variable') { return `const ${reflection.name}${reflection.type ? `: ${reflection.type.toString()}` : ''}`; } return `${kind} ${reflection.name}`; } function getConstructorSignature(reflection) { const constructor = reflection.children?.find(child => child.kindOf(ReflectionKind.Constructor)); return constructor?.signatures?.[0]; } function getPrimarySignature(reflection) { return reflection.signatures?.[0] ?? reflection.getSignature ?? reflection.setSignature; } function displayPartsToText(parts) { const value = parts ?.map(part => part.text) .join('') .replace(/\s+/g, ' ') .trim(); return value || undefined; } function toApiHeading(heading) { const slug = heading.link?.replace(/^#/, ''); if (!slug) { return undefined; } return { depth: heading.level ?? 2, slug, text: heading.text, }; } function getApiHtmlHeadings(html, event) { const htmlHeadings = extractTypeDocHtmlHeadings(html); if (htmlHeadings.length > 0) { return htmlHeadings; } return event.pageHeadings.map(toApiHeading).filter(Boolean); } function compareEntries(left, right) { if (left.sourceOrder !== right.sourceOrder) { return left.sourceOrder - right.sourceOrder; } return left.title.localeCompare(right.title); } function rewriteHtmlLinks(content, sourcePath, targetPaths, locale) { const sourceDir = sourcePath.split('/').slice(0, -1).join('/'); return content.replace( /\bhref=(["'])(?![a-z][a-z0-9+.-]*:|#|\/)([^"']+?\.html)(#[^"']*)?\1/gi, (match, quote, href, hash = '') => { const targetSourcePath = normalizeRelativePath(sourceDir, href); const targetPath = targetPaths.get(targetSourcePath); if (!targetPath) { return match; } const routePath = targetPath.replace(/\.html$/, ''); return `href=${quote}/${locale}/api/${routePath}/${hash}${quote}`; }, ); } function cleanTypeDocHtml(content) { const normalized = stripTypeDocOptionalTags(stripTypeDocAssetIcons(content).replace(/\u00a0/g, ' ')); return addTypeDocGroupHeadingIds( removeTypeDocTypeDeclarationSignatures(inlineTypeDocDefaultValues(removeTypeDocIndexGroup(normalized))), ); } function stripTypeDocAssetIcons(content) { return content.replace(/]*>\s*<\/use>\s*<\/svg>/g, ''); } function stripTypeDocOptionalTags(content) { return content.replace(/Optional<\/code>/g, ''); } function inlineTypeDocDefaultValues(content) { return inlineTypeDocMemberDefaultValues(inlineTypeDocParameterDefaultValues(content)); } function inlineTypeDocMemberDefaultValues(content) { return replaceTypeDocBlocks( content, /]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g, 'section', inlineTypeDocDefaultValueInBlock, ); } function inlineTypeDocParameterDefaultValues(content) { return replaceTypeDocBlocks( content, /]*class=(["'])[^"']*\btsd-parameter\b[^"']*\1[^>]*>/g, 'li', inlineTypeDocDefaultValueInBlock, ); } function replaceTypeDocBlocks(content, pattern, tagName, replacer) { let cleaned = ''; let cursor = 0; let match; while ((match = pattern.exec(content))) { if (match.index < cursor) { continue; } const end = findMatchingElementEnd(content, match.index, tagName); if (end === -1) { continue; } cleaned += content.slice(cursor, match.index); cleaned += replacer(content.slice(match.index, end)); cursor = end; pattern.lastIndex = end; } return cleaned + content.slice(cursor); } function removeTypeDocTypeDeclarationSignatures(content) { return replaceTypeDocBlocks( content, /]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g, 'section', block => block.includes('
') ? block .replace(/
)\s*

Type Declaration<\/h4>/, '$1') : block, ); } function inlineTypeDocDefaultValueInBlock(block) { const defaultBlock = block.match(/
[\s\S]*?<\/div>/)?.[0]; if (!defaultBlock) { return block; } const defaultValue = extractTypeDocDefaultValue(defaultBlock); if (!defaultValue) { return block; } const blockWithoutDefault = block .replace(defaultBlock, '') .replace(/
\s*<\/div>/g, ''); return appendTypeDocDefaultValueToTitle(blockWithoutDefault, defaultValue); } function extractTypeDocDefaultValue(defaultBlock) { const content = defaultBlock .replace(/^
/, '') .replace(/<\/div>$/, '') .replace(//, '') .trim(); const paragraphs = [...content.matchAll(/

([\s\S]*?)<\/p>/g)].map(match => match[1].trim()).filter(Boolean); const inlineCodeMatch = (paragraphs[0] ?? content).match(/]*)?>([\s\S]*?)<\/code>/); if (inlineCodeMatch) { return inlineCodeMatch[1].trim(); } const value = (paragraphs.length > 0 ? paragraphs.join(' ') : content).trim(); const codeMatch = value.match(/^]*)?>([\s\S]*?)<\/code>$/); return (codeMatch?.[1] ?? value).trim(); } function appendTypeDocDefaultValueToTitle(block, defaultValue) { const defaultHtml = `${defaultValue}`; const titledBlock = block.replace(/<(h3|h5)\b([^>]*)>([\s\S]*?)<\/\1>/, (match, tagName, attributes, inner) => { const title = inner.replace( /(\s*]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\2[^>]*><\/a>\s*)$/, `${defaultHtml}$1`, ); return title === inner ? `<${tagName}${attributes}>${inner}${defaultHtml}` : `<${tagName}${attributes}>${title}`; }); if (titledBlock !== block) { return titledBlock; } return block.replace(/(]*>[\s\S]*?)(<\/span>)/, `$1${defaultHtml}$2`); } function removeTypeDocIndexGroup(content) { const marker = '

'; let cleaned = ''; let cursor = 0; while (cursor < content.length) { const start = content.indexOf(marker, cursor); if (start === -1) { cleaned += content.slice(cursor); break; } const end = findMatchingSectionEnd(content, start); cleaned += content.slice(cursor, start); cursor = end === -1 ? start + marker.length : end; } return cleaned; } function findMatchingSectionEnd(content, start) { return findMatchingElementEnd(content, start, 'section'); } function findMatchingElementEnd(content, start, tagName) { const tagPattern = new RegExp(`]*>`, 'g'); tagPattern.lastIndex = start; let depth = 0; let match; while ((match = tagPattern.exec(content))) { if (match[0].startsWith(']*\bdata-key=(["'])section-[^"']+\2[^>]*>\s*)

(.*?)<\/h2>/g, (match, summaryOpen, quote, headingHtml) => { const baseSlug = toKebabCase(stripHtml(headingHtml)) || 'section'; const count = usedSlugs.get(baseSlug) ?? 0; const slug = count === 0 ? baseSlug : `${baseSlug}-${count + 1}`; usedSlugs.set(baseSlug, count + 1); return `${summaryOpen}

${headingHtml}

`; }, ); } function extractTypeDocHtmlHeadings(content) { return [ ...content.matchAll( /]*\bdata-key=(["'])section-[^"']+\1[^>]*>\s*

(.*?)<\/h2>/g, ), ] .map(match => ({ depth: 2, index: match.index ?? 0, slug: match[3], text: typeDocHeadingText(match[4]), })) .concat( [...content.matchAll(/]*\bid=(["'])([^"']+)\1[^>]*>([\s\S]*?)<\/h3>/g)].map(match => ({ depth: 3, index: match.index ?? 0, slug: match[2], text: typeDocHeadingText(match[3]), })), ) .filter(heading => heading.text) .sort((left, right) => left.index - right.index) .map(({ index, ...heading }) => heading); } function typeDocHeadingText(content) { return decodeBasicHtml( stripHtml( content .replace(/]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*><\/a>/g, '') .replace(/[\s\S]*?<\/code>/g, '') .replace(/[\s\S]*?<\/code>/g, '') .replace(/[\s\S]*?<\/span>/g, '') .replace(//g, ''), ), ); } function stripHtml(content) { return content.replace(/<[^>]*>/g, '').trim(); } function decodeBasicHtml(content) { return content .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); } function normalizeRelativePath(sourceDir, href) { const stack = sourceDir ? sourceDir.split('/') : []; for (const part of href.split('/')) { if (part === '.' || part === '') { continue; } if (part === '..') { stack.pop(); continue; } stack.push(part); } return stack.join('/'); } function toKebabCase(value) { return value .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/([A-Za-z])(\d+)/g, '$1-$2') .replace(/[\s_]+/g, '-') .toLowerCase(); }