chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { rollup } from '@internal/utils/dts-rollup.js';
|
||||
import {
|
||||
readJsonFile,
|
||||
readPackageJson,
|
||||
workspaceRoot,
|
||||
listFiles,
|
||||
resolvePackagePath,
|
||||
resolvePackageRoot,
|
||||
resolveWorkspacePath,
|
||||
runCommandOrExit,
|
||||
toPosixPath,
|
||||
} from './package-utils.mjs';
|
||||
|
||||
const packageRoot = resolvePackageRoot();
|
||||
const packageJson = readPackageJson(packageRoot);
|
||||
const packageName = packageJson.name ?? packageRoot;
|
||||
const packageRequire = createRequire(resolve(packageRoot, 'package.json'));
|
||||
const { build } = await importPackageDependency('esbuild');
|
||||
|
||||
const srcEntries = [resolvePackagePath(packageRoot, 'src/index.ts', 'source entry')];
|
||||
const runtimeDir = resolvePackagePath(packageRoot, 'dist', 'runtime output');
|
||||
const declarationOutput = resolvePackagePath(packageRoot, 'dist/index.d.ts', 'declaration output');
|
||||
const declarationDir = dirname(declarationOutput);
|
||||
const tempTypesDir = resolve(packageRoot, 'build');
|
||||
const egsPackagesRoot = resolveWorkspacePath('external/egs-core/packages', 'EGS packages root');
|
||||
const workerBundles = [
|
||||
createWorkerBundle({
|
||||
entry: 'external/egs-core/packages/loaders/splat-loader/worker.ts',
|
||||
entryLabel: 'splat worker entry',
|
||||
fileName: 'splat-worker.js',
|
||||
factorySourceFilter: /loaders[\\/]splat-loader[\\/]index\.ts$/,
|
||||
factoryName: 'WorkerFactor',
|
||||
blobUrlName: 'SplatWorkerBlobUrl',
|
||||
nextStatement: 'const poll =',
|
||||
}),
|
||||
createWorkerBundle({
|
||||
entry: 'external/egs-core/packages/loaders/texture-loader/ktx2/basis/worker/transcoder.worker.ts',
|
||||
entryLabel: 'texture transcoder worker entry',
|
||||
fileName: 'transcoder-worker.js',
|
||||
factorySourceFilter: /loaders[\\/]texture-loader[\\/]ktx2[\\/]basis[\\/]worker[\\/]pool\.ts$/,
|
||||
factoryName: 'WorkerFactor',
|
||||
blobUrlName: 'TranscoderWorkerBlobUrl',
|
||||
nextStatement: 'const pool =',
|
||||
}),
|
||||
createWorkerBundle({
|
||||
entry: 'external/egs-core/packages/utils/splat-utils/worker.ts',
|
||||
entryLabel: 'splat sort worker entry',
|
||||
fileName: 'splat-sort-worker.js',
|
||||
factorySourceFilter: /utils[\\/]splat-utils[\\/]sort\.ts$/,
|
||||
factoryName: 'WorkerFactor',
|
||||
blobUrlName: 'SplatSortWorkerBlobUrl',
|
||||
nextStatement: 'const poll =',
|
||||
}),
|
||||
];
|
||||
|
||||
const declareOnlyClasses = [
|
||||
{ name: 'Viewer', exported: 'export declare class Viewer', unexported: 'declare class Viewer' },
|
||||
{ name: 'Viewport', exported: 'export declare class Viewport', unexported: 'declare class Viewport' },
|
||||
{
|
||||
name: 'Material',
|
||||
exported: 'export declare abstract class Material',
|
||||
unexported: 'declare abstract class Material',
|
||||
},
|
||||
{ name: 'Light', exported: 'export declare abstract class Light', unexported: 'declare abstract class Light' },
|
||||
];
|
||||
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await mkdir(declarationDir, { recursive: true });
|
||||
await rm(tempTypesDir, { recursive: true, force: true });
|
||||
await mkdir(tempTypesDir, { recursive: true });
|
||||
|
||||
await bundleRuntime();
|
||||
emitPackageDeclarations();
|
||||
await bundleDeclarations();
|
||||
await stripPrivateTypingsImports();
|
||||
await rm(tempTypesDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`[package-build] Built ${packageName}.`);
|
||||
|
||||
async function importPackageDependency(name) {
|
||||
try {
|
||||
const resolvedPath = packageRequire.resolve(name);
|
||||
|
||||
return await import(pathToFileURL(resolvedPath).href);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to load ${name} from ${packageName}. Is it listed in that package's dependencies?`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleRuntime() {
|
||||
const shared = {
|
||||
absWorkingDir: packageRoot,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
logLevel: 'info',
|
||||
minify: false,
|
||||
platform: 'browser',
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
treeShaking: true,
|
||||
loader: {
|
||||
'.jpg': 'dataurl',
|
||||
'.png': 'dataurl',
|
||||
},
|
||||
plugins: [workerBundlePatchPlugin(workerBundles), textureLoaderPatchPlugin(), dracoLoaderPatchPlugin()],
|
||||
};
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: srcEntries,
|
||||
outdir: runtimeDir,
|
||||
});
|
||||
|
||||
for (const workerBundle of workerBundles) {
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [workerBundle.entry],
|
||||
outfile: resolve(runtimeDir, workerBundle.fileName),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function emitPackageDeclarations() {
|
||||
runCommandOrExit(
|
||||
process.execPath,
|
||||
[
|
||||
packageRequire.resolve('typescript/bin/tsc'),
|
||||
'--emitDeclarationOnly',
|
||||
'--declarationMap',
|
||||
'false',
|
||||
'--outDir',
|
||||
toPosixPath(tempTypesDir),
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
label: 'tsc --emitDeclarationOnly',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function bundleDeclarations() {
|
||||
rollup(packageRoot, {
|
||||
typeOnlyExports: [
|
||||
{ name: 'Viewer', type: 'class', abstract: false },
|
||||
{ name: 'Viewport', type: 'class', abstract: false },
|
||||
{ name: 'Material', type: 'class', abstract: true },
|
||||
{ name: 'Light', type: 'class', abstract: true },
|
||||
],
|
||||
extractorConfig: {
|
||||
bundledPackages: [
|
||||
'@qunhe/egs',
|
||||
'@qunhe/egs-animation',
|
||||
'@qunhe/egs-texture-loader',
|
||||
'@qunhe/egs-gltf-loader',
|
||||
'@qunhe/egs-draco-loader',
|
||||
'@qunhe/egs-splat-loader',
|
||||
'@qunhe/egs-splat-utils',
|
||||
'@qunhe/egs-lib',
|
||||
],
|
||||
},
|
||||
});
|
||||
await cp(resolve(packageRoot, 'build/index.d.ts'), declarationOutput, { force: true });
|
||||
}
|
||||
|
||||
async function stripPrivateTypingsImports() {
|
||||
const source = await readFile(declarationOutput, 'utf8');
|
||||
const rewritten = source.replace(/^\s*import\s+type\s+\{\s*\}\s+from\s+["']@qunhe\/egs-typings["'];?\r?\n?/gm, '');
|
||||
|
||||
if (rewritten !== source) {
|
||||
await writeFile(declarationOutput, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
function getPackageJsonPaths(dir) {
|
||||
return listFiles(dir, { skipDirectories: ['build', 'dist', 'node_modules'] }).filter(
|
||||
filePath => basename(filePath) === 'package.json',
|
||||
);
|
||||
}
|
||||
|
||||
function isInsidePackage(filePath) {
|
||||
const relativePath = relative(packageRoot, filePath);
|
||||
|
||||
return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function getInheritedConfigPaths(configPaths, fallbackPaths) {
|
||||
const paths = Array.isArray(configPaths) && configPaths.length > 0 ? configPaths : fallbackPaths;
|
||||
|
||||
return paths.map(configPath => toRelativeConfigPath(relative(tempTypesDir, resolve(packageRoot, configPath))));
|
||||
}
|
||||
|
||||
function toRelativeConfigPath(filePath) {
|
||||
const posixPath = toPosixPath(filePath) || '.';
|
||||
|
||||
return posixPath.startsWith('.') ? posixPath : `./${posixPath}`;
|
||||
}
|
||||
|
||||
function replaceExtension(filePath, replacementExtension) {
|
||||
const currentExtension = extname(filePath);
|
||||
|
||||
return currentExtension
|
||||
? `${filePath.slice(0, -currentExtension.length)}${replacementExtension}`
|
||||
: `${filePath}${replacementExtension}`;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value) {
|
||||
return value
|
||||
.replace(/^@/, '')
|
||||
.replace(/[\\/]/g, '-')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
}
|
||||
|
||||
function splitNamedSpecifiers(specifierText) {
|
||||
return specifierText
|
||||
.split(',')
|
||||
.map(specifier => specifier.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getNamedSpecifierName(specifier) {
|
||||
return removeTypeModifier(specifier)
|
||||
.split(/\s+as\s+/u)[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
function removeTypeModifier(specifier) {
|
||||
return specifier.replace(/^type\s+/u, '').trim();
|
||||
}
|
||||
|
||||
function createWorkerBundle(config) {
|
||||
return {
|
||||
...config,
|
||||
entry: resolveWorkspacePath(config.entry, config.entryLabel),
|
||||
};
|
||||
}
|
||||
|
||||
function workerBundlePatchPlugin(workerBundles) {
|
||||
return {
|
||||
name: 'aholo-worker-bundle-patch',
|
||||
setup(buildContext) {
|
||||
for (const workerBundle of workerBundles) {
|
||||
buildContext.onLoad({ filter: workerBundle.factorySourceFilter }, async args => {
|
||||
const source = await readFile(args.path, 'utf8');
|
||||
const contents = replaceWorkerFactory(source, workerBundle);
|
||||
|
||||
if (contents === source) {
|
||||
throw new Error(`Unable to patch ${workerBundle.entryLabel}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents,
|
||||
loader: 'ts',
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replaceWorkerFactory(source, workerBundle) {
|
||||
const factoryPattern = createWorkerFactoryPattern(workerBundle.factoryName, workerBundle.nextStatement);
|
||||
|
||||
return source.replace(factoryPattern, createWorkerFactoryReplacement(workerBundle));
|
||||
}
|
||||
|
||||
function createWorkerFactoryPattern(factoryName, nextStatement) {
|
||||
return new RegExp(
|
||||
`let ${escapeRegExp(factoryName)}: \\(\\) => Worker;\\s*try \\{[\\s\\S]*?\\};?\\s*${escapeRegExp(nextStatement)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkerFactoryReplacement({ factoryName, blobUrlName, fileName, nextStatement }) {
|
||||
return `let ${factoryName}: () => Worker;
|
||||
let ${blobUrlName}: string | undefined;
|
||||
${factoryName} = () => {
|
||||
const workerUrl = new URL("./${fileName}", import.meta.url).href;
|
||||
|
||||
if (!${blobUrlName}) {
|
||||
const source = \`import \${JSON.stringify(workerUrl)};\`;
|
||||
${blobUrlName} = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
||||
}
|
||||
|
||||
return new Worker(${blobUrlName}, { type: "module" });
|
||||
};
|
||||
${nextStatement}`;
|
||||
}
|
||||
|
||||
function textureLoaderPatchPlugin() {
|
||||
return {
|
||||
name: 'aholo-texture-loader-patch',
|
||||
setup(buildContext) {
|
||||
buildContext.onLoad(
|
||||
{ filter: /loaders[\\/]texture-loader[\\/]ktx2[\\/]basis[\\/]wasm[\\/]basis_transcoder\.js$/ },
|
||||
async args => {
|
||||
const source = await readFile(args.path, 'utf8');
|
||||
const nodeBranchShim =
|
||||
'var fs={readFileSync:function(){throw new Error("Node file loading is not available in the browser build.");}};';
|
||||
const contents = source.replace(/var fs=require\("fs"\);/, nodeBranchShim);
|
||||
|
||||
if (contents === source) {
|
||||
throw new Error('Unable to patch basis_transcoder.js node file-system branch.');
|
||||
}
|
||||
|
||||
return {
|
||||
contents,
|
||||
loader: 'js',
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dracoLoaderPatchPlugin() {
|
||||
return {
|
||||
name: 'aholo-draco-loader-patch',
|
||||
setup(buildContext) {
|
||||
buildContext.onResolve({ filter: /draco_decoder\.wasm$/ }, args => ({
|
||||
path: resolve(dirname(args.importer), 'draco_decoder.wasm.js'),
|
||||
}));
|
||||
|
||||
buildContext.onLoad({ filter: /draco_decoder_wrapper\.js$/ }, async args => {
|
||||
const source = await readFile(args.path, 'utf8');
|
||||
const nodeBranchShim = `var Za={readFileSync:function(){throw new Error("Node file loading is not available in the browser build.");},readFile:function(e,b,c){c(new Error("Node file loading is not available in the browser build."));}},qa={dirname:function(){return""},normalize:function(e){return e}};`;
|
||||
const contents = source.replace(/var Za=require\("fs"\),qa=require\("path"\);/, nodeBranchShim);
|
||||
|
||||
if (contents === source) {
|
||||
throw new Error('Unable to patch draco_decoder_wrapper.js node file-system branch.');
|
||||
}
|
||||
|
||||
return {
|
||||
contents,
|
||||
loader: 'js',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, extname, join, resolve } from 'node:path';
|
||||
import {
|
||||
formatWorkspacePath as formatPath,
|
||||
isInsideDir,
|
||||
listFiles,
|
||||
readJsonFile,
|
||||
readWebsiteLocales,
|
||||
statSafe,
|
||||
workspaceRoot,
|
||||
} from './package-utils.mjs';
|
||||
|
||||
const root = workspaceRoot;
|
||||
const websiteSource = resolve(root, 'website/src');
|
||||
const manualRoot = join(websiteSource, 'content/manual');
|
||||
const manualAssetRoot = join(manualRoot, 'assets');
|
||||
const examplesRoot = join(websiteSource, 'content/examples');
|
||||
const apiManifestPath = resolve(root, 'website/.generated/api/manifest.ts');
|
||||
const bannedLinkPattern = /(?:https?:\/\/go\.|go\.\/|cf\.qunhequnhe|pages\/viewpage|display\/EGS)/i;
|
||||
const imagePattern = /!\[[^\]]*]\(([^)]+)\)/g;
|
||||
const markdownLinkPattern = /(^|[^!])\[[^\]]*]\(([^)]+)\)/g;
|
||||
const htmlHrefPattern = /\bhref=(["'])([^"']+)\1/g;
|
||||
const apiReferencePrefix = 'api:';
|
||||
|
||||
const errors = [];
|
||||
const locales = readWebsiteLocales();
|
||||
const defaultLocale = locales[0];
|
||||
const apiEntriesBySymbol = createApiEntriesBySymbol(readApiManifest());
|
||||
|
||||
checkManualContent();
|
||||
checkExamples();
|
||||
checkManualImages();
|
||||
checkManualLinks();
|
||||
checkBannedLinks();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`[content-check] Found ${errors.length} issue(s):`);
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[content-check] OK: ${locales.length} locales, manual pages, examples, links, and assets are consistent.`);
|
||||
|
||||
function checkManualContent() {
|
||||
const manualByLocale = new Map();
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeDir = join(manualRoot, locale);
|
||||
|
||||
if (!existsSync(localeDir)) {
|
||||
errors.push(`Missing manual locale directory: ${formatPath(localeDir)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = new Map();
|
||||
for (const fileName of readdirSync(localeDir)
|
||||
.filter(file => file.endsWith('.md'))
|
||||
.sort()) {
|
||||
const slug = fileName.replace(/\.md$/, '');
|
||||
const filePath = join(localeDir, fileName);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const parsed = parseMarkdown(filePath, content);
|
||||
|
||||
if (slug.includes('tranform')) {
|
||||
errors.push(`Manual slug contains misspelling "tranform": ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
if (content.trim() === '') {
|
||||
errors.push(`Manual page is empty: ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
if (!parsed.frontmatter.title) {
|
||||
errors.push(`Manual page is missing frontmatter title: ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
if (!parsed.frontmatter.description) {
|
||||
errors.push(`Manual page is missing frontmatter description: ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(parsed.frontmatter.order))) {
|
||||
errors.push(`Manual page is missing numeric frontmatter order: ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
if (parsed.body.trim() === '') {
|
||||
errors.push(`Manual page has no body content: ${formatPath(filePath)}`);
|
||||
}
|
||||
|
||||
entries.set(slug, {
|
||||
filePath,
|
||||
headingSignature: getHeadingSignature(content),
|
||||
});
|
||||
}
|
||||
|
||||
manualByLocale.set(locale, entries);
|
||||
}
|
||||
|
||||
const baseEntries = manualByLocale.get(defaultLocale);
|
||||
if (!baseEntries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseSlugs = [...baseEntries.keys()].sort();
|
||||
|
||||
for (const locale of locales.slice(1)) {
|
||||
const localizedEntries = manualByLocale.get(locale);
|
||||
if (!localizedEntries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localizedSlugs = [...localizedEntries.keys()].sort();
|
||||
const missing = baseSlugs.filter(slug => !localizedEntries.has(slug));
|
||||
const extra = localizedSlugs.filter(slug => !baseEntries.has(slug));
|
||||
|
||||
for (const slug of missing) {
|
||||
errors.push(`Manual page "${slug}" exists in ${defaultLocale} but is missing in ${locale}.`);
|
||||
}
|
||||
|
||||
for (const slug of extra) {
|
||||
errors.push(`Manual page "${slug}" exists in ${locale} but is missing in ${defaultLocale}.`);
|
||||
}
|
||||
|
||||
for (const slug of baseSlugs.filter(item => localizedEntries.has(item))) {
|
||||
const baseSignature = baseEntries.get(slug).headingSignature;
|
||||
const localizedSignature = localizedEntries.get(slug).headingSignature;
|
||||
|
||||
if (baseSignature !== localizedSignature) {
|
||||
errors.push(
|
||||
`Manual heading depth structure differs for "${slug}" between ${defaultLocale} (${baseSignature}) and ${locale} (${localizedSignature}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkBannedLinks() {
|
||||
const scanTargets = [
|
||||
join(root, 'README.md'),
|
||||
join(root, 'docs'),
|
||||
join(root, 'packages/renderer/README.md'),
|
||||
join(root, 'website/src/content'),
|
||||
];
|
||||
|
||||
for (const target of scanTargets) {
|
||||
const files = statSafe(target)?.isDirectory() ? listFiles(target) : [target];
|
||||
|
||||
for (const filePath of files.filter(file => ['.md', '.mdx'].includes(extname(file).toLowerCase()))) {
|
||||
if (!existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
if (bannedLinkPattern.test(content)) {
|
||||
errors.push(`Content contains an internal or invalid link: ${formatPath(filePath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkExamples() {
|
||||
if (!existsSync(examplesRoot)) {
|
||||
errors.push(`Missing examples directory: ${formatPath(examplesRoot)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = readdirSync(examplesRoot);
|
||||
const jsonSlugs = files
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace(/\.json$/, ''))
|
||||
.sort();
|
||||
const sourceSlugs = files
|
||||
.filter(file => file.endsWith('.ts'))
|
||||
.map(file => file.replace(/\.ts$/, ''))
|
||||
.sort();
|
||||
|
||||
for (const slug of jsonSlugs.filter(item => !sourceSlugs.includes(item))) {
|
||||
errors.push(`Example metadata is missing a TypeScript source file: ${slug}.json`);
|
||||
}
|
||||
|
||||
for (const slug of sourceSlugs.filter(item => !jsonSlugs.includes(item))) {
|
||||
errors.push(`Example TypeScript source is missing JSON metadata: ${slug}.ts`);
|
||||
}
|
||||
|
||||
for (const slug of jsonSlugs) {
|
||||
const filePath = join(examplesRoot, `${slug}.json`);
|
||||
const metadata = readJson(filePath);
|
||||
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const locale of locales) {
|
||||
if (!metadata.title?.[locale]) {
|
||||
errors.push(`Example "${slug}" is missing title for ${locale}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkManualImages() {
|
||||
const usedImages = new Set();
|
||||
|
||||
for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const match of content.matchAll(imagePattern)) {
|
||||
const target = parseMarkdownTarget(match[1]);
|
||||
|
||||
if (!target || isExternalReference(target.href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.href.startsWith('/manual/')) {
|
||||
errors.push(
|
||||
`Manual image should use a local relative path for editor previews: ${target.href} referenced by ${formatPath(filePath)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLocalReference(target.href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { pathname } = splitPathnameAndTrailing(target.href);
|
||||
const imagePath = resolve(dirname(filePath), decodeURIComponent(pathname));
|
||||
|
||||
if (!isInsideDir(manualAssetRoot, imagePath)) {
|
||||
errors.push(
|
||||
`Manual image must live under website/src/content/manual/assets: ${target.href} referenced by ${formatPath(filePath)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedImages.add(formatManualAssetPath(imagePath));
|
||||
|
||||
if (!existsSync(imagePath)) {
|
||||
errors.push(`Manual image is missing: ${target.href} referenced by ${formatPath(filePath)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isImageFile(imagePath)) {
|
||||
errors.push(
|
||||
`Manual image reference is not an image file: ${target.href} referenced by ${formatPath(filePath)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const imagePath of listFiles(manualAssetRoot).filter(isImageFile)) {
|
||||
const normalized = formatManualAssetPath(imagePath);
|
||||
|
||||
if (!usedImages.has(normalized)) {
|
||||
errors.push(`Manual image is not referenced by any manual page: ${normalized}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkManualLinks() {
|
||||
for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const target of getManualLinkTargets(content)) {
|
||||
const parsed = parseMarkdownTarget(target);
|
||||
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isApiReference(parsed.href)) {
|
||||
checkManualApiLink(parsed.href, target, filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLocalReference(parsed.href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { pathname } = splitPathnameAndTrailing(parsed.href);
|
||||
|
||||
if (extname(pathname).toLowerCase() !== '.md') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedPath = resolve(dirname(filePath), decodeURIComponent(pathname));
|
||||
|
||||
if (!isInsideDir(manualRoot, linkedPath)) {
|
||||
errors.push(
|
||||
`Manual Markdown link must point to a manual page: ${target} referenced by ${formatPath(filePath)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existsSync(linkedPath)) {
|
||||
errors.push(`Manual page link is missing: ${target} referenced by ${formatPath(filePath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkManualApiLink(href, target, filePath) {
|
||||
const { pathname } = splitPathnameAndTrailing(href);
|
||||
const symbol = decodeURIComponent(pathname.slice(apiReferencePrefix.length)).trim();
|
||||
|
||||
if (!symbol) {
|
||||
errors.push(`Manual API link is missing a symbol: ${target} referenced by ${formatPath(filePath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = apiEntriesBySymbol.get(symbol) ?? [];
|
||||
|
||||
if (entries.length === 0) {
|
||||
errors.push(`Manual API link target not found: ${target} referenced by ${formatPath(filePath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length > 1) {
|
||||
errors.push(
|
||||
`Manual API link target is ambiguous: ${target} referenced by ${formatPath(filePath)}. Use Namespace.Symbol.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getManualLinkTargets(content) {
|
||||
const targets = [];
|
||||
|
||||
for (const match of content.matchAll(markdownLinkPattern)) {
|
||||
targets.push(match[2]);
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(htmlHrefPattern)) {
|
||||
targets.push(match[2]);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function parseMarkdown(filePath, content) {
|
||||
if (!content.startsWith('---')) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
|
||||
const endMatch = content.slice(3).match(/\r?\n---\r?\n/);
|
||||
if (!endMatch || endMatch.index === undefined) {
|
||||
errors.push(`Manual page has unterminated frontmatter: ${formatPath(filePath)}`);
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatterEnd = 3 + endMatch.index;
|
||||
const delimiterLength = endMatch[0].length;
|
||||
const frontmatterText = content.slice(3, frontmatterEnd);
|
||||
const body = content.slice(frontmatterEnd + delimiterLength);
|
||||
const frontmatter = {};
|
||||
|
||||
for (const line of frontmatterText.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frontmatter[match[1]] = match[2].replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function getHeadingSignature(content) {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.match(/^(#{2,6})\s+\S/))
|
||||
.filter(Boolean)
|
||||
.map(match => match[1].length)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return readJsonFile(filePath, 'example metadata');
|
||||
} catch (error) {
|
||||
errors.push(`Unable to parse JSON at ${formatPath(filePath)}: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readApiManifest() {
|
||||
const source = readFileSync(apiManifestPath, 'utf8');
|
||||
const match = source.match(/^export const apiManifest = ([\s\S]*?) as const;\s*export type /);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Unable to parse API manifest at ${formatPath(apiManifestPath)}.`);
|
||||
}
|
||||
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function createApiEntriesBySymbol(manifest) {
|
||||
const entriesBySymbol = new Map();
|
||||
|
||||
for (const entry of manifest.entries ?? []) {
|
||||
addApiEntrySymbol(entriesBySymbol, entry.title, entry);
|
||||
addApiEntrySymbol(entriesBySymbol, `${entry.namespace}.${entry.title}`, entry);
|
||||
}
|
||||
|
||||
return entriesBySymbol;
|
||||
}
|
||||
|
||||
function addApiEntrySymbol(entriesBySymbol, symbol, entry) {
|
||||
const entries = entriesBySymbol.get(symbol);
|
||||
|
||||
if (entries) {
|
||||
entries.push(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
entriesBySymbol.set(symbol, [entry]);
|
||||
}
|
||||
|
||||
function isImageFile(filePath) {
|
||||
return ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'].includes(extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
function parseMarkdownTarget(target) {
|
||||
const trimmed = target.trim();
|
||||
const match = trimmed.match(/^(\S+)(.*)$/s);
|
||||
|
||||
return match ? { href: match[1], suffix: match[2] } : undefined;
|
||||
}
|
||||
|
||||
function isExternalReference(href) {
|
||||
return /^[a-z][a-z\d+.-]*:/i.test(href);
|
||||
}
|
||||
|
||||
function isApiReference(href) {
|
||||
const { pathname } = splitPathnameAndTrailing(href);
|
||||
|
||||
return pathname.startsWith(apiReferencePrefix);
|
||||
}
|
||||
|
||||
function isLocalReference(href) {
|
||||
return !href.startsWith('/') && !href.startsWith('#') && !isExternalReference(href);
|
||||
}
|
||||
|
||||
function splitPathnameAndTrailing(href) {
|
||||
const match = href.match(/^([^?#]*)([?#].*)?$/s);
|
||||
|
||||
return {
|
||||
pathname: match?.[1] ?? href,
|
||||
trailing: match?.[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function formatManualAssetPath(filePath) {
|
||||
return formatPath(filePath).replace(/^website\/src\/content\/manual\//, 'manual/');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import {
|
||||
assertInsideDir,
|
||||
readPackageJson,
|
||||
resolvePackagePath,
|
||||
resolvePackageRoot,
|
||||
toPosixPath,
|
||||
} from './package-utils.mjs';
|
||||
|
||||
const packageRoot = resolvePackageRoot();
|
||||
const packageJson = readPackageJson(packageRoot);
|
||||
const packageName = packageJson.name ?? packageRoot;
|
||||
const targetDirs = getCleanTargetDirs(packageJson).map(target =>
|
||||
resolvePackagePath(packageRoot, target, 'clean target'),
|
||||
);
|
||||
|
||||
for (const targetDir of [...new Set(targetDirs.map(dir => resolve(dir)))]) {
|
||||
assertInsideDir(packageRoot, targetDir, 'Clean target');
|
||||
|
||||
if (resolve(targetDir) === resolve(packageRoot)) {
|
||||
throw new Error(`Refusing to clean the package root: ${targetDir}`);
|
||||
}
|
||||
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
console.log(`[package-clean] Removed ${formatPackagePath(targetDir)} for ${packageName}.`);
|
||||
}
|
||||
|
||||
function getCleanTargetDirs(metadata) {
|
||||
const configuredTargets = metadata.aholoClean?.targets;
|
||||
|
||||
if (Array.isArray(configuredTargets) && configuredTargets.length > 0) {
|
||||
return configuredTargets;
|
||||
}
|
||||
|
||||
const outputDirs = [metadata.main, metadata.module, metadata.types, metadata.typings]
|
||||
.filter(value => typeof value === 'string' && value.trim() !== '')
|
||||
.map(filePath => dirname(filePath))
|
||||
.filter(dir => dir !== '.');
|
||||
|
||||
return outputDirs.length > 0 ? [...new Set(outputDirs)] : ['dist'];
|
||||
}
|
||||
|
||||
function formatPackagePath(filePath) {
|
||||
return toPosixPath(relative(packageRoot, filePath));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runCommand, runCommandOrExit, workspaceRoot } from './package-utils.mjs';
|
||||
|
||||
const rootDir = workspaceRoot;
|
||||
|
||||
if (process.env.AHOLO_SKIP_SUBMODULE_UPDATE === '1') {
|
||||
console.log('[submodules] Skipping because AHOLO_SKIP_SUBMODULE_UPDATE=1.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(resolve(rootDir, '.git'))) {
|
||||
console.log('[submodules] Skipping submodule check outside a Git checkout.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const unrecordedSubmodules = listUnrecordedSubmoduleCommits();
|
||||
|
||||
if (unrecordedSubmodules.length > 0) {
|
||||
console.log('[submodules] Skipping update because these submodules have unrecorded commits:');
|
||||
|
||||
for (const submodule of unrecordedSubmodules) {
|
||||
console.log(` - ${submodule}`);
|
||||
}
|
||||
|
||||
console.log('[submodules] Run git add <path> first if the parent repo should record these commits.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runCommandOrExit('git', ['submodule', 'update', '--init', '--recursive'], {
|
||||
cwd: rootDir,
|
||||
env: gitEnv(),
|
||||
label: 'git submodule update --init --recursive',
|
||||
});
|
||||
|
||||
console.log('[submodules] Ready.');
|
||||
|
||||
function listUnrecordedSubmoduleCommits() {
|
||||
return listSubmodulePaths().filter(hasUnrecordedSubmoduleCommit);
|
||||
}
|
||||
|
||||
function listSubmodulePaths() {
|
||||
if (!existsSync(resolve(rootDir, '.gitmodules'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = runCommandOrExit('git', ['config', '--file', '.gitmodules', '--get-regexp', 'path'], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
env: gitEnv(),
|
||||
label: 'git config --file .gitmodules --get-regexp path',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return (result.stdout ?? '')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(line => line.replace(/^submodule\..*\.path\s+/, ''));
|
||||
}
|
||||
|
||||
function hasUnrecordedSubmoduleCommit(submodulePath) {
|
||||
const result = runCommand('git', ['diff', '--quiet', '--ignore-submodules=dirty', '--', submodulePath], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
env: gitEnv(),
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
if (result.status === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.status === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`[submodules] Unable to inspect submodule status: ${submodulePath}`);
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
console.error(result.stderr);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function gitEnv() {
|
||||
const env = { ...process.env };
|
||||
const windowsOpenSsh = 'C:\\Windows\\System32\\OpenSSH\\ssh.exe';
|
||||
|
||||
if (process.platform === 'win32' && !env.GIT_SSH_COMMAND && existsSync(windowsOpenSsh)) {
|
||||
env.GIT_SSH_COMMAND = windowsOpenSsh.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
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(/<svg\b[^>]*>\s*<use\s+href=(["'])[^"']*assets\/icons\.svg#[^"']+\1><\/use>\s*<\/svg>/g, '');
|
||||
}
|
||||
|
||||
function stripTypeDocOptionalTags(content) {
|
||||
return content.replace(/<code class="tsd-tag">Optional<\/code>/g, '');
|
||||
}
|
||||
|
||||
function inlineTypeDocDefaultValues(content) {
|
||||
return inlineTypeDocMemberDefaultValues(inlineTypeDocParameterDefaultValues(content));
|
||||
}
|
||||
|
||||
function inlineTypeDocMemberDefaultValues(content) {
|
||||
return replaceTypeDocBlocks(
|
||||
content,
|
||||
/<section\b[^>]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g,
|
||||
'section',
|
||||
inlineTypeDocDefaultValueInBlock,
|
||||
);
|
||||
}
|
||||
|
||||
function inlineTypeDocParameterDefaultValues(content) {
|
||||
return replaceTypeDocBlocks(
|
||||
content,
|
||||
/<li\b[^>]*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,
|
||||
/<section\b[^>]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g,
|
||||
'section',
|
||||
block =>
|
||||
block.includes('<div class="tsd-type-declaration">')
|
||||
? block
|
||||
.replace(/<div class="tsd-signature\b[^>]*>[\s\S]*?<\/div>/, '')
|
||||
.replace(/(<div class="tsd-type-declaration">)\s*<h4>Type Declaration<\/h4>/, '$1')
|
||||
: block,
|
||||
);
|
||||
}
|
||||
|
||||
function inlineTypeDocDefaultValueInBlock(block) {
|
||||
const defaultBlock = block.match(/<div class="tsd-tag-default(?:Value)?">[\s\S]*?<\/div>/)?.[0];
|
||||
|
||||
if (!defaultBlock) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const defaultValue = extractTypeDocDefaultValue(defaultBlock);
|
||||
|
||||
if (!defaultValue) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const blockWithoutDefault = block
|
||||
.replace(defaultBlock, '')
|
||||
.replace(/<div class="tsd-comment tsd-typography">\s*<\/div>/g, '');
|
||||
|
||||
return appendTypeDocDefaultValueToTitle(blockWithoutDefault, defaultValue);
|
||||
}
|
||||
|
||||
function extractTypeDocDefaultValue(defaultBlock) {
|
||||
const content = defaultBlock
|
||||
.replace(/^<div class="tsd-tag-default(?:Value)?">/, '')
|
||||
.replace(/<\/div>$/, '')
|
||||
.replace(/<h4\b[\s\S]*?<\/h4>/, '')
|
||||
.trim();
|
||||
const paragraphs = [...content.matchAll(/<p>([\s\S]*?)<\/p>/g)].map(match => match[1].trim()).filter(Boolean);
|
||||
const inlineCodeMatch = (paragraphs[0] ?? content).match(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/);
|
||||
|
||||
if (inlineCodeMatch) {
|
||||
return inlineCodeMatch[1].trim();
|
||||
}
|
||||
|
||||
const value = (paragraphs.length > 0 ? paragraphs.join(' ') : content).trim();
|
||||
const codeMatch = value.match(/^<code(?:\s[^>]*)?>([\s\S]*?)<\/code>$/);
|
||||
|
||||
return (codeMatch?.[1] ?? value).trim();
|
||||
}
|
||||
|
||||
function appendTypeDocDefaultValueToTitle(block, defaultValue) {
|
||||
const defaultHtml = `<code class="tsd-default-value">${defaultValue}</code>`;
|
||||
const titledBlock = block.replace(/<(h3|h5)\b([^>]*)>([\s\S]*?)<\/\1>/, (match, tagName, attributes, inner) => {
|
||||
const title = inner.replace(
|
||||
/(\s*<a\b[^>]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\2[^>]*><\/a>\s*)$/,
|
||||
`${defaultHtml}$1`,
|
||||
);
|
||||
|
||||
return title === inner
|
||||
? `<${tagName}${attributes}>${inner}${defaultHtml}</${tagName}>`
|
||||
: `<${tagName}${attributes}>${title}</${tagName}>`;
|
||||
});
|
||||
|
||||
if (titledBlock !== block) {
|
||||
return titledBlock;
|
||||
}
|
||||
|
||||
return block.replace(/(<span\b[^>]*>[\s\S]*?)(<\/span>)/, `$1${defaultHtml}$2`);
|
||||
}
|
||||
|
||||
function removeTypeDocIndexGroup(content) {
|
||||
const marker = '<section class="tsd-panel-group tsd-index-group">';
|
||||
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(`</?${tagName}\\b[^>]*>`, 'g');
|
||||
tagPattern.lastIndex = start;
|
||||
|
||||
let depth = 0;
|
||||
let match;
|
||||
|
||||
while ((match = tagPattern.exec(content))) {
|
||||
if (match[0].startsWith('</')) {
|
||||
depth -= 1;
|
||||
} else {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
return tagPattern.lastIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function addTypeDocGroupHeadingIds(content) {
|
||||
const usedSlugs = new Map();
|
||||
|
||||
return content.replace(
|
||||
/(<summary\b[^>]*\bdata-key=(["'])section-[^"']+\2[^>]*>\s*)<h2>(.*?)<\/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}<h2 id="${slug}">${headingHtml}</h2>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function extractTypeDocHtmlHeadings(content) {
|
||||
return [
|
||||
...content.matchAll(
|
||||
/<summary\b[^>]*\bdata-key=(["'])section-[^"']+\1[^>]*>\s*<h2 id=(["'])([^"']+)\2>(.*?)<\/h2>/g,
|
||||
),
|
||||
]
|
||||
.map(match => ({
|
||||
depth: 2,
|
||||
index: match.index ?? 0,
|
||||
slug: match[3],
|
||||
text: typeDocHeadingText(match[4]),
|
||||
}))
|
||||
.concat(
|
||||
[...content.matchAll(/<h3\b[^>]*\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(/<a\b[^>]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*><\/a>/g, '')
|
||||
.replace(/<code class="tsd-default-value">[\s\S]*?<\/code>/g, '')
|
||||
.replace(/<code class="tsd-tag">[\s\S]*?<\/code>/g, '')
|
||||
.replace(/<span class="tsd-flag">[\s\S]*?<\/span>/g, '')
|
||||
.replace(/<wbr\s*\/?>/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();
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates AI-friendly English markdown copies of the built documentation
|
||||
* under website/dist/llm/. Reads cleanest sources where possible (manual
|
||||
* markdown, example .ts/.json) and converts generated API HTML fragments
|
||||
* to markdown.
|
||||
*
|
||||
* Output layout:
|
||||
* website/dist/llm/
|
||||
* index.md
|
||||
* README.md
|
||||
* manual/{slug}.md, index.md
|
||||
* api/{category}/{folder}/{name}.md, index.md
|
||||
* examples/{slug}.md, index.md
|
||||
* website/dist/llms.txt
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, extname, resolve } from 'node:path';
|
||||
import { workspaceRoot } from './package-utils.mjs';
|
||||
|
||||
const LOCALE = 'en-US';
|
||||
const manualSourceRoot = resolve(workspaceRoot, 'website/src/content/manual');
|
||||
const examplesSourceRoot = resolve(workspaceRoot, 'website/src/content/examples');
|
||||
const generatedApiRoot = resolve(workspaceRoot, 'website/.generated/api');
|
||||
const websiteDistRoot = resolve(workspaceRoot, 'website/dist');
|
||||
const llmRoot = resolve(workspaceRoot, 'website/dist/llm');
|
||||
const llmsTxtPath = resolve(websiteDistRoot, 'llms.txt');
|
||||
|
||||
await rm(llmRoot, { recursive: true, force: true });
|
||||
await mkdir(llmRoot, { recursive: true });
|
||||
|
||||
const apiManifest = await loadApiManifest();
|
||||
const manualEntries = await emitManual();
|
||||
const apiEntries = await emitApi();
|
||||
const exampleEntries = await emitExamples();
|
||||
|
||||
await writeFile(resolve(llmRoot, 'index.md'), renderRootIndex(manualEntries, apiEntries, exampleEntries));
|
||||
await writeFile(resolve(llmRoot, 'README.md'), renderReadme());
|
||||
await writeFile(llmsTxtPath, renderLlmsTxt(manualEntries, apiEntries, exampleEntries));
|
||||
|
||||
console.log(
|
||||
`[llm-docs] Wrote markdown corpus and /llms.txt: ` +
|
||||
`${manualEntries.length} manual, ${apiEntries.length} api, ${exampleEntries.length} example files.`,
|
||||
);
|
||||
|
||||
// ---------- Manual ----------
|
||||
|
||||
async function emitManual() {
|
||||
const sourceDir = resolve(manualSourceRoot, LOCALE);
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = (await readdir(sourceDir, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile() && extname(entry.name) === '.md')
|
||||
.map(entry => entry.name);
|
||||
|
||||
const entries = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
const filePath = resolve(sourceDir, fileName);
|
||||
const source = await readFile(filePath, 'utf8');
|
||||
const { frontmatter, body } = parseFrontmatter(source);
|
||||
const slug = fileName.replace(/\.md$/, '');
|
||||
const title = stringField(frontmatter, 'title') ?? slug;
|
||||
const description = stringField(frontmatter, 'description') ?? '';
|
||||
const order = numberField(frontmatter, 'order') ?? 0;
|
||||
const rewrittenBody = rewriteManualBody(body);
|
||||
const markdown = renderManualMarkdown(title, description, rewrittenBody);
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'manual', `${slug}.md`), markdown);
|
||||
entries.push({ slug, title, description, order });
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug));
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'manual', 'index.md'), renderManualIndex(entries));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function renderManualMarkdown(title, description, body) {
|
||||
const sections = [`# ${title}`];
|
||||
|
||||
if (description && description !== title) {
|
||||
sections.push(`> ${description}`);
|
||||
}
|
||||
|
||||
sections.push(body.trim());
|
||||
|
||||
return `${sections.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function renderManualIndex(entries) {
|
||||
const lines = [
|
||||
'# Manual',
|
||||
'',
|
||||
'> Full product manual. Read `getting-started.md` then `basic-concepts.md` before using the SDK.',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
const description = entry.description && entry.description !== entry.title ? ` — ${entry.description}` : '';
|
||||
lines.push(`- [${entry.title}](./${entry.slug}.md)${description}`);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function rewriteManualBody(body) {
|
||||
const withRewrittenLinks = body.replace(
|
||||
/(^|[^!])(\[[^\]]*]\()([^)\s]+)([^)]*\))/g,
|
||||
(match, prefix, opening, href, closing) => {
|
||||
const rewritten = rewriteManualHref(href);
|
||||
return rewritten ? `${prefix}${opening}${rewritten}${closing}` : match;
|
||||
},
|
||||
);
|
||||
|
||||
return withRewrittenLinks.replace(/(!\[[^\]]*]\()([^)\s]+)([^)]*\))/g, (match, opening, href, closing) => {
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const sourceLocation = `../../src/content/manual/${LOCALE}/${href.replace(/^\.?\/+/, '')}`;
|
||||
return `${opening}${sourceLocation}${closing}`;
|
||||
});
|
||||
}
|
||||
|
||||
function rewriteManualHref(href) {
|
||||
if (!href) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (href.startsWith('api:')) {
|
||||
const symbol = href
|
||||
.slice('api:'.length)
|
||||
.replace(/[#?].*$/, '')
|
||||
.trim();
|
||||
const entry = findApiEntryBySymbol(symbol);
|
||||
return entry ? `../api/${entry.slug}.md` : undefined;
|
||||
}
|
||||
|
||||
if (href.startsWith('/')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Rewrite cross-section relative links like `../../examples/foo/` → `../examples/foo.md`.
|
||||
const crossSection = href.match(/^\.\.\/\.\.\/(examples|api|manual)\/([^?#]*?)\/?([?#].*)?$/);
|
||||
|
||||
if (crossSection) {
|
||||
const [, section, slug, trailing = ''] = crossSection;
|
||||
const cleanSlug = slug.replace(/\/$/, '');
|
||||
return `../${section}/${cleanSlug === '' ? 'index' : cleanSlug}.md${trailing}`;
|
||||
}
|
||||
|
||||
if (href.endsWith('.md') || /\.md[?#]/.test(href)) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
|
||||
async function emitApi() {
|
||||
if (!apiManifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiLocaleDir = resolve(generatedApiRoot, LOCALE);
|
||||
|
||||
if (!existsSync(apiLocaleDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
|
||||
for (const entry of apiManifest.entries ?? []) {
|
||||
const sourcePath = resolve(apiLocaleDir, `${entry.slug}.html`);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = await readFile(sourcePath, 'utf8');
|
||||
const body = htmlToMarkdown(html, {
|
||||
rewriteLink: href => rewriteApiHref(href, entry.slug),
|
||||
});
|
||||
const heading = entry.signature
|
||||
? `# ${entry.title}\n\n\`\`\`ts\n${entry.signature}\n\`\`\``
|
||||
: `# ${entry.title}`;
|
||||
const meta = renderApiMeta(entry);
|
||||
const description = entry.description ? `> ${entry.description}\n\n` : '';
|
||||
const markdown = `${heading}\n\n${meta}${description}${body.trim()}\n`;
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'api', `${entry.slug}.md`), markdown);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'api', 'index.md'), renderApiIndex(entries));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function renderApiMeta(entry) {
|
||||
const segments = [];
|
||||
|
||||
if (entry.categoryLabel) {
|
||||
segments.push(`Category: \`${entry.categoryLabel}\``);
|
||||
}
|
||||
|
||||
if (entry.namespaceLabel && entry.namespaceLabel !== entry.categoryLabel) {
|
||||
segments.push(`Namespace: \`${entry.namespaceLabel}\``);
|
||||
}
|
||||
|
||||
if (entry.kindLabel) {
|
||||
segments.push(`Kind: \`${entry.kindLabel}\``);
|
||||
}
|
||||
|
||||
return segments.length ? `${segments.join(' · ')}\n\n` : '';
|
||||
}
|
||||
|
||||
function renderApiIndex(entries) {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.categoryLabel ?? 'Misc'}::${entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc'}`;
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
categoryLabel: entry.categoryLabel ?? 'Misc',
|
||||
namespaceLabel: entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc',
|
||||
entries: [],
|
||||
});
|
||||
}
|
||||
|
||||
grouped.get(key).entries.push(entry);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'# API Reference',
|
||||
'',
|
||||
'> Generated from TypeDoc, grouped by namespace. Each `.md` file corresponds to one exported symbol.',
|
||||
'',
|
||||
];
|
||||
|
||||
let lastCategory;
|
||||
|
||||
for (const group of grouped.values()) {
|
||||
if (group.categoryLabel !== lastCategory) {
|
||||
lines.push('', `## ${group.categoryLabel}`, '');
|
||||
lastCategory = group.categoryLabel;
|
||||
}
|
||||
|
||||
if (group.namespaceLabel && group.namespaceLabel !== group.categoryLabel) {
|
||||
lines.push(`### ${group.namespaceLabel}`, '');
|
||||
}
|
||||
|
||||
for (const entry of group.entries) {
|
||||
lines.push(`- [${entry.title}](./${entry.slug}.md) — ${entry.kindLabel}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function rewriteApiHref(href, currentSlug) {
|
||||
if (!href) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const localePrefix = `/${LOCALE}/api/`;
|
||||
|
||||
if (href.startsWith(localePrefix)) {
|
||||
const [pathname, hash = ''] = splitHash(href.slice(localePrefix.length));
|
||||
const targetSlug = pathname.replace(/\/$/, '');
|
||||
if (!targetSlug) {
|
||||
return `./index.md${hash}`;
|
||||
}
|
||||
return relativeMdHref(currentSlug, targetSlug) + hash;
|
||||
}
|
||||
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function splitHash(value) {
|
||||
const index = value.indexOf('#');
|
||||
if (index === -1) {
|
||||
return [value, ''];
|
||||
}
|
||||
return [value.slice(0, index), value.slice(index)];
|
||||
}
|
||||
|
||||
function relativeMdHref(fromSlug, toSlug) {
|
||||
const fromParts = fromSlug.split('/');
|
||||
const toParts = toSlug.split('/');
|
||||
fromParts.pop();
|
||||
|
||||
let common = 0;
|
||||
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
||||
common += 1;
|
||||
}
|
||||
|
||||
const ups = fromParts.length - common;
|
||||
const downs = toParts.slice(common);
|
||||
const prefix = ups === 0 ? './' : '../'.repeat(ups);
|
||||
|
||||
return `${prefix}${downs.join('/')}.md`;
|
||||
}
|
||||
|
||||
function findApiEntryBySymbol(symbol) {
|
||||
if (!apiManifest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = apiManifest.entries ?? [];
|
||||
const direct = entries.find(entry => entry.title === symbol);
|
||||
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const dotted = symbol.split('.');
|
||||
|
||||
if (dotted.length === 2) {
|
||||
return entries.find(entry => entry.namespace === dotted[0] && entry.title === dotted[1]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function loadApiManifest() {
|
||||
const manifestPath = resolve(generatedApiRoot, 'manifest.ts');
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = await readFile(manifestPath, 'utf8');
|
||||
const start = source.indexOf('{');
|
||||
const end = source.lastIndexOf('}');
|
||||
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(source.slice(start, end + 1));
|
||||
}
|
||||
|
||||
// ---------- Examples ----------
|
||||
|
||||
async function emitExamples() {
|
||||
if (!existsSync(examplesSourceRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await readdir(examplesSourceRoot, { withFileTypes: true });
|
||||
const slugs = entries
|
||||
.filter(entry => entry.isFile() && extname(entry.name) === '.json')
|
||||
.map(entry => entry.name.replace(/\.json$/, ''));
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
const jsonPath = resolve(examplesSourceRoot, `${slug}.json`);
|
||||
const codePath = resolve(examplesSourceRoot, `${slug}.ts`);
|
||||
const meta = JSON.parse(await readFile(jsonPath, 'utf8'));
|
||||
const code = existsSync(codePath) ? await readFile(codePath, 'utf8') : '';
|
||||
const title = meta?.title?.[LOCALE] ?? meta?.title?.['en-US'] ?? slug;
|
||||
const tags = Array.isArray(meta?.tags) ? meta.tags : [];
|
||||
const order = typeof meta?.order === 'number' ? meta.order : Number.POSITIVE_INFINITY;
|
||||
|
||||
const markdown = renderExampleMarkdown({ slug, title, tags, code });
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'examples', `${slug}.md`), markdown);
|
||||
items.push({ slug, title, tags, order });
|
||||
}
|
||||
|
||||
items.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug));
|
||||
|
||||
await writeMarkdown(resolve(llmRoot, 'examples', 'index.md'), renderExamplesIndex(items));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderExampleMarkdown({ slug, title, tags, code }) {
|
||||
const tagLine = tags.length ? `Tags: ${tags.map(tag => `\`${tag}\``).join(', ')}\n\n` : '';
|
||||
const sourceHint = `Source: \`website/src/content/examples/${slug}.ts\`\n\n`;
|
||||
const liveUrl = `/${LOCALE}/examples/${slug}/`;
|
||||
const codeBlock = code.trim() ? `\`\`\`ts\n${code.trim()}\n\`\`\`\n` : '_No code attached._\n';
|
||||
|
||||
return `# ${title}\n\n${tagLine}${sourceHint}Live page: \`${liveUrl}\`\n\n${codeBlock}`;
|
||||
}
|
||||
|
||||
function renderExamplesIndex(items) {
|
||||
const lines = [
|
||||
'# Examples',
|
||||
'',
|
||||
'> Each example demonstrates one SDK capability. The markdown file contains the original TypeScript source.',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const tagSuffix = item.tags.length ? ` — tags: ${item.tags.join(', ')}` : '';
|
||||
lines.push(`- [${item.title}](./${item.slug}.md)${tagSuffix}`);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// ---------- Root index ----------
|
||||
|
||||
function renderRootIndex(manual, api, examples) {
|
||||
const lines = [
|
||||
'# Aholo Viewer — AI-Friendly Documentation',
|
||||
'',
|
||||
'This directory is the machine-readable corpus of the Aholo Viewer docs. ',
|
||||
'It is regenerated on every `pnpm build` from the same sources that produce the public website.',
|
||||
'',
|
||||
'## Entry Points',
|
||||
'',
|
||||
`- [Manual](./manual/index.md) — ${manual.length} pages`,
|
||||
`- [API Reference](./api/index.md) — ${api.length} symbols`,
|
||||
`- [Examples](./examples/index.md) — ${examples.length} runnable demos`,
|
||||
'',
|
||||
'## Recommended Reading Order',
|
||||
'',
|
||||
'1. `manual/getting-started.md` — install and bootstrap a viewer in minutes.',
|
||||
'2. `manual/basic-concepts.md` — mental model for scene, camera, renderer.',
|
||||
'3. Browse `api/` and `examples/` on demand.',
|
||||
'',
|
||||
'## Directory Layout',
|
||||
'',
|
||||
'```',
|
||||
'llm/',
|
||||
' index.md # this file',
|
||||
' manual/ # user manual (markdown)',
|
||||
' api/ # API reference (converted from TypeDoc HTML)',
|
||||
' examples/ # runnable examples with TypeScript source',
|
||||
'```',
|
||||
'',
|
||||
];
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function renderReadme() {
|
||||
return [
|
||||
'# /llm — AI-friendly docs',
|
||||
'',
|
||||
'This folder is generated by `scripts/generate-llm-docs.mjs` as part of `pnpm build`.',
|
||||
'',
|
||||
'It mirrors the human-facing site under `/{locale}/manual`, `/{locale}/api`, `/{locale}/examples`',
|
||||
'but as plain English markdown, which is easier for automated coding agents to parse.',
|
||||
'',
|
||||
'Do not edit files here directly — they will be regenerated on every build.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderLlmsTxt(manual, api, examples) {
|
||||
return [
|
||||
'# Aholo Viewer',
|
||||
'',
|
||||
'> High-performance 3D Gaussian Splatting rendering for web applications.',
|
||||
'',
|
||||
'AI assistants should use the markdown corpus under `/llm/` before scraping the human-facing site.',
|
||||
'',
|
||||
'## Primary Docs',
|
||||
'',
|
||||
`- [AI documentation index](/llm/index.md): overview and recommended reading order.`,
|
||||
`- [Manual](/llm/manual/index.md): product manual, ${manual.length} pages.`,
|
||||
`- [API Reference](/llm/api/index.md): exported SDK symbols, ${api.length} entries.`,
|
||||
`- [Examples](/llm/examples/index.md): runnable TypeScript examples, ${examples.length} demos.`,
|
||||
'',
|
||||
'## Recommended Use',
|
||||
'',
|
||||
'- Start with `/llm/manual/getting-started.md` for setup.',
|
||||
'- Read `/llm/manual/basic-concepts.md` for the scene, camera, and renderer model.',
|
||||
'- Use `/llm/api/index.md` to locate public exports.',
|
||||
'- Use `/llm/examples/index.md` for runnable integration patterns.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ---------- Utilities ----------
|
||||
|
||||
async function writeMarkdown(filePath, content) {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content);
|
||||
}
|
||||
|
||||
function parseFrontmatter(source) {
|
||||
if (!source.startsWith('---')) {
|
||||
return { frontmatter: {}, body: source };
|
||||
}
|
||||
|
||||
const closing = source.indexOf('\n---', 3);
|
||||
|
||||
if (closing === -1) {
|
||||
return { frontmatter: {}, body: source };
|
||||
}
|
||||
|
||||
const rawFrontmatter = source.slice(3, closing).trim();
|
||||
const body = source.slice(closing + 4).replace(/^\r?\n/, '');
|
||||
const frontmatter = {};
|
||||
|
||||
for (const line of rawFrontmatter.split(/\r?\n/)) {
|
||||
const match = line.match(/^([\w-]+)\s*:\s*(.*)$/);
|
||||
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, key, rawValue] = match;
|
||||
const trimmed = rawValue.trim().replace(/^['"](.*)['"]$/, '$1');
|
||||
|
||||
frontmatter[key] = trimmed;
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
function stringField(frontmatter, key) {
|
||||
const value = frontmatter[key];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function numberField(frontmatter, key) {
|
||||
const value = Number(frontmatter[key]);
|
||||
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
// ---------- HTML → Markdown ----------
|
||||
|
||||
function htmlToMarkdown(html, { rewriteLink } = {}) {
|
||||
let working = html;
|
||||
|
||||
working = working.replace(/<a\b[^>]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*>[\s\S]*?<\/a>/g, '');
|
||||
working = working.replace(/<svg\b[\s\S]*?<\/svg>/g, '');
|
||||
working = working.replace(/<wbr\s*\/?>/g, '');
|
||||
|
||||
working = working.replace(/<pre\b[^>]*>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g, (_match, attrs, inner) => {
|
||||
const langMatch = attrs.match(/class=(["'])([^"']*)\1/);
|
||||
const langClass = langMatch?.[2] ?? '';
|
||||
const lang = langClass.match(/language-([\w-]+)/)?.[1] ?? '';
|
||||
const text = decodeHtmlEntities(stripTags(inner));
|
||||
return `\n\n\`\`\`${lang}\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`;
|
||||
});
|
||||
|
||||
working = working.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/g, (_match, inner) => {
|
||||
const text = decodeHtmlEntities(stripTags(inner));
|
||||
return `\n\n\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`;
|
||||
});
|
||||
|
||||
working = working.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/g, (_match, inner) => {
|
||||
const text = decodeHtmlEntities(stripTags(inner)).replace(/`/g, '\\`');
|
||||
return `\`${text}\``;
|
||||
});
|
||||
|
||||
working = working.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/g, (_match, level, inner) => {
|
||||
const text = decodeHtmlEntities(stripTags(inner)).trim();
|
||||
if (!text) return '';
|
||||
return `\n\n${'#'.repeat(Number(level))} ${text}\n\n`;
|
||||
});
|
||||
|
||||
working = working.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/g, (_match, attrs, inner) => {
|
||||
const hrefMatch = attrs.match(/href=(["'])([^"']*)\1/);
|
||||
const text = decodeHtmlEntities(stripTags(inner)).trim();
|
||||
|
||||
if (!hrefMatch) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const rawHref = decodeHtmlEntities(hrefMatch[2]);
|
||||
const finalHref = rewriteLink ? (rewriteLink(rawHref) ?? rawHref) : rawHref;
|
||||
|
||||
if (!text) {
|
||||
return finalHref;
|
||||
}
|
||||
|
||||
return `[${text}](${finalHref})`;
|
||||
});
|
||||
|
||||
working = working.replace(/<(?:strong|b)\b[^>]*>([\s\S]*?)<\/(?:strong|b)>/g, (_match, inner) => {
|
||||
return `**${decodeHtmlEntities(stripTags(inner)).trim()}**`;
|
||||
});
|
||||
working = working.replace(/<(?:em|i)\b[^>]*>([\s\S]*?)<\/(?:em|i)>/g, (_match, inner) => {
|
||||
return `*${decodeHtmlEntities(stripTags(inner)).trim()}*`;
|
||||
});
|
||||
|
||||
working = working.replace(/<br\s*\/?>(\s*)/g, '\n$1');
|
||||
|
||||
working = working.replace(/<(ul|ol)\b[^>]*>([\s\S]*?)<\/\1>/g, (_match, tag, inner) => {
|
||||
const isOrdered = tag === 'ol';
|
||||
const items = [];
|
||||
let counter = 1;
|
||||
|
||||
inner.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/g, (_full, itemInner) => {
|
||||
const itemText = collapseWhitespace(decodeHtmlEntities(stripTags(itemInner)));
|
||||
if (itemText) {
|
||||
const bullet = isOrdered ? `${counter}.` : '-';
|
||||
items.push(`${bullet} ${itemText}`);
|
||||
counter += 1;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
return `\n\n${items.join('\n')}\n\n`;
|
||||
});
|
||||
|
||||
working = working.replace(/<(p|section|article|div|details|summary)\b[^>]*>/g, '\n\n');
|
||||
working = working.replace(/<\/(p|section|article|div|details|summary)>/g, '\n\n');
|
||||
|
||||
working = stripTags(working);
|
||||
working = decodeHtmlEntities(working);
|
||||
working = working.replace(/[ \t]+\n/g, '\n');
|
||||
working = working.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return working.trim();
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return value.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function collapseWhitespace(value) {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value) {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function resolvePackageRoot(packageRootArg = process.argv[2]) {
|
||||
const packageRoot = resolve(process.cwd(), packageRootArg ?? '.');
|
||||
assertInsideDir(workspaceRoot, packageRoot, 'Package root');
|
||||
|
||||
const packageJsonPath = resolve(packageRoot, 'package.json');
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`Package root is missing package.json: ${packageRoot}`);
|
||||
}
|
||||
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
export function readPackageJson(packageRoot) {
|
||||
return readJsonFile(resolve(packageRoot, 'package.json'), 'package metadata');
|
||||
}
|
||||
|
||||
export function readJsonFile(filePath, label = 'JSON') {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read ${label} at ${filePath}.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagePath(packageRoot, value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`Missing ${label}.`);
|
||||
}
|
||||
|
||||
const absolutePath = resolve(packageRoot, value);
|
||||
assertInsideDir(packageRoot, absolutePath, label);
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
export function resolveWorkspacePath(value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`Missing ${label}.`);
|
||||
}
|
||||
|
||||
const absolutePath = resolve(workspaceRoot, value);
|
||||
assertInsideDir(workspaceRoot, absolutePath, label);
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
export function assertInsideDir(parent, child, label) {
|
||||
if (!isInsideDir(parent, child)) {
|
||||
throw new Error(`${label} must stay inside ${parent}: ${child}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isInsideDir(parent, child) {
|
||||
const relativePath = relative(resolve(parent), resolve(child));
|
||||
|
||||
return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
export function formatWorkspacePath(filePath) {
|
||||
return toPosixPath(relative(workspaceRoot, resolve(filePath)));
|
||||
}
|
||||
|
||||
export function statSafe(filePath) {
|
||||
try {
|
||||
return statSync(filePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function listFiles(directory, options = {}) {
|
||||
if (!existsSync(directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const skipDirectories = new Set(options.skipDirectories ?? []);
|
||||
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && skipDirectories.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = resolve(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFiles(entryPath, options));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function readWebsiteLocales() {
|
||||
const localeSourcePath = resolve(workspaceRoot, 'website/src/i18n/locales.ts');
|
||||
const source = readFileSync(localeSourcePath, 'utf8');
|
||||
const localeMatches = [...source.matchAll(/code:\s*["']([^"']+)["']/g)].map(match => match[1]);
|
||||
|
||||
if (localeMatches.length === 0) {
|
||||
throw new Error(`Unable to read website locales from ${formatWorkspacePath(localeSourcePath)}.`);
|
||||
}
|
||||
|
||||
return localeMatches;
|
||||
}
|
||||
|
||||
export function getPnpmCommand() {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
||||
}
|
||||
|
||||
export function runCommand(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
cwd: options.cwd ?? workspaceRoot,
|
||||
encoding: options.encoding,
|
||||
env: options.env ?? process.env,
|
||||
shell: options.shell ?? false,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
export function runCommandOrExit(command, args, options = {}) {
|
||||
const result = runCommand(command, args, options);
|
||||
|
||||
if (result.status === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const cwd = options.cwd ?? workspaceRoot;
|
||||
const label = options.label ?? `${command} ${args.join(' ')}`;
|
||||
|
||||
console.error(`[scripts] Command failed in ${cwd}: ${label}`);
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
console.error(result.stderr);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
export function toPosixPath(value) {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getPnpmCommand, runCommandOrExit, workspaceRoot } from './package-utils.mjs';
|
||||
|
||||
const rootDir = workspaceRoot;
|
||||
const egsRoot = join(rootDir, 'external/egs-core');
|
||||
|
||||
if (process.env.AHOLO_SKIP_EGS_TYPES === '1') {
|
||||
console.log('[egs-types] Skipping EGS type preparation because AHOLO_SKIP_EGS_TYPES=1.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runCommandOrExit(process.execPath, [join(rootDir, 'scripts/ensure-submodules.mjs')], {
|
||||
cwd: rootDir,
|
||||
label: 'node scripts/ensure-submodules.mjs',
|
||||
});
|
||||
ensureEgsInstall();
|
||||
|
||||
runCommandOrExit(getPnpmCommand(), ['run', '--filter=!@internal/*', '-r', '--if-present', 'build:types:release'], {
|
||||
cwd: egsRoot,
|
||||
label: 'pnpm run --filter=!@internal/* -r --if-present build:types:release',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
console.log('[egs-types] Ready.');
|
||||
|
||||
function ensureEgsInstall() {
|
||||
const requiredFiles = [
|
||||
join(egsRoot, 'node_modules/@internal/tsconfig/index.json'),
|
||||
join(egsRoot, 'node_modules/typescript/lib/tsc.js'),
|
||||
];
|
||||
|
||||
if (requiredFiles.every(file => existsSync(file))) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[egs-types] Installing EGS build dependencies from external/egs-core/pnpm-lock.yaml.');
|
||||
runCommandOrExit(getPnpmCommand(), ['install', '--frozen-lockfile', '--ignore-scripts'], {
|
||||
cwd: egsRoot,
|
||||
label: 'pnpm install --frozen-lockfile --ignore-scripts',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import child_process from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const packages = JSON.parse(
|
||||
child_process.execSync('pnpm list --filter=@manycore/* -r -depth -1 --json', { stdio: 'pipe' }).toString('utf-8'),
|
||||
).filter(item => !item.private);
|
||||
|
||||
// update splat-transform sub packages version.
|
||||
{
|
||||
const splatTransformSubPackages = packages.filter(
|
||||
item =>
|
||||
item.name.startsWith('@manycore/aholo-splat-transform') && item.name !== '@manycore/aholo-splat-transform',
|
||||
);
|
||||
const splatTransformMainPackage = packages.find(item => item.name === '@manycore/aholo-splat-transform');
|
||||
const splatTransformMainPackageJson = JSON.parse(
|
||||
fs.readFileSync(path.resolve(splatTransformMainPackage.path, 'package.json'), 'utf-8'),
|
||||
);
|
||||
for (const p of splatTransformSubPackages) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.resolve(p.path, 'package.json'), 'utf-8'));
|
||||
p.version = splatTransformMainPackageJson.version;
|
||||
packageJson.version = splatTransformMainPackageJson.version;
|
||||
splatTransformMainPackageJson.optionalDependencies[p.name] = splatTransformMainPackageJson.version;
|
||||
fs.writeFileSync(path.resolve(p.path, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8');
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.resolve(splatTransformMainPackage.path, 'package.json'),
|
||||
JSON.stringify(splatTransformMainPackageJson, undefined, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
const publishedPackages = [];
|
||||
|
||||
for (const p of packages) {
|
||||
const cwd = p.path;
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.resolve(cwd, 'package.json'), 'utf-8'));
|
||||
const hiddenBuildCommand = packageJson.scripts?.['.build'];
|
||||
let published = false;
|
||||
try {
|
||||
child_process.execSync(`npm view ${p.name}@${p.version}`, { stdio: 'ignore' });
|
||||
published = true;
|
||||
} catch {
|
||||
// assume not found. should publish.
|
||||
}
|
||||
if (!published) {
|
||||
if (hiddenBuildCommand) {
|
||||
// hidden build command exists, add build commands to call .build
|
||||
packageJson.scripts.build = 'pnpm run .build';
|
||||
fs.writeFileSync(path.resolve(cwd, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8');
|
||||
}
|
||||
// run build command if exists
|
||||
child_process.execSync('pnpm run --if-present build', { stdio: 'inherit', cwd });
|
||||
// cleanup package.json before publish
|
||||
child_process.execSync('npm pkg delete scripts devDependencies', { stdio: 'inherit', cwd });
|
||||
child_process.execSync('npm publish --access public', { stdio: 'inherit', cwd });
|
||||
// restore package.json
|
||||
if (hiddenBuildCommand) {
|
||||
delete packageJson.scripts.build;
|
||||
}
|
||||
fs.writeFileSync(path.resolve(cwd, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8');
|
||||
publishedPackages.push({
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (publishedPackages.length > 0) {
|
||||
console.log('published:');
|
||||
for (const p of publishedPackages) {
|
||||
console.log(`\t${p.name}@${p.version}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import child_process from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { assertInsideDir, formatWorkspacePath, readPackageJson, resolveWorkspacePath } from './package-utils.mjs';
|
||||
|
||||
const [packageName, versionArg] = process.argv.slice(2);
|
||||
|
||||
if (!packageName) {
|
||||
console.error('Usage: node scripts/read-package-changelog.mjs <package-name> [version]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageInfo = JSON.parse(
|
||||
child_process.execSync('pnpm list --filter=@manycore/* -r -depth -1 --json', { stdio: 'pipe' }).toString('utf-8'),
|
||||
).find(item => item.name === packageName);
|
||||
|
||||
const packageRoot = resolveWorkspacePath(packageInfo.path, 'Package root');
|
||||
const packageJson = readPackageJson(packageRoot);
|
||||
const packageVersion = versionArg ?? packageJson.version;
|
||||
|
||||
if (typeof packageVersion !== 'string' || packageVersion.trim() === '') {
|
||||
throw new Error(`Missing package version for ${formatWorkspacePath(packageRoot)}.`);
|
||||
}
|
||||
|
||||
const changelogPath = resolve(packageRoot, 'CHANGELOG.md');
|
||||
assertInsideDir(packageRoot, changelogPath, 'Changelog path');
|
||||
|
||||
if (!existsSync(changelogPath)) {
|
||||
throw new Error(`Missing CHANGELOG.md for ${formatWorkspacePath(packageRoot)}.`);
|
||||
}
|
||||
|
||||
const changelog = readFileSync(changelogPath, 'utf8');
|
||||
const body = readChangelogSection(changelog, packageVersion, formatWorkspacePath(changelogPath));
|
||||
|
||||
process.stdout.write(body);
|
||||
|
||||
function readChangelogSection(changelog, version, changelogLabel) {
|
||||
const versionHeading = new RegExp(`^##\\s+\\[?${escapeRegExp(version)}\\]?(?:\\s.*)?$`, 'm');
|
||||
const match = versionHeading.exec(changelog);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Missing ${version} section in ${changelogLabel}.`);
|
||||
}
|
||||
|
||||
const sectionStart = match.index + match[0].length;
|
||||
const rest = changelog.slice(sectionStart);
|
||||
const nextSection = /^##\s+/m.exec(rest);
|
||||
const body = (nextSection ? rest.slice(0, nextSection.index) : rest).trim();
|
||||
|
||||
if (!body) {
|
||||
throw new Error(`Empty ${version} section in ${changelogLabel}.`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user