chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:18:05 +08:00
commit acd8e21031
297 changed files with 56514 additions and 0 deletions
+349
View File
@@ -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, '\\$&');
}
+465
View File
@@ -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/');
}
+46
View File
@@ -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));
}
+99
View File
@@ -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;
}
+865
View File
@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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();
}
+661
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/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(/&amp;/g, '&');
}
+156
View File
@@ -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, '/');
}
+43
View File
@@ -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',
});
}
+74
View File
@@ -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}`);
}
}
+60
View File
@@ -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, '\\$&');
}