Merge branch 'main' into v2

This commit is contained in:
Vanilagy
2026-07-07 21:00:13 +02:00
317 changed files with 55314 additions and 5009 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
set -e
# This script must be executed via `npm run build`
# Clear the stuff from last build
rm -rf dist
rm -rf packages/mp3-encoder/dist
rm -rf packages/ac3/dist
rm -rf packages/aac-encoder/dist
rm -rf packages/flac-encoder/dist
rm -rf packages/prores/dist
rm -rf packages/server/dist
# Ensure license headers on all source files
tsx scripts/ensure-license-headers.ts
# Type check & generate .js and .d.ts files
tsc -p src --stripInternal false # Don't strip internals since the packages may use them
tsc -p packages/mp3-encoder
tsc -p packages/ac3
tsc -p packages/aac-encoder
tsc -p packages/flac-encoder
tsc -p packages/prores
tsc -p packages/server
# Generate the root again, now with internals properly stripped
rm -rf dist
tsc -p src
# So that the resulting files use valid ESM imports with file extension. This only runs for the core Mediabunny as only
# it ships the individual files to npm (for tree shaking, because it's large)
npm run fix-build-import-paths
# Creates bundles for all packages
tsx scripts/bundle.ts
# Declaration file rollup and checks
api-extractor run
api-extractor run -c packages/mp3-encoder/api-extractor.json
api-extractor run -c packages/ac3/api-extractor.json
api-extractor run -c packages/aac-encoder/api-extractor.json
api-extractor run -c packages/flac-encoder/api-extractor.json
api-extractor run -c packages/prores/api-extractor.json
api-extractor run -c packages/server/api-extractor.json
# Checks that all symbols are documented
tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts
tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts
tsx scripts/check-docblocks.ts packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts
tsx scripts/check-docblocks.ts packages/prores/dist/mediabunny-prores.d.ts
tsx scripts/check-docblocks.ts packages/server/dist/mediabunny-server.d.ts
# Checks that API docs are generatable
npm run docs:generate -- --dry
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
echo 'export as namespace MediabunnyAc3;' >> packages/ac3/dist/mediabunny-ac3.d.ts
echo 'export as namespace MediabunnyAacEncoder;' >> packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts
echo 'export as namespace MediabunnyFlacEncoder;' >> packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts
echo 'export as namespace MediabunnyProres;' >> packages/prores/dist/mediabunny-prores.d.ts
echo 'export as namespace MediabunnyServer;' >> packages/server/dist/mediabunny-server.d.ts
+152 -3
View File
@@ -11,6 +11,7 @@ const createVariants = async (
umdExtension: string,
specificUmdConfig: esbuild.BuildOptions = {},
specificEsmConfig: esbuild.BuildOptions = {},
nodeUmdVariant = false,
) => {
const baseConfig: esbuild.BuildOptions = {
entryPoints: [entryPoint],
@@ -22,7 +23,7 @@ const createVariants = async (
},
banner: {
js: `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -73,7 +74,19 @@ const createVariants = async (
minify: true,
});
return [umdVariant, esmVariant, umdMinifiedVariant, esmMinifiedVariant];
const variants = [umdVariant, esmVariant, umdMinifiedVariant, esmMinifiedVariant];
if (nodeUmdVariant) {
const nodeVariant = await esbuild.context({
...umdConfig,
...specificUmdConfig,
outfile: `${outfileBase}.node.${umdExtension}`,
platform: 'node', // This is different
});
variants.push(nodeVariant);
}
return variants;
};
const mediabunnyVariants = await createVariants(
@@ -81,13 +94,16 @@ const mediabunnyVariants = await createVariants(
'Mediabunny',
'dist/bundles/mediabunny',
'cjs',
undefined,
undefined,
true,
);
const mp3EncoderVariants = await createVariants(
'packages/mp3-encoder/src/index.ts',
'MediabunnyMp3Encoder',
'packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
@@ -114,9 +130,142 @@ const mp3EncoderVariants = await createVariants(
},
);
const ac3Variants = await createVariants(
'packages/ac3/src/index.ts',
'MediabunnyAc3',
'packages/ac3/dist/bundles/mediabunny-ac3',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
mediabunny: 'Mediabunny',
}),
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
{
external: ['mediabunny'],
plugins: [
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
);
const aacEncoderVariants = await createVariants(
'packages/aac-encoder/src/index.ts',
'MediabunnyAacEncoder',
'packages/aac-encoder/dist/bundles/mediabunny-aac-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
mediabunny: 'Mediabunny',
}),
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
{
external: ['mediabunny'],
plugins: [
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
);
const flacEncoderVariants = await createVariants(
'packages/flac-encoder/src/index.ts',
'MediabunnyFlacEncoder',
'packages/flac-encoder/dist/bundles/mediabunny-flac-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
mediabunny: 'Mediabunny',
}),
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
{
external: ['mediabunny'],
plugins: [
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
);
const proresVariants = await createVariants(
'packages/prores/src/index.ts',
'MediabunnyProres',
'packages/prores/dist/bundles/mediabunny-prores',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
mediabunny: 'Mediabunny',
}),
],
},
{
external: ['mediabunny'],
platform: 'node', // To retain the Node imports
},
);
const serverVariants = await createVariants(
'packages/server/src/index.ts',
'MediabunnyServer',
'packages/server/dist/bundles/mediabunny-server',
'cjs',
{
platform: 'node',
packages: 'external',
external: ['mediabunny'],
},
{
platform: 'node',
packages: 'external',
external: ['mediabunny'],
},
);
const contexts = [
...mediabunnyVariants,
...mp3EncoderVariants,
...ac3Variants,
...aacEncoderVariants,
...flacEncoderVariants,
...proresVariants,
...serverVariants,
];
if (process.argv[2] === '--watch') {
+2 -8
View File
@@ -16,7 +16,6 @@ const checkDocblocks = (filePath: string) => {
if (
ts.isInterfaceDeclaration(node)
|| ts.isClassDeclaration(node)
|| ts.isConstructorDeclaration(node)
|| ts.isMethodDeclaration(node)
|| ts.isGetAccessorDeclaration(node)
|| ts.isSetAccessorDeclaration(node)
@@ -24,6 +23,7 @@ const checkDocblocks = (filePath: string) => {
|| ts.isFunctionDeclaration(node)
|| ts.isTypeAliasDeclaration(node)
|| ts.isEnumDeclaration(node)
|| ts.isEnumMember(node)
|| ts.isPropertySignature(node)
|| ts.isMethodSignature(node)
|| ts.isVariableStatement(node)
@@ -61,13 +61,7 @@ const checkDocblocks = (filePath: string) => {
let name = 'anonymous';
const kind = ts.SyntaxKind[node.kind].replace(/Declaration|Statement/g, '').toLowerCase();
if (ts.isConstructorDeclaration(node)) {
// For constructors, use the parent class name
const parent = node.parent;
if (ts.isClassDeclaration(parent) && parent.name) {
name = parent.name.text;
}
} else if ('name' in node && node.name) {
if ('name' in node && node.name) {
if (ts.isIdentifier(node.name)) {
name = node.name.text;
} else if ('getText' in node.name) {
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
set -e
rm -rf dist/modules
tsc -p src --stripInternal false
rm -rf packages/mp3-encoder/dist/modules
tsc -p packages/mp3-encoder
rm -rf packages/ac3/dist/modules
tsc -p packages/ac3
rm -rf packages/aac-encoder/dist/modules
tsc -p packages/aac-encoder
rm -rf packages/flac-encoder/dist/modules
tsc -p packages/flac-encoder
rm -rf packages/prores/dist/modules
tsc -p packages/prores
rm -rf packages/server/dist/modules
tsc -p packages/server
tsc -p tsconfig.vitest.json --noEmit
tsc -p scripts --noEmit
tsc -p tsconfig.vite.json --noEmit
+7 -2
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LICENSE_HEADER = `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -35,8 +35,13 @@ const checkDirectory = (dirPath: string) => {
};
checkDirectory(path.join(__dirname, '..', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src'));
checkDirectory(path.join(__dirname, '..', 'shared'));
checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'ac3', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'flac-encoder', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'aac-encoder', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'prores', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'server', 'src'));
if (missingFiles.length > 0) {
console.error('Files missing license header:');
+5 -3
View File
@@ -28,7 +28,7 @@ export default function Worker() {
const inlineWorkerFunctionCode = `
export default async function inlineWorker(scriptText) {
if (typeof Worker !== 'undefined' && typeof Bun === 'undefined') {
// Browser, Deno
// Browser, Deno (Deno can't do dynamic import of worker_threads)
const blob = new Blob([scriptText], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
@@ -42,8 +42,7 @@ export default async function inlineWorker(scriptText) {
try {
Worker = (await import('worker_threads')).Worker;
} catch {
const workerModule = 'worker_threads';
Worker = require(workerModule).Worker;
Worker = require('worker_threads').Worker;
}
const worker = new Worker(scriptText, { eval: true });
@@ -56,6 +55,9 @@ export default async function inlineWorker(scriptText) {
build.onResolve({ filter: /^__inline-worker$/ }, ({ path }) => {
return { path, namespace: 'inline-worker' };
});
build.onResolve({ filter: /^worker_threads$/ }, ({ path }) => {
return { path, external: true }; // Keep it in the bundle
});
build.onLoad({ filter: /.*/, namespace: 'inline-worker' }, () => {
return { contents: inlineWorkerFunctionCode, loader: 'js' };
});
+265 -134
View File
@@ -48,11 +48,13 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
// Extract special fields
const headingText = apiConfig['heading'] || 'API Reference';
const introText = apiConfig['intro'];
const indexDescription = apiConfig['description'];
// Create a copy without the special fields for group processing
const groupConfig = { ...apiConfig };
delete groupConfig['heading'];
delete groupConfig['intro'];
delete groupConfig['description'];
// Clear and recreate output directory (skip if dry run)
if (!dry) {
@@ -67,6 +69,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const classHierarchy = new Map<string, string[]>(); // Maps parent class to array of subclasses
const classInstances = new Map<string, string[]>(); // Maps class name to array of instance variable names
const hasDeprecatedTag = (node: ts.Node): boolean => {
return ts.getJSDocTags(node).some(tag => tag.tagName.text === 'deprecated');
};
const collectExportedTypes = (module: ts.Symbol, visited = new Set<ts.Symbol>()): void => {
if (visited.has(module)) return;
visited.add(module);
@@ -120,15 +126,22 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
if (!declaration) return;
// If it's a reexport, follow it recursively
// If it's a reexport, resolve it to the underlying symbol (following chains of aliases,
// e.g. a re-export of a re-export) rather than recursing into the whole module it lives in
// -- `getExportsOfModule` above already gives us one entry per exported symbol, so a module
// recursion here would revisit (and duplicate) every other export of that module too.
if (exportSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
let aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
while (aliasedSymbol.flags & ts.SymbolFlags.Alias) {
aliasedSymbol = typeChecker.getAliasedSymbol(aliasedSymbol);
}
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
if (aliasedDeclaration) {
const sourceFile = aliasedDeclaration.getSourceFile();
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
if (moduleSymbol) {
symbols.push(...getAllExportedSymbols(moduleSymbol, visited));
// Push the aliased symbol (not the alias) so downstream sees the real
// declaration and its JSDoc rather than the empty ExportSpecifier.
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
if (hasPublicTag) {
symbols.push(aliasedSymbol);
}
}
}
@@ -333,11 +346,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
if (linkText) {
// If custom link text is provided, always use it.
displayText = linkText.trim();
} else if (memberName) {
// If it's a member link, default the text to just the member name.
} else if (memberName && typeName === currentTypeName) {
// Member link on the current type: just the member name.
displayText = `\`${memberName}\``;
} else {
// Otherwise, it's a type link, so use the full type name.
// Type link, or member link on another type: use the full target.
displayText = `\`${cleanTarget}\``;
}
@@ -372,6 +385,34 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
});
};
// Helper to build a "> **Deprecated.** ..." notice from a node's @deprecated JSDoc tag.
// Returns an empty string if the node has no @deprecated tag.
const getDeprecationNotice = (node: ts.Node, currentTypeName?: string): string => {
const tag = ts.getJSDocTags(node).find(t => t.tagName.text === 'deprecated');
if (!tag) {
return '';
}
let text = '';
if (tag.comment) {
if (typeof tag.comment === 'string') {
text = processLinkTags(tag.comment.trim(), currentTypeName);
} else {
// comment is a NodeArray of JSDocComment elements (text + inline tags)
const raw = tag.comment.map((part) => {
if (ts.isJSDocLinkLike(part)) {
const linkName = part.name?.getText() ?? '';
const linkText = part.text?.trim() ?? '';
// Reconstruct as {@link Name text}
return `{@link ${linkName}${linkText ? ' ' + linkText : ''}}`;
}
return part.text ?? '';
}).join('');
text = processLinkTags(raw.trim(), currentTypeName);
}
}
return text ? `> **Deprecated.** ${text}\n\n` : '> **Deprecated.**\n\n';
};
// Helper to extract linked types from {@link} tags in text
const extractLinkedTypes = (text: string): string[] => {
if (!text) return [];
@@ -622,7 +663,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
// Only process symbols with @public tag
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
if (!hasPublicTag) return;
if (!hasPublicTag) {
return;
}
// Check for @group tag (handle re-exports by looking at the original declaration)
let targetDeclaration = declaration;
@@ -646,7 +689,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
}
// Check if it's a supported type
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration)) {
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration) || ts.isEnumDeclaration(declaration)) {
// Supported types - continue processing
} else {
// Unsupported type - throw error with type info
@@ -660,14 +703,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const variableName = declaration.name.getText();
// Get variable description from JSDoc
const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0];
let description = '';
if (jsDocComment && ts.isJSDoc(jsDocComment)) {
const commentText = jsDocComment.comment;
if (typeof commentText === 'string') {
description = processLinkTags(commentText.trim(), variableName);
}
}
const description = extractJsDocDescription(declaration, {
tagHandling: 'filterAll',
transform: text => processLinkTags(text, variableName),
});
// Check if it's a function type
const variableType = typeChecker.getTypeAtLocation(declaration);
@@ -710,7 +749,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
? `${variableName}(\n${params.join(',\n')},\n): ${returnType};`
: `${variableName}(): ${returnType};`;
let markdown = `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
const deprecationNotice = getDeprecationNotice(declaration, variableName);
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n${deprecationNotice}\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
// Find referenced types in all parameters and return type
const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
@@ -724,7 +764,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
}
} else {
// Handle regular variables
let markdown = `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`;
const deprecationNotice = getDeprecationNotice(declaration, variableName);
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`;
const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined';
const variableDefinition = `const ${variableName} = ${variableValue};`;
markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``;
@@ -742,6 +783,43 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
return;
}
// Handle enum declarations separately
if (ts.isEnumDeclaration(declaration)) {
const enumName = declaration.name.text;
const description = extractJsDocDescription(declaration, {
tagHandling: 'filterAll',
transform: text => processLinkTags(text, enumName),
});
const order = symbolOrderMap.get(enumName);
if (order === undefined) {
throw new Error(`Symbol '${enumName}' not found in entry files export order`);
}
indexEntries.push({ name: enumName, type: 'Enum', group: groupName, order });
// Reconstruct the enum body so each member keeps its value and description
const memberLines = declaration.members.map((member) => {
const memberName = member.name.getText();
const initializer = member.initializer ? ` = ${member.initializer.getText()}` : '';
const memberDescription = extractJsDocDescription(member, {
tagHandling: 'filterAll',
transform: text => processLinkTags(text, enumName),
});
const commentLine = memberDescription ? `\t/** ${memberDescription} */\n` : '';
return `${commentLine}\t${memberName}${initializer},`;
});
const enumDefinition = `enum ${enumName} {\n${memberLines.join('\n')}\n}`;
const deprecationNotice = getDeprecationNotice(declaration, enumName);
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Enum" />\n\n# ${enumName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`;
markdown += `\`\`\`ts\n${enumDefinition}\n\`\`\``;
generatedDocs.set(enumName, markdown);
return;
}
{
const className = declaration.name.text;
const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword);
@@ -765,48 +843,18 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const events: string[] = [];
const methods: string[] = [];
const staticMethods: string[] = [];
const deprecatedProperties: string[] = [];
const deprecatedMethods: string[] = [];
let constructor: string | null = null;
let extendsClause = '';
let implementsClause = '';
let typeParameters: string | null = null;
// Get class description from JSDoc (or from superclass if none)
let description = '';
const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0];
if (jsDocComment && ts.isJSDoc(jsDocComment)) {
// First try to get the comment from the parsed JSDoc
const commentText = jsDocComment.comment;
if (typeof commentText === 'string' && commentText.trim()) {
description = processLinkTags(commentText.trim(), className);
} else {
// If no comment text, extract from raw source text
const sourceFile = declaration.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDocComment.getStart();
const end = jsDocComment.getEnd();
const rawJsDoc = sourceText.substring(start, end);
// Extract the content between /** and */
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @tags but keep the description
const lines = content.split('\n');
const descLines = lines.filter(line => !line.trim().startsWith('@'));
const rawDesc = descLines.join('\n').trim();
if (rawDesc) {
description = processLinkTags(rawDesc, className);
}
}
}
}
let description = extractJsDocDescription(declaration, {
tagHandling: 'filterAll',
transform: text => processLinkTags(text, className),
});
// If no description, check superclass (only for classes/interfaces)
if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
@@ -915,37 +963,12 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
// Helper to get JSDoc description with superclass fallback (recursive)
const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => {
const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
if (jsDoc && ts.isJSDoc(jsDoc)) {
// First try the parsed comment
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
return processLinkTags(jsDoc.comment.trim(), className);
} else {
// If no parsed comment, extract from raw source (same logic as class descriptions)
const sourceFile = member.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDoc.getStart();
const end = jsDoc.getEnd();
const rawJsDoc = sourceText.substring(start, end);
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @tags but keep the description
const lines = content.split('\n');
const descLines = lines.filter(line => !line.trim().startsWith('@'));
const rawDesc = descLines.join('\n').trim();
if (rawDesc) {
return processLinkTags(rawDesc, className);
}
}
}
const ownDescription = extractJsDocDescription(member, {
tagHandling: 'filterAll',
transform: text => processLinkTags(text, className),
});
if (ownDescription) {
return ownDescription;
}
// Recursively check superclass hierarchy for this member's documentation
@@ -1020,6 +1043,30 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const hasInternalTag = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'internal');
if (hasInternalTag) return;
const isDeprecatedMember = hasDeprecatedTag(member);
const addDeprecationNotice = (content: string) => {
// Insert the deprecation notice right after the heading line
const headingEnd = content.indexOf('\n');
return content.slice(0, headingEnd) + '\n\n' + getDeprecationNotice(member, className) + content.slice(headingEnd + 1);
};
const pushProperty = (content: string) => {
if (isDeprecatedMember) {
deprecatedProperties.push(addDeprecationNotice(content));
} else {
properties.push(content);
}
};
const pushMethod = (content: string) => {
if (isDeprecatedMember) {
deprecatedMethods.push(addDeprecationNotice(content));
} else {
methods.push(content);
}
};
if (ts.isConstructorDeclaration(member) && !isAbstract) {
// Skip private constructors
const isPrivate = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
@@ -1230,7 +1277,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
if (isEventHandler) {
events.push(propertyContent);
} else {
properties.push(propertyContent);
pushProperty(propertyContent);
}
} else if (ts.isGetAccessorDeclaration(member) && member.name) {
const name = member.name.getText();
@@ -1263,7 +1310,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
references.forEach(ref => addUsage(ref, className, name, 'property'));
const inheritedBadge = '';
properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
} else if (ts.isSetAccessorDeclaration(member) && member.name) {
const name = member.name.getText();
const param = member.parameters[0];
@@ -1286,7 +1333,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
references.forEach(ref => addUsage(ref, className, name, 'property'));
const inheritedBadge = '';
properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
} else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) {
const name = member.name.getText();
const isStatic = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword);
@@ -1384,7 +1431,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
if (isStatic) {
staticMethods.push(methodContent);
} else {
methods.push(methodContent);
pushMethod(methodContent);
}
}
};
@@ -1496,22 +1543,29 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
}
}
// Sort properties and methods alphabetically (but not for type aliases - keep source order)
// Sort properties and methods alphabetically, but with bracketed names like
// `[Symbol.dispose]()` always at the bottom. Type aliases keep source order.
const compareMemberNames = (a: string, b: string) => {
const nameA = a.match(/### `([^`]+)`/)?.[1] ?? '';
const nameB = b.match(/### `([^`]+)`/)?.[1] ?? '';
const aIsBracket = nameA.startsWith('[');
const bIsBracket = nameB.startsWith('[');
if (aIsBracket !== bIsBracket) {
return aIsBracket ? 1 : -1;
}
return nameA.localeCompare(nameB);
};
if (!ts.isTypeAliasDeclaration(declaration)) {
properties.sort((a, b) => {
const nameA = a.match(/### (.+)/)?.[1] || '';
const nameB = b.match(/### (.+)/)?.[1] || '';
return nameA.localeCompare(nameB);
});
properties.sort(compareMemberNames);
methods.sort(compareMemberNames);
}
staticMethods.sort((a, b) => {
const nameA = a.match(/### (.+)/)?.[1] || '';
const nameB = b.match(/### (.+)/)?.[1] || '';
return nameA.localeCompare(nameB);
});
staticMethods.sort(compareMemberNames);
deprecatedProperties.sort(compareMemberNames);
deprecatedMethods.sort(compareMemberNames);
let markdown = '';
let markdown = buildFrontmatter(description);
// Add VPBadge import and badge for all types
markdown += `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n`;
@@ -1526,7 +1580,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
markdown += `<VPBadge type="info" text="Interface" />\n\n`;
}
markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`;
const deprecationNotice = getDeprecationNotice(declaration, className);
markdown += `# ${className}\n\n${deprecationNotice}${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`;
// Add subclasses section for classes that have subclasses
if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) {
@@ -1684,6 +1739,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`;
}
const deprecated = [...deprecatedProperties, ...deprecatedMethods];
if (deprecated.length > 0) {
markdown += `\n## Deprecated\n\n${deprecated.join('\n\n')}\n`;
}
generatedDocs.set(className, markdown);
}
});
@@ -1788,7 +1848,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
throw new Error(`Groups found in code but not in API config: ${missingGroups.join(', ')}`);
}
let indexMarkdown = `# ${headingText}\n\n`;
let indexMarkdown = `${buildFrontmatter(indexDescription ?? '')}# ${headingText}\n\n`;
// Add intro text if provided
if (introText) {
@@ -1840,43 +1900,114 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
}
};
// Helper to get the full description text from a JSDoc comment, handling inline tags.
const getFullJSDocDescription = (node: ts.Node): string => {
// Shared helper for extracting a JSDoc description, handling inline tags via raw-source fallback.
// - `tagHandling: 'stopAtFirst'` matches the behavior used by property-level descriptions: if any
// @-tag line is encountered, all subsequent lines are dropped (including any trailing description).
// - `tagHandling: 'filterAll'` matches the behavior used by class/variable top-level descriptions:
// every @-tag line is filtered out individually, preserving interleaved description lines.
// - `transform` is applied to the final non-empty description (e.g. to process {@link} tags).
const extractJsDocDescription = (
node: ts.Node,
opts: { tagHandling: 'stopAtFirst' | 'filterAll'; transform?: (text: string) => string },
): string => {
const jsDoc = ts.getJSDocCommentsAndTags(node)[0];
if (!jsDoc || !ts.isJSDoc(jsDoc)) return '';
// If it's a simple string, just return it.
if (typeof jsDoc.comment === 'string') {
return jsDoc.comment.trim();
if (!jsDoc || !ts.isJSDoc(jsDoc)) {
return '';
}
// If it's a structured comment (with inline tags), get the raw text.
const transform = opts.transform ?? ((t: string) => t);
// If the parsed comment is a non-empty string (no inline tags), use it directly.
// When the parsed comment is an empty string or a structured NodeArray (inline tags like
// {@link}), fall through to raw-source extraction. For empty-string comments this is safe:
// the raw block can only contain @-tags, so raw extraction also yields empty.
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
return transform(jsDoc.comment.trim());
}
// Structured comment (contains inline tags like {@link}); extract description from raw source.
const sourceFile = node.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDoc.getStart();
const end = jsDoc.getEnd();
const rawJsDoc = sourceText.substring(start, end);
const rawJsDoc = sourceText.substring(jsDoc.getStart(), jsDoc.getEnd());
// Extract the content between /** and */
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @-tags (like @param, @returns) to keep only the main description
const lines = content.split('\n');
const descLines = [];
for (const line of lines) {
if (line.trim().startsWith('@')) break; // Stop at the first @-tag
descLines.push(line);
}
return descLines.join('\n').trim();
if (!match || !match[1]) {
return '';
}
return '';
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
const lines = content.split('\n');
let descLines: string[];
if (opts.tagHandling === 'stopAtFirst') {
descLines = [];
for (const line of lines) {
if (line.trim().startsWith('@')) {
break;
}
descLines.push(line);
}
} else {
// Skip @tag lines and their continuation lines (continuation ends at a blank line
// or the next @tag). Without this, multi-line tags like @deprecated bleed into the
// description.
descLines = [];
let inTagContinuation = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('@')) {
inTagContinuation = true;
continue;
}
if (inTagContinuation) {
if (trimmed === '') {
inTagContinuation = false;
}
continue;
}
descLines.push(line);
}
}
const rawDesc = descLines.join('\n').trim();
if (!rawDesc) {
return '';
}
return transform(rawDesc);
};
// Helper to get the full description text from a JSDoc comment, handling inline tags.
const getFullJSDocDescription = (node: ts.Node): string => {
return extractJsDocDescription(node, { tagHandling: 'stopAtFirst' });
};
// Convert a markdown description (possibly multi-paragraph, with **bold**, `code`,
// and [text](link) from processed @link tags) into a single-line plain-text string
// suitable for the `description` field in YAML frontmatter.
const descriptionToFrontmatter = (description: string): string => {
return description
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\s+/g, ' ')
.trim();
};
const buildFrontmatter = (description: string): string => {
if (!description) {
return '';
}
const cleaned = descriptionToFrontmatter(description);
if (!cleaned) {
return '';
}
const yamlValue = `"${cleaned.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return `---\ndescription: ${yamlValue}\n---\n\n`;
};
const main = () => {
+40
View File
@@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
// After `npm version --workspaces` bumps each package's version, this rewrites dependency ranges that point at sibling
// workspaces so they follow along.
const root = path.join(import.meta.dirname, '..');
const workspaceDirs = ['.', ...fs.readdirSync(path.join(root, 'packages')).map(x => `packages/${x}`)]
.filter(dir => fs.existsSync(path.join(root, dir, 'package.json')));
type Manifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
};
const manifests = workspaceDirs.map((dir) => {
const filePath = path.join(root, dir, 'package.json');
return { filePath, json: JSON.parse(fs.readFileSync(filePath, 'utf8')) as Manifest };
});
const versions = new Map(manifests.map(({ json }) => [json.name, json.version]));
for (const { filePath, json } of manifests) {
let changed = false;
for (const name of Object.keys(json.dependencies ?? {})) {
const version = versions.get(name);
if (version && json.dependencies![name] !== `^${version}`) {
json.dependencies![name] = `^${version}`;
changed = true;
}
}
if (changed) {
fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
console.log(`Synced workspace dependency ranges in ${path.relative(root, filePath)}`);
}
}