From 4263be56e66beb74d24cbcca1b6c1ce8ae0753df Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Sun, 14 Jun 2026 01:39:47 +0200 Subject: [PATCH 1/4] build(docs-gen): add ast raw data extractor --- infra/docs-gen/EXTRACTION_PIPELINE.md | 45 ++ infra/docs-gen/README.md | 47 ++ infra/docs-gen/package.json | 7 +- infra/docs-gen/src/config.mjs | 59 ++ infra/docs-gen/src/extract-extension-data.mjs | 133 +++++ infra/docs-gen/src/extractor/actions.test.mjs | 25 + infra/docs-gen/src/extractor/ast.mjs | 504 ++++++++++++++++++ infra/docs-gen/src/extractor/cli.test.mjs | 17 + infra/docs-gen/src/extractor/constants.mjs | 154 ++++++ .../docs-gen/src/extractor/constants.test.mjs | 47 ++ infra/docs-gen/src/extractor/examples.mjs | 149 ++++++ .../docs-gen/src/extractor/examples.test.mjs | 46 ++ infra/docs-gen/src/extractor/index.mjs | 113 ++++ infra/docs-gen/src/extractor/keymaps.test.mjs | 64 +++ infra/docs-gen/src/extractor/markdown-gen.mjs | 87 +++ infra/docs-gen/src/extractor/options.mjs | 235 ++++++++ infra/docs-gen/src/extractor/options.test.mjs | 79 +++ infra/docs-gen/src/extractor/output.mjs | 26 + infra/docs-gen/src/extractor/presets.mjs | 67 +++ infra/docs-gen/src/extractor/scan.mjs | 195 +++++++ infra/docs-gen/src/extractor/schema.test.mjs | 19 + infra/docs-gen/src/extractor/source-files.mjs | 63 +++ infra/docs-gen/src/generate-docs.mjs | 59 +- infra/docs-gen/src/logger.mjs | 33 ++ infra/docs-gen/src/utils.mjs | 42 ++ pnpm-lock.yaml | 3 + 26 files changed, 2277 insertions(+), 41 deletions(-) create mode 100644 infra/docs-gen/EXTRACTION_PIPELINE.md create mode 100644 infra/docs-gen/README.md create mode 100644 infra/docs-gen/src/config.mjs create mode 100644 infra/docs-gen/src/extract-extension-data.mjs create mode 100644 infra/docs-gen/src/extractor/actions.test.mjs create mode 100644 infra/docs-gen/src/extractor/ast.mjs create mode 100644 infra/docs-gen/src/extractor/cli.test.mjs create mode 100644 infra/docs-gen/src/extractor/constants.mjs create mode 100644 infra/docs-gen/src/extractor/constants.test.mjs create mode 100644 infra/docs-gen/src/extractor/examples.mjs create mode 100644 infra/docs-gen/src/extractor/examples.test.mjs create mode 100644 infra/docs-gen/src/extractor/index.mjs create mode 100644 infra/docs-gen/src/extractor/keymaps.test.mjs create mode 100644 infra/docs-gen/src/extractor/markdown-gen.mjs create mode 100644 infra/docs-gen/src/extractor/options.mjs create mode 100644 infra/docs-gen/src/extractor/options.test.mjs create mode 100644 infra/docs-gen/src/extractor/output.mjs create mode 100644 infra/docs-gen/src/extractor/presets.mjs create mode 100644 infra/docs-gen/src/extractor/scan.mjs create mode 100644 infra/docs-gen/src/extractor/schema.test.mjs create mode 100644 infra/docs-gen/src/extractor/source-files.mjs create mode 100644 infra/docs-gen/src/logger.mjs create mode 100644 infra/docs-gen/src/utils.mjs diff --git a/infra/docs-gen/EXTRACTION_PIPELINE.md b/infra/docs-gen/EXTRACTION_PIPELINE.md new file mode 100644 index 00000000..077cbb5f --- /dev/null +++ b/infra/docs-gen/EXTRACTION_PIPELINE.md @@ -0,0 +1,45 @@ +# Extension Extraction Pipeline + +```mermaid +flowchart TD + CLI["extract-extension-data.mjs
Parse CLI options"] --> Extractor["ExtensionExtractor
src/extractor/index.mjs"] + Extractor --> Categories["Extension categories
src/config.mjs"] + Categories --> Collect["collectExtensionRefs()
Collect all extension dirs"] + Collect --> Filter["filterExtensionRefs()
Apply blacklist and --only"] + Filter --> Scan["scanExtension()
src/extractor/scan.mjs"] + + Scan --> FieldConfig["Docs field config
EXTENSION_DOC_FIELD_CONFIG"] + Scan --> Files["readExtensionSources()
src/utils.mjs"] + Files --> Selectors["Source file selectors
src/extractor/source-files.mjs"] + Selectors --> SourceText["Production source text"] + Selectors --> TestFiles["Test files"] + Selectors --> SerializerFiles["Serializer and Specs files"] + + SourceText --> Constants["extractConstants()
src/extractor/constants.mjs"] + SourceText --> AstScanners["AST source scanners
src/extractor/ast.mjs"] + SourceText --> Options["Option declarations
src/extractor/options.mjs"] + SerializerFiles --> SerializerHints["Serializer hints"] + TestFiles --> MarkupExamples["Markup examples
src/extractor/examples.mjs"] + + Constants --> Schema["Schema names
nodes and marks"] + AstScanners --> ExtractedFields["Actions, keymaps, input rules,
plugins, md plugins"] + Options --> ExtractedFields + FieldConfig --> IR["Extension IR record"] + Schema --> IR + ExtractedFields --> IR + SerializerHints --> IR + MarkupExamples --> IR + + Extractor --> Presets["Preset membership
src/extractor/presets.mjs"] + Presets --> IR + IR --> JsonOut["extensions.json
src/extractor/output.mjs"] + IR --> MarkdownOut["raw/*.md
output.mjs -> markdown-gen.mjs"] +``` + +The extractor keeps orchestration and parsing separate: + +- `index.mjs` collects all extension directories, filters them by blacklist and `--only`, and decides when output is written. +- `scan.mjs` builds one extension record from source files and parser results. +- `source-files.mjs` owns file selection rules. +- `ast.mjs`, `options.mjs`, `examples.mjs`, and `constants.mjs` own TypeScript AST parsing details. +- `output.mjs` and `markdown-gen.mjs` own generated artifacts. diff --git a/infra/docs-gen/README.md b/infra/docs-gen/README.md new file mode 100644 index 00000000..d0e44334 --- /dev/null +++ b/infra/docs-gen/README.md @@ -0,0 +1,47 @@ +# Docs Generator + +`infra/docs-gen` contains tooling for two documentation flows: + +- building Diplodoc input from `docs/*.md`; +- extracting raw extension metadata from `packages/editor/src/extensions`. + +## Commands + +- `pnpm --filter @markdown-editor/docs-gen run build` creates `tmp/docs-src` and builds `dist/docs`. +- `pnpm --filter @markdown-editor/docs-gen run extract` creates `tmp/docs-gen/extensions.json` and `tmp/docs-gen/raw/*.md`. +- `pnpm --filter @markdown-editor/docs-gen run test` runs the colocated Node unit tests. + +## Files + +- `package.json` defines the local docs-gen package, scripts, and Nx target. +- `EXTRACTION_PIPELINE.md` documents the raw extension extraction flow with a Mermaid diagram. +- `src/config.mjs` stores shared paths, extension categories, extension blacklist entries, raw docs field sources, preset definitions, and generator constants. +- `src/extract-extension-data.mjs` provides the CLI entry point for raw extension extraction. +- `src/generate-docs.mjs` builds the Diplodoc source tree from repository markdown files. +- `src/logger.mjs` contains the small console logger used by docs-gen CLIs. +- `src/utils.mjs` contains filesystem helpers shared by the build and extraction flows. + +## Extractor Files + +- `src/extractor/index.mjs` contains `ExtensionExtractor`, the high-level orchestrator that scans extension categories, enriches records with presets, and writes output. +- `src/extractor/scan.mjs` scans one filtered extension directory and assembles the raw extension IR record from `EXTENSION_DOC_FIELD_CONFIG`. +- `src/extractor/source-files.mjs` selects source, spec, serializer, root index, specs index, and test files from an extension directory. +- `src/extractor/output.mjs` writes extracted JSON and raw Markdown artifacts. +- `src/extractor/markdown-gen.mjs` renders one raw extension Markdown file from extracted metadata. +- `src/extractor/presets.mjs` parses editor preset files and resolves inherited preset membership. +- `src/extractor/constants.mjs` extracts string constants, enum values, object scalar members, and resolves references between them. +- `src/extractor/options.mjs` extracts extension option fields from local TypeScript option declarations. +- `src/extractor/examples.mjs` extracts serializer test markup examples from `same(...)` calls and simple local string expressions. +- `src/extractor/ast.mjs` contains TypeScript AST helpers and the source scanners for schema names, actions, keymaps, plugins, input rules, markdown-it plugins, and serializer hints. +- `src/extractor/actions.test.mjs` covers action extraction behavior. +- `src/extractor/cli.test.mjs` covers extraction CLI argument parsing. +- `src/extractor/constants.test.mjs` covers constant extraction and reference resolution behavior. +- `src/extractor/examples.test.mjs` covers markup example extraction behavior. +- `src/extractor/keymaps.test.mjs` covers keymap extraction behavior. +- `src/extractor/options.test.mjs` covers option declaration extraction behavior. + +## Output + +- `tmp/docs-gen/extensions.json` is the machine-readable raw IR for all extracted extensions. +- `tmp/docs-gen/raw/*.md` is the raw Markdown view of each extension record. +- `tmp/docs-src` is the generated Diplodoc input tree used by the docs build. diff --git a/infra/docs-gen/package.json b/infra/docs-gen/package.json index de6aa64c..404f34ac 100644 --- a/infra/docs-gen/package.json +++ b/infra/docs-gen/package.json @@ -14,9 +14,12 @@ } }, "scripts": { - "build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs" + "build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs", + "extract": "node src/extract-extension-data.mjs", + "test": "node --test src/extractor/*.test.mjs" }, "dependencies": { - "@diplodoc/cli": "5.43.0" + "@diplodoc/cli": "5.43.0", + "typescript": "catalog:ts" } } diff --git a/infra/docs-gen/src/config.mjs b/infra/docs-gen/src/config.mjs new file mode 100644 index 00000000..b27da08a --- /dev/null +++ b/infra/docs-gen/src/config.mjs @@ -0,0 +1,59 @@ +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +export const DOCS_DIR = join(REPO_ROOT, 'docs'); +export const DOCS_SRC_DIR = join(REPO_ROOT, 'tmp/docs-src'); +export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); +export const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); + +export const GITHUB_RAW_RE = + /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; +export const HEADER_RE = /^#{5}\s+(.+)$/; + +export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; + +export const INTERNAL_EXTENSION_BLACKLIST = [ + 'BaseInputRules', + 'BaseKeymap', + 'BaseStyles', + 'ReactRenderer', + 'SharedState', +]; + +export const EXTENSION_BLACKLIST = [ + // Example blacklist entry for generated extension docs. + 'YfmCut', +]; + +export const EXTENSION_DOC_FIELD_CONFIG = { + name: {from: 'extension directory name'}, + sourcePath: {from: 'extension directory path relative to repository root'}, + category: {from: 'configured extension category directory'}, + nodes: {from: 'extension spec files: addNode and addNodeSpec calls'}, + marks: {from: 'extension spec files: addMark and addMarkSpec calls'}, + actions: {from: 'extension source files: addAction calls'}, + keymaps: {from: 'extension source files: addKeymap callback return values'}, + inputRules: {from: 'extension source files: input-rule factory calls'}, + plugins: {from: 'extension source files: addPlugin calls'}, + mdPlugins: {from: 'extension source files: markdown-it md.use calls'}, + serializerHints: {from: 'serializer source files: state.write and state.text calls'}, + options: {from: 'extension source files: local *Options TypeScript declarations'}, + markupExamples: {from: 'extension test files: same(...) serializer examples'}, + presets: {from: 'editor preset files: preset builder use(...) calls'}, +}; + +export const PRESET_DEFS = [ + {name: 'ZeroPreset', file: 'zero.ts', parent: null}, + {name: 'CommonMarkPreset', file: 'commonmark.ts', parent: 'ZeroPreset'}, + {name: 'DefaultPreset', file: 'default.ts', parent: 'CommonMarkPreset'}, + {name: 'YfmPreset', file: 'yfm.ts', parent: 'DefaultPreset'}, + {name: 'FullPreset', file: 'full.ts', parent: 'YfmPreset'}, +]; + +/** + * Checks whether an extension should be skipped by the raw data extractor. + */ +export function isBlacklistedExtension(name) { + return [...INTERNAL_EXTENSION_BLACKLIST, ...EXTENSION_BLACKLIST].includes(name); +} diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs new file mode 100644 index 00000000..3c961439 --- /dev/null +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import {isAbsolute, join} from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; + +import {DOCS_GEN_DIR, EDITOR_PKG_DIR, REPO_ROOT} from './config.mjs'; +import {ExtensionExtractor} from './extractor/index.mjs'; +import {logger} from './logger.mjs'; + +/** + * Resolves a path from the repository root. + */ +function resolveFromRoot(path) { + return isAbsolute(path) ? path : join(REPO_ROOT, path); +} + +/** + * Creates default CLI options. + */ +function createDefaultOptions() { + return { + editorPkg: EDITOR_PKG_DIR, + outDir: DOCS_GEN_DIR, + only: null, + }; +} + +/** + * Reads a required option value. + */ +function readOptionValue(args, index, optionName) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${optionName}`); + } + + return value; +} + +/** + * Parses comma-separated extension names. + */ +function parseOnlyOption(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +/** + * Applies one CLI option to a new options object. + */ +function applyOption(opts, args, index) { + const arg = args[index]; + + switch (arg) { + case '--editor-pkg': + return { + nextIndex: index + 1, + opts: {...opts, editorPkg: resolveFromRoot(readOptionValue(args, index, arg))}, + }; + case '--out-dir': + return { + nextIndex: index + 1, + opts: {...opts, outDir: resolveFromRoot(readOptionValue(args, index, arg))}, + }; + case '--only': + return { + nextIndex: index + 1, + opts: {...opts, only: parseOnlyOption(readOptionValue(args, index, arg))}, + }; + case '--help': + return {nextIndex: index, opts: {...opts, help: true}}; + default: + throw new Error(`Unknown option: ${arg}`); + } +} + +/** + * Parses CLI options for extension data extraction. + */ +export function parseArgs(args = process.argv.slice(2)) { + let opts = createDefaultOptions(); + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--') return opts; + + const parsedOption = applyOption(opts, args, index); + opts = parsedOption.opts; + index = parsedOption.nextIndex; + } + + return opts; +} + +/** + * Prints command usage information. + */ +function printHelp() { + logger.info('Usage: pnpm --filter @markdown-editor/docs-gen run extract [options]'); + logger.info(''); + logger.info('Options:'); + logger.info(' --only Bold,Link Extract selected extension names'); + logger.info(' --out-dir tmp/docs-gen Override output directory'); + logger.info(' --editor-pkg path Override packages/editor path'); +} + +/** + * Runs extension data extraction from CLI arguments. + */ +export function main(args = process.argv.slice(2)) { + const opts = parseArgs(args); + if (opts.help) { + printHelp(); + return; + } + + new ExtensionExtractor({ + editorPkg: opts.editorPkg, + outDir: opts.outDir, + repoRoot: REPO_ROOT, + }).run({only: opts.only}); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + logger.error(error); + process.exit(1); + } +} diff --git a/infra/docs-gen/src/extractor/actions.test.mjs b/infra/docs-gen/src/extractor/actions.test.mjs new file mode 100644 index 00000000..410dd16a --- /dev/null +++ b/infra/docs-gen/src/extractor/actions.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {extractActions} from './ast.mjs'; + +test('extractActions captures direct and chained builder.addAction calls', () => { + const content = [ + "builder.addAction('bold', () => boldAction);", + 'builder', + ' .addAction(BoldAction.Toggle, () => toggle)', + ' .addAction(BoldAction.Off, () => off);', + ].join('\n'); + + assert.deepEqual(extractActions(content), ['bold', 'BoldAction.Toggle', 'BoldAction.Off']); +}); + +test('extractActions ignores non-builder addAction calls', () => { + const content = [ + "tr.addAction('shouldNotMatch', cb);", + "service.addAction('alsoSkip', cb);", + "builder.addAction('keepMe', cb);", + ].join('\n'); + + assert.deepEqual(extractActions(content), ['keepMe']); +}); diff --git a/infra/docs-gen/src/extractor/ast.mjs b/infra/docs-gen/src/extractor/ast.mjs new file mode 100644 index 00000000..e373faab --- /dev/null +++ b/infra/docs-gen/src/extractor/ast.mjs @@ -0,0 +1,504 @@ +import ts from 'typescript'; + +const BUILDER_ROOT_NAME = 'builder'; + +const BUILDER_CHAIN_METHODS = new Set([ + 'addAction', + 'addKeymap', + 'addMark', + 'addMarkSpec', + 'addMarkdownTokenParserSpec', + 'addNode', + 'addNodeSerializerSpec', + 'addNodeSpec', + 'addPlugin', + 'configureMd', +]); + +const INPUT_RULE_FACTORIES = new Set([ + 'inlineNodeInputRule', + 'markInputRule', + 'nodeInputRule', + 'textblockTypeInputRule', + 'wrappingInputRule', +]); + +/** + * Parses TypeScript or TSX source into a traversable AST. + */ +export function parseSource(content, fileName = 'source.tsx') { + return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +/** + * Visits every AST node depth-first. + */ +export function forEachNode(root, callback) { + const visit = (node) => { + callback(node); + ts.forEachChild(node, visit); + }; + + visit(root); +} + +/** + * Deduplicates values while preserving source order. + */ +export function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +/** + * Removes syntax wrappers that do not affect a static expression value. + */ +export function unwrapExpression(expression) { + let current = expression; + + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + + return current; +} + +/** + * Reads a static property name from an object-like AST node. + */ +export function getStaticPropertyName(name, {allowComputedLiteral = false} = {}) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + + if ( + allowComputedLiteral && + ts.isComputedPropertyName(name) && + ts.isStringLiteralLike(unwrapExpression(name.expression)) + ) { + return unwrapExpression(name.expression).text; + } + + return null; +} + +/** + * Resolves syntax that names a constant, enum member, or string literal. + */ +export function getExpressionName(expression, sourceFile) { + const current = unwrapExpression(expression); + + if (ts.isStringLiteralLike(current)) return current.text; + if (ts.isIdentifier(current)) return current.text; + + if (ts.isPropertyAccessExpression(current)) { + const baseName = getExpressionName(current.expression, sourceFile); + return baseName ? `${baseName}.${current.name.text}` : current.getText(sourceFile).trim(); + } + + if ( + ts.isElementAccessExpression(current) && + ts.isStringLiteralLike(current.argumentExpression) + ) { + const baseName = getExpressionName(current.expression, sourceFile); + return baseName ? `${baseName}.${current.argumentExpression.text}` : null; + } + + return null; +} + +/** + * Resolves a literal string expression. + */ +export function getStringValue(expression) { + const current = unwrapExpression(expression); + return ts.isStringLiteralLike(current) ? current.text : null; +} + +/** + * Checks whether an expression is the root builder or a builder call chain. + */ +function isBuilderExpression(expression) { + const current = unwrapExpression(expression); + + if (ts.isIdentifier(current)) return current.text === BUILDER_ROOT_NAME; + + if (!ts.isCallExpression(current)) return false; + if (!ts.isPropertyAccessExpression(current.expression)) return false; + if (!BUILDER_CHAIN_METHODS.has(current.expression.name.text)) return false; + + return isBuilderExpression(current.expression.expression); +} + +/** + * Returns the called property name for a call expression. + */ +function getCallPropertyName(callExpression) { + const expression = unwrapExpression(callExpression.expression); + return ts.isPropertyAccessExpression(expression) ? expression.name.text : null; +} + +/** + * Checks whether a call is made on the extension builder chain. + */ +function isBuilderMethodCall(callExpression, methodNames) { + const expression = unwrapExpression(callExpression.expression); + + return ( + ts.isPropertyAccessExpression(expression) && + methodNames.has(expression.name.text) && + isBuilderExpression(expression.expression) + ); +} + +/** + * Extracts first arguments from extension builder method calls. + */ +function extractBuilderCallFirstArgs(content, methodNames) { + const sourceFile = parseSource(content); + const names = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, methodNames)) return; + + const firstArg = node.arguments[0]; + const name = firstArg ? getExpressionName(firstArg, sourceFile) : null; + if (name) names.push(name); + }); + + return unique(names); +} + +/** + * Extracts ProseMirror node registrations from builder calls. + */ +export function extractAddNode(content) { + return extractBuilderCallFirstArgs(content, new Set(['addNode'])); +} + +/** + * Extracts ProseMirror mark registrations from builder calls. + */ +export function extractAddMark(content) { + return extractBuilderCallFirstArgs(content, new Set(['addMark'])); +} + +/** + * Extracts node names from granular node spec registrations. + */ +export function extractNodeSpecs(content) { + return extractBuilderCallFirstArgs(content, new Set(['addNodeSpec'])); +} + +/** + * Extracts mark names from granular mark spec registrations. + */ +export function extractMarkSpecs(content) { + return extractBuilderCallFirstArgs(content, new Set(['addMarkSpec'])); +} + +/** + * Extracts editor action identifiers from builder calls. + */ +export function extractActions(content) { + return extractBuilderCallFirstArgs(content, new Set(['addAction'])); +} + +/** + * Reads a returned expression from a function-like callback. + */ +function getStaticReturnExpression(callback) { + const body = callback.body; + if (!ts.isBlock(body)) return unwrapExpression(body); + + for (const statement of body.statements) { + if (ts.isReturnStatement(statement)) return statement.expression || null; + } + + return null; +} + +/** + * Describes a factory callback or plugin expression with a stable identifier. + */ +function describeFactoryExpression(expression, sourceFile) { + const current = unwrapExpression(expression); + + if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) { + return getExpressionName(current, sourceFile); + } + + if (ts.isCallExpression(current)) { + return getExpressionName(current.expression, sourceFile); + } + + if (ts.isNewExpression(current)) { + return getExpressionName(current.expression, sourceFile); + } + + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const returned = getStaticReturnExpression(current); + return returned ? describeFactoryExpression(returned, sourceFile) : null; + } + + return null; +} + +/** + * Extracts ProseMirror plugin factory names. + */ +export function extractPlugins(content) { + const sourceFile = parseSource(content); + const plugins = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addPlugin']))) { + return; + } + + const firstArg = node.arguments[0]; + const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; + if (pluginName) plugins.push(pluginName); + }); + + return unique(plugins); +} + +/** + * Extracts static keys from an object literal. + */ +function extractObjectLiteralKeys(objectLiteral, knownObjects = new Map()) { + const keys = []; + + for (const property of objectLiteral.properties) { + if (ts.isSpreadAssignment(property)) { + const spreadName = ts.isIdentifier(property.expression) + ? property.expression.text + : null; + if (spreadName && knownObjects.has(spreadName)) { + keys.push(...knownObjects.get(spreadName)); + } + continue; + } + + if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) { + continue; + } + + const key = getStaticPropertyName(property.name); + if (key) keys.push(key); + } + + return keys; +} + +/** + * Reads object-literal keys from a return expression or local object reference. + */ +function extractKeymapExpressionKeys(expression, knownObjects = new Map()) { + const current = unwrapExpression(expression); + + if (ts.isObjectLiteralExpression(current)) { + return extractObjectLiteralKeys(current, knownObjects); + } + + if (ts.isIdentifier(current) && knownObjects.has(current.text)) { + return knownObjects.get(current.text); + } + + return []; +} + +/** + * Registers one top-level object literal binding from a callback block. + */ +function collectObjectBinding(statement, knownObjects) { + if (!ts.isVariableStatement(statement)) return; + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + + const initializer = unwrapExpression(declaration.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + knownObjects.set( + declaration.name.text, + unique(extractObjectLiteralKeys(initializer, knownObjects)), + ); + } + } +} + +/** + * Registers a static key assignment into a known object binding. + */ +function collectObjectAssignment(statement, knownObjects) { + if (!ts.isExpressionStatement(statement)) return; + + const expression = unwrapExpression(statement.expression); + if ( + !ts.isBinaryExpression(expression) || + expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) { + return; + } + + const left = unwrapExpression(expression.left); + let objectName = null; + let keyName = null; + + if (ts.isPropertyAccessExpression(left)) { + objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; + keyName = left.name.text; + } else if (ts.isElementAccessExpression(left)) { + objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; + keyName = ts.isStringLiteralLike(left.argumentExpression) + ? left.argumentExpression.text + : null; + } + + if (!objectName || !keyName || !knownObjects.has(objectName)) return; + + knownObjects.set(objectName, unique([...knownObjects.get(objectName), keyName])); +} + +/** + * Extracts static key bindings from an addKeymap callback. + */ +function extractKeymapCallbackKeys(callback) { + const body = unwrapExpression(callback.body); + if (!ts.isBlock(body)) return extractKeymapExpressionKeys(body); + + const knownObjects = new Map(); + for (const statement of body.statements) { + collectObjectBinding(statement, knownObjects); + collectObjectAssignment(statement, knownObjects); + + if (ts.isReturnStatement(statement) && statement.expression) { + return extractKeymapExpressionKeys(statement.expression, knownObjects); + } + } + + return []; +} + +/** + * Extracts static key bindings from addKeymap callbacks. + */ +export function extractKeymaps(content) { + const sourceFile = parseSource(content); + const keymaps = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addKeymap']))) { + return; + } + + const callback = unwrapExpression(node.arguments[0]); + if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) { + keymaps.push(...extractKeymapCallbackKeys(callback)); + } + }); + + return unique(keymaps); +} + +/** + * Extracts one input-rule syntax descriptor from a factory call. + */ +function describeInputRuleCall(callExpression) { + const firstArg = callExpression.arguments[0]; + if (!firstArg) return null; + + const current = unwrapExpression(firstArg); + if (current.kind === ts.SyntaxKind.RegularExpressionLiteral) { + return current.getText(callExpression.getSourceFile()); + } + + if (ts.isObjectLiteralExpression(current)) { + let open = null; + let close = null; + + for (const property of current.properties) { + if (!ts.isPropertyAssignment(property)) continue; + + const key = getStaticPropertyName(property.name); + if (key === 'open') open = getStringValue(property.initializer); + if (key === 'close') close = getStringValue(property.initializer); + } + + return open !== null && close !== null ? `${open}...${close}` : null; + } + + return null; +} + +/** + * Extracts input-rule syntax patterns. + */ +export function extractInputRules(content) { + const sourceFile = parseSource(content); + const rules = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + + const expression = unwrapExpression(node.expression); + if (!ts.isIdentifier(expression) || !INPUT_RULE_FACTORIES.has(expression.text)) return; + + const rule = describeInputRuleCall(node); + if (rule) rules.push(rule); + }); + + return unique(rules); +} + +/** + * Extracts markdown-it plugin registrations. + */ +export function extractMdPlugins(content) { + const sourceFile = parseSource(content); + const plugins = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || getCallPropertyName(node) !== 'use') return; + + const expression = unwrapExpression(node.expression); + if (!ts.isPropertyAccessExpression(expression) || !ts.isIdentifier(expression.expression)) + return; + if (expression.expression.text !== 'md') return; + + const firstArg = node.arguments[0]; + const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; + if (pluginName) plugins.push(pluginName); + }); + + return unique(plugins); +} + +/** + * Extracts serializer output snippets. + */ +export function extractSerializerSyntax(content) { + const sourceFile = parseSource(content); + const snippets = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + + const expression = unwrapExpression(node.expression); + if (!ts.isPropertyAccessExpression(expression)) return; + if (!ts.isIdentifier(expression.expression) || expression.expression.text !== 'state') + return; + if (expression.name.text !== 'write' && expression.name.text !== 'text') return; + + const snippet = node.arguments[0] ? getStringValue(node.arguments[0]) : null; + if (snippet?.trim()) snippets.push(snippet); + }); + + return unique(snippets); +} diff --git a/infra/docs-gen/src/extractor/cli.test.mjs b/infra/docs-gen/src/extractor/cli.test.mjs new file mode 100644 index 00000000..02f8f9e4 --- /dev/null +++ b/infra/docs-gen/src/extractor/cli.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {parseArgs} from '../extract-extension-data.mjs'; + +test('parseArgs parses selected extensions', () => { + assert.deepEqual(parseArgs(['--only', 'Bold, Link']).only, ['Bold', 'Link']); +}); + +test('parseArgs stops option parsing after separator', () => { + assert.equal(parseArgs(['--help', '--', '--unknown']).help, true); +}); + +test('parseArgs rejects missing option values', () => { + assert.throws(() => parseArgs(['--out-dir']), /Missing value for --out-dir/u); + assert.throws(() => parseArgs(['--only', '--help']), /Missing value for --only/u); +}); diff --git a/infra/docs-gen/src/extractor/constants.mjs b/infra/docs-gen/src/extractor/constants.mjs new file mode 100644 index 00000000..a4a0f9e8 --- /dev/null +++ b/infra/docs-gen/src/extractor/constants.mjs @@ -0,0 +1,154 @@ +import ts from 'typescript'; + +import { + forEachNode, + getExpressionName, + getStaticPropertyName, + getStringValue, + parseSource, + unwrapExpression, +} from './ast.mjs'; + +/** + * Resolves a scalar initializer into a literal value or constant reference. + */ +function readScalarInitializer(initializer, sourceFile) { + const stringValue = getStringValue(initializer); + if (stringValue !== null) return stringValue; + + const current = unwrapExpression(initializer); + if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) { + return getExpressionName(current, sourceFile); + } + + return null; +} + +/** + * Extracts top-level scalar members from an object literal. + */ +function extractObjectScalarMembers(objectName, objectLiteral, constants, sourceFile) { + for (const property of objectLiteral.properties) { + if (!ts.isPropertyAssignment(property)) continue; + + const key = getStaticPropertyName(property.name); + if (!key) continue; + + const value = readScalarInitializer(property.initializer, sourceFile); + if (value !== null) { + constants.set(`${objectName}.${key}`, value); + } + } +} + +/** + * Extracts a scalar const declaration. + */ +function extractVariableDeclaration(declaration, constants, sourceFile) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return; + + const name = declaration.name.text; + const initializer = unwrapExpression(declaration.initializer); + + if (ts.isObjectLiteralExpression(initializer)) { + extractObjectScalarMembers(name, initializer, constants, sourceFile); + return; + } + + const value = readScalarInitializer(initializer, sourceFile); + if (value !== null) { + constants.set(name, value); + } +} + +/** + * Extracts string-valued enum members. + */ +function extractEnumDeclaration(enumDeclaration, constants) { + const enumName = enumDeclaration.name.text; + + for (const member of enumDeclaration.members) { + const memberName = getStaticPropertyName(member.name); + if (!memberName || !member.initializer) continue; + + const value = getStringValue(member.initializer); + if (value !== null) { + constants.set(`${enumName}.${memberName}`, value); + } + } +} + +/** + * Resolves aliases between constants after all candidates are collected. + */ +function resolveConstantAliases(constants) { + for (let pass = 0; pass < 5; pass++) { + for (const [key, value] of constants) { + if (typeof value === 'string' && constants.has(value) && key !== value) { + constants.set(key, constants.get(value)); + } + } + } +} + +/** + * Extracts string-valued constants, enums, and scalar object members. + */ +export function extractConstants(content) { + const sourceFile = parseSource(content); + const constants = new Map(); + + forEachNode(sourceFile, (node) => { + if (ts.isVariableDeclaration(node)) { + extractVariableDeclaration(node, constants, sourceFile); + } else if (ts.isEnumDeclaration(node)) { + extractEnumDeclaration(node, constants); + } + }); + + resolveConstantAliases(constants); + + return constants; +} + +/** + * Resolves one raw identifier through the constants map. + */ +export function resolveConstant(raw, constants) { + if (!raw) return raw; + if (constants.has(raw)) return constants.get(raw); + + for (const [key, value] of constants) { + if (key.endsWith(`.${raw}`) || key === raw) return value; + } + + return raw; +} + +/** + * Resolves a list of raw identifiers and expands constant namespaces. + */ +export function resolveAllConstants(rawList, constants) { + const resolved = []; + + for (const raw of rawList) { + const value = resolveConstant(raw, constants); + + if (value === raw && constants.size > 0) { + const prefix = raw + '.'; + const members = []; + for (const [key, memberValue] of constants) { + if (key.startsWith(prefix)) members.push(resolveConstant(memberValue, constants)); + } + + if (members.length > 0) { + resolved.push(...members); + continue; + } + } + + resolved.push(value); + } + + return [...new Set(resolved)]; +} diff --git a/infra/docs-gen/src/extractor/constants.test.mjs b/infra/docs-gen/src/extractor/constants.test.mjs new file mode 100644 index 00000000..30fbd40e --- /dev/null +++ b/infra/docs-gen/src/extractor/constants.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {extractConstants, resolveAllConstants} from './constants.mjs'; + +test('extractConstants captures top-level scalar object props with nested objects', () => { + const content = [ + 'export const CLASSNAMES = {', + " Inline: { Container: 'a', Sharp: 'b' },", + " Block: 'mathblock',", + " Display: 'display',", + '} as const;', + ].join('\n'); + + const map = extractConstants(content); + + assert.equal(map.get('CLASSNAMES.Block'), 'mathblock'); + assert.equal(map.get('CLASSNAMES.Display'), 'display'); +}); + +test('extractConstants handles enums after objects with nested braces', () => { + const content = [ + 'const NESTED = { Inner: { Foo: "bar" }, Outer: "value" };', + 'export enum NodeName { Para = "paragraph", Doc = "doc" }', + ].join('\n'); + + const map = extractConstants(content); + + assert.equal(map.get('NESTED.Outer'), 'value'); + assert.equal(map.get('NodeName.Para'), 'paragraph'); + assert.equal(map.get('NodeName.Doc'), 'doc'); +}); + +test('extractConstants resolves identifier-valued props through reference chains', () => { + const content = ["const NAME = 'bold';", 'export const Specs = { Bold: NAME };'].join('\n'); + + const map = extractConstants(content); + + assert.equal(map.get('Specs.Bold'), 'bold'); +}); + +test('resolveAllConstants expands a prefix into all known members', () => { + const content = 'enum NodeName { Para = "paragraph", Doc = "doc" }'; + const map = extractConstants(content); + + assert.deepEqual(resolveAllConstants(['NodeName'], map).sort(), ['doc', 'paragraph']); +}); diff --git a/infra/docs-gen/src/extractor/examples.mjs b/infra/docs-gen/src/extractor/examples.mjs new file mode 100644 index 00000000..efc9ae83 --- /dev/null +++ b/infra/docs-gen/src/extractor/examples.mjs @@ -0,0 +1,149 @@ +import ts from 'typescript'; + +import {forEachNode, parseSource, unwrapExpression} from './ast.mjs'; + +/** + * Applies a known string method to a value. + */ +function applyStringMethod(value, method) { + if (method === 'trim') return value.trim(); + if (method === 'trimStart') return value.trimStart(); + if (method === 'trimEnd') return value.trimEnd(); + return value; +} + +/** + * Resolves a template literal expression. + */ +function resolveTemplateExpression(expression, bindings) { + let result = expression.head.text; + + for (const span of expression.templateSpans) { + const value = resolveStringExpression(span.expression, bindings); + if (value === null) return null; + + result += value + span.literal.text; + } + + return result; +} + +/** + * Resolves an array join expression. + */ +function resolveArrayJoinExpression(callExpression, bindings) { + const callee = unwrapExpression(callExpression.expression); + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'join') return null; + + const arrayExpression = unwrapExpression(callee.expression); + if (!ts.isArrayLiteralExpression(arrayExpression)) return null; + + const delimiterExpression = callExpression.arguments[0]; + const delimiter = delimiterExpression + ? resolveStringExpression(delimiterExpression, bindings) + : ','; + if (delimiter === null) return null; + + const parts = arrayExpression.elements.map((element) => + resolveStringExpression(element, bindings), + ); + if (parts.some((part) => part === null)) return null; + + return parts.join(delimiter); +} + +/** + * Resolves a string method call. + */ +function resolveStringMethodCall(callExpression, bindings) { + const callee = unwrapExpression(callExpression.expression); + if (!ts.isPropertyAccessExpression(callee)) return null; + + const method = callee.name.text; + if (method === 'join') return resolveArrayJoinExpression(callExpression, bindings); + if (method !== 'trim' && method !== 'trimStart' && method !== 'trimEnd') return null; + + const value = resolveStringExpression(callee.expression, bindings); + return value === null ? null : applyStringMethod(value, method); +} + +/** + * Resolves supported string expressions. + */ +function resolveStringExpression(expression, bindings) { + const current = unwrapExpression(expression); + + if (ts.isStringLiteralLike(current)) return current.text; + if (ts.isTemplateExpression(current)) return resolveTemplateExpression(current, bindings); + if (ts.isIdentifier(current)) return bindings.get(current.text) ?? null; + if (ts.isCallExpression(current)) return resolveStringMethodCall(current, bindings); + + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = resolveStringExpression(current.left, bindings); + const right = resolveStringExpression(current.right, bindings); + return left === null || right === null ? null : left + right; + } + + return null; +} + +/** + * Collects resolvable string bindings declared before a source position. + */ +function collectStringBindingsBefore(sourceFile, position) { + const declarations = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer) { + return; + } + + if (node.getStart(sourceFile) < position) { + declarations.push(node); + } + }); + + declarations.sort((left, right) => left.getStart(sourceFile) - right.getStart(sourceFile)); + + const bindings = new Map(); + for (const declaration of declarations) { + const value = resolveStringExpression(declaration.initializer, bindings); + if (value !== null) { + bindings.set(declaration.name.text, value); + } + } + + return bindings; +} + +/** + * Checks whether a call expression invokes same(...). + */ +function isSameCall(callExpression) { + const callee = unwrapExpression(callExpression.expression); + + if (ts.isIdentifier(callee)) return callee.text === 'same'; + return ts.isPropertyAccessExpression(callee) && callee.name.text === 'same'; +} + +/** + * Extracts markdown examples from serializer test helpers. + */ +export function extractTestExamples(content) { + const sourceFile = parseSource(content); + const examples = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isSameCall(node) || node.arguments.length === 0) { + return; + } + + const bindings = collectStringBindingsBefore(sourceFile, node.getStart(sourceFile)); + const example = resolveStringExpression(node.arguments[0], bindings); + if (example !== null) { + examples.push(example); + } + }); + + return examples; +} diff --git a/infra/docs-gen/src/extractor/examples.test.mjs b/infra/docs-gen/src/extractor/examples.test.mjs new file mode 100644 index 00000000..82bf82bd --- /dev/null +++ b/infra/docs-gen/src/extractor/examples.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; + +import {extractTestExamples} from './examples.mjs'; + +/** + * Reads a repository file as UTF-8 text. + */ +function readRepoFile(relativePath) { + return readFileSync(new URL(`../../../../${relativePath}`, import.meta.url), 'utf-8'); +} + +test('extractTestExamples resolves literals, local bindings, joins, and template bindings', () => { + const content = [ + "const formula = 'x + y';", + 'const markup = `', + '# title', + '`.trimStart();', + "same(['Term', ': Description'].join('\\n'), doc());", + 'checker.same(markup, doc());', + 'same(`$$${formula}$$\\n\\n`, doc());', + ].join('\n'); + + assert.deepEqual(extractTestExamples(content), [ + 'Term\n: Description', + '# title\n', + '$$x + y$$\n\n', + ]); +}); + +test('extractTestExamples captures joined Deflist markup', () => { + const examples = extractTestExamples( + readRepoFile('packages/editor/src/extensions/markdown/Deflist/Deflist.test.ts'), + ); + + assert.ok(examples.includes('Term\n: Description')); +}); + +test('extractTestExamples captures markup stored in local constants', () => { + const examples = extractTestExamples( + readRepoFile('packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts'), + ); + + assert.ok(examples.some((example) => example.includes('nested table'))); +}); diff --git a/infra/docs-gen/src/extractor/index.mjs b/infra/docs-gen/src/extractor/index.mjs new file mode 100644 index 00000000..826615f4 --- /dev/null +++ b/infra/docs-gen/src/extractor/index.mjs @@ -0,0 +1,113 @@ +import {existsSync, mkdirSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; + +import {EXTENSION_CATEGORIES, isBlacklistedExtension} from '../config.mjs'; +import {logger} from '../logger.mjs'; +import {listDirs, readText} from '../utils.mjs'; + +import {writeExtensionsJson, writeRawMarkdownFiles} from './output.mjs'; +import {getPresetsForExtension, parsePresets} from './presets.mjs'; +import {scanExtension} from './scan.mjs'; + +export class ExtensionExtractor { + /** + * Creates an extension extractor for editor source paths. + */ + constructor({editorPkg, outDir, repoRoot}) { + this.editorPkg = editorPkg; + this.repoRoot = repoRoot; + this.extensionsDir = join(editorPkg, 'src/extensions'); + this.presetsDir = join(editorPkg, 'src/presets'); + this.outDir = outDir; + this.rawDir = join(outDir, 'raw'); + } + + /** + * Scans one extension directory into raw metadata. + */ + scan(extDir, category) { + return scanExtension({extDir, category, repoRoot: this.repoRoot}); + } + + /** + * Collects all configured extension directories before filtering. + */ + collectExtensionRefs() { + const refs = []; + + for (const category of EXTENSION_CATEGORIES) { + const categoryDir = join(this.extensionsDir, category); + for (const dirName of listDirs(categoryDir)) { + refs.push({ + name: dirName, + category, + extDir: join(categoryDir, dirName), + }); + } + } + + return refs; + } + + /** + * Applies configured extension filters after the full list is known. + */ + filterExtensionRefs(refs, {only} = {}) { + const onlySet = only?.length ? new Set(only) : null; + + return refs.filter((ref) => { + if (isBlacklistedExtension(ref.name)) return false; + return !onlySet || onlySet.has(ref.name); + }); + } + + /** + * Scans all configured extension categories. + */ + scanAll({only} = {}) { + const allRefs = this.collectExtensionRefs(); + const extensionRefs = this.filterExtensionRefs(allRefs, {only}); + + return { + totalCount: allRefs.length, + extensions: extensionRefs.map((ref) => this.scan(ref.extDir, ref.category)), + }; + } + + /** + * Writes extracted JSON IR and raw Markdown files. + */ + run({only} = {}) { + logger.info('Extracting raw extension data...'); + + if (existsSync(this.rawDir)) { + rmSync(this.rawDir, {recursive: true, force: true}); + } + mkdirSync(this.rawDir, {recursive: true}); + + const version = this.getEditorVersion(); + const {totalCount, extensions} = this.scanAll({only}); + const presetMap = parsePresets(this.presetsDir); + + for (const extension of extensions) { + extension.presets = getPresetsForExtension(presetMap, extension.name); + } + + writeExtensionsJson(this.outDir, version, extensions); + writeRawMarkdownFiles(this.rawDir, extensions, presetMap, version); + + logger.success(`Raw data written to ${this.outDir}`); + logger.info(`Discovered extensions: ${totalCount}`); + logger.info(`Extensions: ${extensions.length}`); + + return {version, extensions}; + } + + /** + * Reads the editor package version. + */ + getEditorVersion() { + const pkg = JSON.parse(readText(join(this.editorPkg, 'package.json'))); + return pkg.version; + } +} diff --git a/infra/docs-gen/src/extractor/keymaps.test.mjs b/infra/docs-gen/src/extractor/keymaps.test.mjs new file mode 100644 index 00000000..640f2cb3 --- /dev/null +++ b/infra/docs-gen/src/extractor/keymaps.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; + +import {extractKeymaps} from './ast.mjs'; + +/** + * Reads a repository file as UTF-8 text. + */ +function readRepoFile(relativePath) { + return readFileSync(new URL(`../../../../${relativePath}`, import.meta.url), 'utf-8'); +} + +test('extractKeymaps handles direct object returns and ignores computed keys', () => { + const content = [ + 'builder.addKeymap(() => ({', + ' Tab: handleTab,', + " 'Shift-Tab': handleShiftTab,", + ' [dynamicKey]: ignoreMe,', + '}));', + ].join('\n'); + + assert.deepEqual(extractKeymaps(content), ['Tab', 'Shift-Tab']); +}); + +test('extractKeymaps merges returned object literals with spread bindings', () => { + const content = [ + 'builder.addKeymap(() => {', + ' const bindings: Keymap = {Backspace: resetHeading};', + ' return {', + ' Tab: handleTab,', + ' ...bindings,', + " 'Shift-Tab': handleShiftTab,", + ' };', + '});', + ].join('\n'); + + assert.deepEqual(extractKeymaps(content), ['Tab', 'Backspace', 'Shift-Tab']); +}); + +test('extractKeymaps captures static keymaps from the Lists extension', () => { + const content = readRepoFile('packages/editor/src/extensions/markdown/Lists/index.ts'); + + assert.deepEqual(extractKeymaps(content), [ + 'Tab', + 'Shift-Tab', + 'Backspace', + 'Mod-[', + 'Mod-]', + 'Enter', + ]); +}); + +test('extractKeymaps captures static block-body bindings and ignores dynamic ones', () => { + const headingContent = readRepoFile('packages/editor/src/extensions/markdown/Heading/index.ts'); + const historyContent = readRepoFile('packages/editor/src/extensions/behavior/History/index.ts'); + const editorModeContent = readRepoFile( + 'packages/editor/src/extensions/behavior/EditorModeKeymap/index.ts', + ); + + assert.deepEqual(extractKeymaps(headingContent), ['Backspace']); + assert.deepEqual(extractKeymaps(historyContent), []); + assert.deepEqual(extractKeymaps(editorModeContent), []); +}); diff --git a/infra/docs-gen/src/extractor/markdown-gen.mjs b/infra/docs-gen/src/extractor/markdown-gen.mjs new file mode 100644 index 00000000..310348a3 --- /dev/null +++ b/infra/docs-gen/src/extractor/markdown-gen.mjs @@ -0,0 +1,87 @@ +import {getPresetsForExtension} from './presets.mjs'; + +/** + * Formats a value as inline Markdown code. + */ +function code(value) { + return `\`${String(value).replace(/\|/g, '\\|').replace(/\n/g, '\\n')}\``; +} + +/** + * Adds a Markdown list section when values are present. + */ +function addList(lines, title, values) { + if (values.length === 0) return; + + lines.push(`## ${title}`, ''); + for (const value of values) { + lines.push(`- ${code(value)}`); + } + lines.push(''); +} + +/** + * Generates a raw Markdown page for extracted extension data. + */ +export function generateRawMd(extension, presetMap, version) { + const presets = getPresetsForExtension(presetMap, extension.name); + const lines = [ + '---', + `extension: ${extension.name}`, + `version: ${version}`, + `category: ${extension.category}`, + `source: ${extension.sourcePath}`, + '---', + '', + `# ${extension.name}`, + '', + '## Source', + '', + `- ${code(extension.sourcePath)}`, + '', + '## Presets', + '', + ]; + + if (presets.length > 0) { + for (const preset of presets) lines.push(`- ${preset}`); + } else { + lines.push('Not included in standard presets.'); + } + lines.push(''); + + if (extension.nodes.length > 0 || extension.marks.length > 0) { + lines.push('## Schema', ''); + for (const node of extension.nodes) { + lines.push(`- Node: ${code(node)}`); + } + for (const mark of extension.marks) { + lines.push(`- Mark: ${code(mark)}`); + } + lines.push(''); + } + + addList(lines, 'Actions', extension.actions); + addList(lines, 'Keymaps', extension.keymaps); + addList(lines, 'Input Rules', extension.inputRules); + addList(lines, 'Markdown-It Plugins', extension.mdPlugins); + addList(lines, 'ProseMirror Plugins', extension.plugins); + addList(lines, 'Serializer Hints', extension.serializerHints); + + if (extension.options.length > 0) { + lines.push('## Options', '', '| Option | Type |', '|--------|------|'); + for (const option of extension.options) { + lines.push(`| ${code(option.name)} | ${code(option.type)} |`); + } + lines.push(''); + } + + if (extension.markupExamples.length > 0) { + lines.push('## Markup Examples', ''); + for (const example of extension.markupExamples.slice(0, 10)) { + lines.push('```markdown', example, '```', ''); + } + } + + return lines.join('\n'); +} diff --git a/infra/docs-gen/src/extractor/options.mjs b/infra/docs-gen/src/extractor/options.mjs new file mode 100644 index 00000000..26ed82b5 --- /dev/null +++ b/infra/docs-gen/src/extractor/options.mjs @@ -0,0 +1,235 @@ +import ts from 'typescript'; + +import {getStaticPropertyName, parseSource} from './ast.mjs'; + +/** + * Normalizes whitespace in extracted type snippets. + */ +function normalizeWhitespace(content) { + return content.trim().replace(/\s+/g, ' '); +} + +/** + * Creates a type declaration record. + */ +function createDeclaration(name, kind, node, sourceFile) { + return { + name, + kind, + node, + sourceFile, + }; +} + +/** + * Parses local *Options declarations from TypeScript source. + */ +function parseOptionDeclarations(content) { + const sourceFile = parseSource(content); + const declarations = new Map(); + + for (const statement of sourceFile.statements) { + if ( + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && + statement.name.text.endsWith('Options') + ) { + declarations.set( + statement.name.text, + createDeclaration( + statement.name.text, + ts.isInterfaceDeclaration(statement) ? 'interface' : 'type', + statement, + sourceFile, + ), + ); + } + } + + return declarations; +} + +/** + * Deduplicates option fields by name. + */ +function uniqueOptionFields(fields) { + const result = new Map(); + + for (const field of fields) { + if (!result.has(field.name)) { + result.set(field.name, field); + } + } + + return [...result.values()]; +} + +/** + * Reads a field type as source text. + */ +function readFieldType(member, sourceFile) { + return member.type ? normalizeWhitespace(member.type.getText(sourceFile)) : 'unknown'; +} + +/** + * Reads field names from Pick/Omit string literal unions. + */ +function readStringLiteralUnion(typeNode) { + if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteralLike(typeNode.literal)) { + return [typeNode.literal.text]; + } + + if (ts.isUnionTypeNode(typeNode)) { + return typeNode.types.flatMap(readStringLiteralUnion); + } + + return []; +} + +/** + * Reads fields declared inside an inline object type. + */ +function readTypeLiteralFields(typeLiteral, sourceFile) { + const fields = []; + + for (const member of typeLiteral.members) { + if (!ts.isPropertySignature(member) || !member.name) continue; + + const name = getStaticPropertyName(member.name); + if (!name) continue; + + fields.push({ + name, + type: readFieldType(member, sourceFile), + }); + } + + return fields; +} + +/** + * Filters fields to names from a utility type. + */ +function pickOptionFields(fields, names) { + const allowedNames = new Set(names); + return fields.filter((field) => allowedNames.has(field.name)); +} + +/** + * Excludes fields by names from a utility type. + */ +function omitOptionFields(fields, names) { + const omittedNames = new Set(names); + return fields.filter((field) => !omittedNames.has(field.name)); +} + +/** + * Resolves a type reference to another local declaration or utility type. + */ +function resolveTypeReference(typeNode, declarations, seen, sourceFile) { + const typeName = typeNode.typeName.getText(sourceFile); + const typeArguments = typeNode.typeArguments || []; + + if ((typeName === 'Pick' || typeName === 'Omit') && typeArguments.length >= 2) { + const fields = resolveTypeNode(typeArguments[0], declarations, seen, sourceFile); + const names = readStringLiteralUnion(typeArguments[1]); + + return typeName === 'Pick' + ? pickOptionFields(fields, names) + : omitOptionFields(fields, names); + } + + if (!declarations.has(typeName)) return []; + + return resolveOptionDeclaration(typeName, declarations, seen); +} + +/** + * Resolves supported TypeScript option type nodes. + */ +function resolveTypeNode(typeNode, declarations, seen, sourceFile) { + if (!typeNode) return []; + + if (ts.isParenthesizedTypeNode(typeNode)) { + return resolveTypeNode(typeNode.type, declarations, seen, sourceFile); + } + + if (ts.isTypeLiteralNode(typeNode)) { + return readTypeLiteralFields(typeNode, sourceFile); + } + + if (ts.isIntersectionTypeNode(typeNode)) { + return typeNode.types.flatMap((part) => + resolveTypeNode(part, declarations, seen, sourceFile), + ); + } + + if (ts.isTypeReferenceNode(typeNode)) { + return resolveTypeReference(typeNode, declarations, seen, sourceFile); + } + + return []; +} + +/** + * Resolves fields inherited by an interface declaration. + */ +function resolveInterfaceHeritage(interfaceNode, declarations, seen, sourceFile) { + const clauses = interfaceNode.heritageClauses || []; + const fields = []; + + for (const clause of clauses) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue; + + for (const type of clause.types) { + const typeName = type.expression.getText(sourceFile); + if (!declarations.has(typeName)) continue; + + fields.push(...resolveOptionDeclaration(typeName, declarations, seen)); + } + } + + return fields; +} + +/** + * Resolves fields from a declaration by name. + */ +function resolveOptionDeclaration(name, declarations, seen = new Set()) { + if (seen.has(name)) return []; + seen.add(name); + + const declaration = declarations.get(name); + if (!declaration) return []; + + const {node, sourceFile} = declaration; + const inheritedFields = ts.isInterfaceDeclaration(node) + ? resolveInterfaceHeritage(node, declarations, seen, sourceFile) + : resolveTypeNode(node.type, declarations, seen, sourceFile); + const ownFields = ts.isInterfaceDeclaration(node) + ? readTypeLiteralFields(node, sourceFile) + : []; + + return uniqueOptionFields([...inheritedFields, ...ownFields]); +} + +/** + * Selects preferred option declaration names. + */ +function selectOptionNames(declarations, preferredNames) { + const existingPreferredNames = preferredNames.filter((name) => declarations.has(name)); + return existingPreferredNames.length > 0 ? existingPreferredNames : [...declarations.keys()]; +} + +/** + * Extracts exported extension options fields. + */ +export function extractOptionsType(content, preferredNames = []) { + const declarations = parseOptionDeclarations(content); + + for (const name of selectOptionNames(declarations, preferredNames)) { + const fields = resolveOptionDeclaration(name, declarations); + if (fields.length > 0) return fields; + } + + return []; +} diff --git a/infra/docs-gen/src/extractor/options.test.mjs b/infra/docs-gen/src/extractor/options.test.mjs new file mode 100644 index 00000000..6531db73 --- /dev/null +++ b/infra/docs-gen/src/extractor/options.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; + +import {extractOptionsType} from './options.mjs'; + +/** + * Reads a repository file as UTF-8 text. + */ +function readRepoFile(relativePath) { + return readFileSync(new URL(`../../../../${relativePath}`, import.meta.url), 'utf-8'); +} + +/** + * Maps option fields by name. + */ +function mapFields(fields) { + return new Map(fields.map((field) => [field.name, field.type])); +} + +test('extractOptionsType resolves local aliases', () => { + const content = [ + readRepoFile('packages/editor/src/extensions/markdown/Image/index.ts'), + readRepoFile('packages/editor/src/extensions/markdown/Image/imageUrlPaste/index.ts'), + ].join('\n'); + + assert.deepEqual(extractOptionsType(content, ['ImageOptions']), [ + {name: 'parseInsertedUrlAsImage', type: 'ParseInsertedUrlAsImage'}, + ]); +}); + +test('extractOptionsType resolves Pick utility types', () => { + const content = [ + readRepoFile('packages/editor/src/extensions/behavior/Clipboard/index.ts'), + readRepoFile('packages/editor/src/extensions/behavior/Clipboard/clipboard.ts'), + ].join('\n'); + + assert.deepEqual(extractOptionsType(content, ['ClipboardOptions']), [ + {name: 'pasteFileHandler', type: '(file: File) => void'}, + ]); +}); + +test('extractOptionsType keeps nested object option types intact', () => { + const fields = mapFields( + extractOptionsType( + readRepoFile('packages/editor/src/extensions/additional/Mermaid/index.ts'), + ['MermaidOptions'], + ), + ); + + assert.equal(fields.get('autoSave'), '{ enabled: boolean; delay?: number; }'); + assert.equal( + fields.get('theme'), + "{ dark: MermaidConfig['theme']; light: MermaidConfig['theme']; }", + ); +}); + +test('extractOptionsType reads interface declarations with local fields', () => { + const fields = mapFields( + extractOptionsType( + readRepoFile('packages/editor/src/extensions/additional/YfmHtmlBlock/index.ts'), + ['YfmHtmlBlockOptions'], + ), + ); + + assert.equal(fields.get('useConfig'), '() => IHTMLIFrameElementConfig | undefined'); + assert.equal(fields.get('autoSave'), '{ enabled: boolean; delay?: number; }'); +}); + +test('extractOptionsType merges intersections without corrupting nested fields', () => { + const content = [ + readRepoFile('packages/editor/src/extensions/markdown/CodeBlock/index.ts'), + readRepoFile('packages/editor/src/extensions/markdown/CodeBlock/CodeBlockSpecs/index.ts'), + ].join('\n'); + const fields = mapFields(extractOptionsType(content, ['CodeBlockOptions'])); + + assert.equal(fields.get('codeBlockKey'), 'string | null'); + assert.equal(fields.get('lineWrapping'), '{ enabled?: boolean; }'); +}); diff --git a/infra/docs-gen/src/extractor/output.mjs b/infra/docs-gen/src/extractor/output.mjs new file mode 100644 index 00000000..d4d021ca --- /dev/null +++ b/infra/docs-gen/src/extractor/output.mjs @@ -0,0 +1,26 @@ +import {writeFileSync} from 'node:fs'; +import {join} from 'node:path'; + +import {generateRawMd} from './markdown-gen.mjs'; + +/** + * Writes extension IR as JSON. + */ +export function writeExtensionsJson(outDir, version, extensions) { + writeFileSync( + join(outDir, 'extensions.json'), + JSON.stringify({version, extensions}, null, 2) + '\n', + ); +} + +/** + * Writes raw Markdown files for extensions. + */ +export function writeRawMarkdownFiles(rawDir, extensions, presetMap, version) { + for (const extension of extensions) { + writeFileSync( + join(rawDir, `${extension.name}.md`), + generateRawMd(extension, presetMap, version), + ); + } +} diff --git a/infra/docs-gen/src/extractor/presets.mjs b/infra/docs-gen/src/extractor/presets.mjs new file mode 100644 index 00000000..91f9826d --- /dev/null +++ b/infra/docs-gen/src/extractor/presets.mjs @@ -0,0 +1,67 @@ +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; + +import ts from 'typescript'; + +import {PRESET_DEFS} from '../config.mjs'; +import {readText} from '../utils.mjs'; + +import {forEachNode, getExpressionName, parseSource, unwrapExpression} from './ast.mjs'; + +/** + * Extracts direct extension uses from a preset source file. + */ +function extractPresetUses(content) { + const sourceFile = parseSource(content); + const directUses = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + + const expression = unwrapExpression(node.expression); + if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== 'use') return; + + const firstArg = node.arguments[0]; + const extensionName = firstArg ? getExpressionName(firstArg, sourceFile) : null; + if (!extensionName) return; + + if (!extensionName.endsWith('Preset') && !extensionName.endsWith('Specs')) { + directUses.push(extensionName); + } + }); + + return directUses; +} + +/** + * Parses preset files into inherited extension membership. + */ +export function parsePresets(presetsDir) { + const presetMap = new Map(); + + for (const def of PRESET_DEFS) { + const filePath = join(presetsDir, def.file); + if (!existsSync(filePath)) continue; + + const directUses = extractPresetUses(readText(filePath)); + const inherited = def.parent ? presetMap.get(def.parent) || [] : []; + presetMap.set(def.name, [...new Set([...inherited, ...directUses])]); + } + + return presetMap; +} + +/** + * Finds presets that include an extension. + */ +export function getPresetsForExtension(presetMap, extensionName) { + const presets = []; + + for (const [presetName, extensions] of presetMap) { + if (extensions.includes(extensionName)) { + presets.push(presetName); + } + } + + return presets; +} diff --git a/infra/docs-gen/src/extractor/scan.mjs b/infra/docs-gen/src/extractor/scan.mjs new file mode 100644 index 00000000..1197f190 --- /dev/null +++ b/infra/docs-gen/src/extractor/scan.mjs @@ -0,0 +1,195 @@ +import {basename, relative} from 'node:path'; + +import {EXTENSION_DOC_FIELD_CONFIG} from '../config.mjs'; +import {readAllTsFiles} from '../utils.mjs'; + +import { + extractActions, + extractAddMark, + extractAddNode, + extractInputRules, + extractKeymaps, + extractMarkSpecs, + extractMdPlugins, + extractNodeSpecs, + extractPlugins, + extractSerializerSyntax, +} from './ast.mjs'; +import {extractConstants, resolveAllConstants} from './constants.mjs'; +import {extractTestExamples} from './examples.mjs'; +import {extractOptionsType} from './options.mjs'; +import { + findRootIndexFile, + findSpecsIndexFile, + isTestFile, + joinContents, + selectSerializerFiles, + selectSourceFiles, + selectSpecFiles, +} from './source-files.mjs'; + +/** + * Deduplicates extracted values while keeping the source order. + */ +function unique(values) { + return [...new Set(values)]; +} + +/** + * Reads extension files and separates production sources from tests. + */ +function readExtensionSources(extDir) { + const allFiles = readAllTsFiles(extDir); + const sourceFiles = selectSourceFiles(allFiles); + + return { + allFiles, + sourceFiles, + allContent: joinContents(sourceFiles), + }; +} + +/** + * Builds the source text used for schema extraction. + */ +function buildSchemaContent(sourceFiles, extDir) { + return joinContents(selectSpecFiles(sourceFiles, extDir)); +} + +/** + * Extracts schema nodes and marks. + */ +export function extractSchema(specContent, constants) { + return { + nodes: resolveAllConstants( + [...extractAddNode(specContent), ...extractNodeSpecs(specContent)], + constants, + ), + marks: resolveAllConstants( + [...extractAddMark(specContent), ...extractMarkSpecs(specContent)], + constants, + ), + }; +} + +/** + * Builds option declaration names preferred for an extension. + */ +function buildPreferredOptionNames(extensionName) { + return [`${extensionName}Options`, `${extensionName}SpecsOptions`]; +} + +/** + * Extracts extension options from local source declarations. + */ +export function extractOptions(sourceFiles, extDir, extensionName) { + const rootIndexFile = findRootIndexFile(sourceFiles, extDir); + const specsIndexFile = findSpecsIndexFile(sourceFiles); + const preferredNames = buildPreferredOptionNames(extensionName); + const allOptions = extractOptionsType(joinContents(sourceFiles), preferredNames); + + if (allOptions.length > 0) return allOptions; + if (rootIndexFile) return extractOptionsType(rootIndexFile.content, preferredNames); + if (specsIndexFile) return extractOptionsType(specsIndexFile.content, preferredNames); + + return []; +} + +/** + * Extracts unique markup examples from test files. + */ +export function extractMarkupExamples(files) { + return [ + ...new Set( + files + .filter((file) => isTestFile(file.path)) + .flatMap((file) => extractTestExamples(file.content)), + ), + ]; +} + +/** + * Extracts raw action identifiers. + */ +function extractActionNames(content, constants) { + return resolveAllConstants(extractActions(content), constants); +} + +/** + * Extracts unique ProseMirror plugin names. + */ +function extractPluginNames(content) { + return unique(extractPlugins(content)); +} + +/** + * Extracts unique markdown-it plugin names. + */ +function extractMdPluginNames(content) { + return unique(extractMdPlugins(content)); +} + +/** + * Extracts unique serializer output snippets. + */ +function extractSerializerHints(sourceFiles) { + const serializerContent = joinContents(selectSerializerFiles(sourceFiles)); + return unique(extractSerializerSyntax(serializerContent)); +} + +const FIELD_EXTRACTORS = { + name: ({name}) => name, + sourcePath: ({sourcePath}) => sourcePath, + category: ({category}) => category, + nodes: ({schema}) => schema.nodes, + marks: ({schema}) => schema.marks, + actions: ({allContent, constants}) => extractActionNames(allContent, constants), + keymaps: ({allContent}) => extractKeymaps(allContent), + inputRules: ({allContent}) => extractInputRules(allContent), + plugins: ({allContent}) => extractPluginNames(allContent), + mdPlugins: ({allContent}) => extractMdPluginNames(allContent), + serializerHints: ({sourceFiles}) => extractSerializerHints(sourceFiles), + options: ({sourceFiles, extDir, name}) => extractOptions(sourceFiles, extDir, name), + markupExamples: ({allFiles}) => extractMarkupExamples(allFiles), + presets: () => [], +}; + +/** + * Builds the final extension IR record. + */ +function createExtensionRecord(context) { + const record = {}; + + for (const fieldName of Object.keys(EXTENSION_DOC_FIELD_CONFIG)) { + const extractField = FIELD_EXTRACTORS[fieldName]; + if (!extractField) { + throw new Error(`Missing docs-gen field extractor: ${fieldName}`); + } + + record[fieldName] = extractField(context); + } + + return record; +} + +/** + * Scans one extension directory into raw metadata. + */ +export function scanExtension({extDir, category, repoRoot}) { + const name = basename(extDir); + const {allFiles, sourceFiles, allContent} = readExtensionSources(extDir); + const constants = extractConstants(allContent); + const schema = extractSchema(buildSchemaContent(sourceFiles, extDir), constants); + + return createExtensionRecord({ + extDir, + name, + sourcePath: relative(repoRoot, extDir), + category, + allFiles, + sourceFiles, + allContent, + constants, + schema, + }); +} diff --git a/infra/docs-gen/src/extractor/schema.test.mjs b/infra/docs-gen/src/extractor/schema.test.mjs new file mode 100644 index 00000000..175566b2 --- /dev/null +++ b/infra/docs-gen/src/extractor/schema.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {extractAddMark, extractAddNode, extractMarkSpecs, extractNodeSpecs} from './ast.mjs'; + +test('schema extraction captures legacy and granular builder methods', () => { + const content = [ + "builder.addNode('legacyNode', () => ({}));", + 'builder', + ' .addNodeSpec(NodeName.Granular, () => ({}))', + ' .addMark(MarkName.Legacy, () => ({}))', + " .addMarkSpec('granularMark', () => ({}));", + ].join('\n'); + + assert.deepEqual(extractAddNode(content), ['legacyNode']); + assert.deepEqual(extractNodeSpecs(content), ['NodeName.Granular']); + assert.deepEqual(extractAddMark(content), ['MarkName.Legacy']); + assert.deepEqual(extractMarkSpecs(content), ['granularMark']); +}); diff --git a/infra/docs-gen/src/extractor/source-files.mjs b/infra/docs-gen/src/extractor/source-files.mjs new file mode 100644 index 00000000..06f3afe8 --- /dev/null +++ b/infra/docs-gen/src/extractor/source-files.mjs @@ -0,0 +1,63 @@ +import {dirname} from 'node:path'; + +/** + * Checks whether a file path points to a TypeScript test file. + */ +export function isTestFile(path) { + return /\.test\.tsx?$/.test(path); +} + +/** + * Selects production files from all extension files. + */ +export function selectSourceFiles(files) { + return files.filter((file) => !isTestFile(file.path)); +} + +/** + * Joins file contents into one source string. + */ +export function joinContents(files) { + return files.map((file) => file.content).join('\n'); +} + +/** + * Checks whether a source file can contain schema metadata. + */ +export function isSpecSourceFile(file, extDir) { + return ( + file.path.includes('Specs') || + file.path.includes('const') || + file.path.includes('schema') || + file.path.includes('parser') || + (file.path.endsWith('/index.ts') && dirname(file.path) === extDir) + ); +} + +/** + * Selects files that can contain schema registrations. + */ +export function selectSpecFiles(files, extDir) { + return files.filter((file) => isSpecSourceFile(file, extDir)); +} + +/** + * Selects files that can contain serializer hints. + */ +export function selectSerializerFiles(files) { + return files.filter((file) => file.path.includes('serializer') || file.path.includes('Specs')); +} + +/** + * Finds an extension root index file. + */ +export function findRootIndexFile(files, extDir) { + return files.find((file) => file.path.endsWith('/index.ts') && dirname(file.path) === extDir); +} + +/** + * Finds a specs index file. + */ +export function findSpecsIndexFile(files) { + return files.find((file) => file.path.includes('Specs') && file.path.endsWith('/index.ts')); +} diff --git a/infra/docs-gen/src/generate-docs.mjs b/infra/docs-gen/src/generate-docs.mjs index ffb42097..f3ef8ddc 100644 --- a/infra/docs-gen/src/generate-docs.mjs +++ b/infra/docs-gen/src/generate-docs.mjs @@ -7,24 +7,17 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import {dirname, join, resolve} from 'node:path'; +import {dirname, join} from 'node:path'; import process from 'node:process'; -import {fileURLToPath} from 'node:url'; -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -const DOCS_DIR = join(REPO_ROOT, 'docs'); -const OUT_DIR = join(REPO_ROOT, 'tmp/docs-src'); -const GITHUB_RAW_RE = - /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; +import {DOCS_DIR, DOCS_SRC_DIR, GITHUB_RAW_RE, HEADER_RE} from './config.mjs'; // Source docs use ##### as a metadata header (not rendered). // Format: "##### Category / Title" or "##### Title" (no category). // This line is stripped from the output; the rest becomes the page content. -const HEADER_RE = /^#{5}\s+(.+)$/; /** - * Converts a string to a URL-friendly slug (lowercase, alphanumeric, hyphens). - * @param str + * Converts a string to a URL-friendly slug. */ function slugify(str) { return str @@ -34,8 +27,7 @@ function slugify(str) { } /** - * Extracts category and title from a `##### Category / Title` header line. - * @param firstLine + * Extracts category and title from a metadata header. */ function parseHeader(firstLine) { const match = firstLine.match(HEADER_RE); @@ -52,10 +44,10 @@ function parseHeader(firstLine) { /** Removes all generated content from the output directory. */ function cleanOutDir() { - if (existsSync(OUT_DIR)) { - rmSync(OUT_DIR, {recursive: true, force: true}); + if (existsSync(DOCS_SRC_DIR)) { + rmSync(DOCS_SRC_DIR, {recursive: true, force: true}); } - mkdirSync(OUT_DIR, {recursive: true}); + mkdirSync(DOCS_SRC_DIR, {recursive: true}); } /** Reads all markdown files from the source directory and parses their headers. */ @@ -94,8 +86,7 @@ function collectDocs() { } /** - * Splits docs into a category map and a top-level (uncategorized) list. - * @param docs + * Splits docs into categorized and top-level groups. */ function groupByCategory(docs) { const categories = new Map(); @@ -116,8 +107,7 @@ function groupByCategory(docs) { } /** - * Builds a relative output file path from the doc's category and title slugs. - * @param doc + * Builds a relative output file path. */ function computeOutputPath(doc) { if (doc.category) { @@ -127,8 +117,7 @@ function computeOutputPath(doc) { } /** - * Ensures no two docs resolve to the same output path; exits on collision. - * @param docs + * Ensures no two docs resolve to the same output path. */ function checkDuplicatePaths(docs) { const seen = new Map(); @@ -145,9 +134,7 @@ function checkDuplicatePaths(docs) { } /** - * Rewrites absolute GitHub raw URLs to relative paths based on doc nesting depth. - * @param content - * @param doc + * Rewrites absolute GitHub raw URLs to relative asset paths. */ function rewriteAssetUrls(content, doc) { const prefix = doc.category ? '../' : './'; @@ -155,21 +142,19 @@ function rewriteAssetUrls(content, doc) { } /** - * Writes stripped markdown content to categorized output paths. - * @param docs + * Writes stripped Markdown content to categorized output paths. */ function writeDocFiles(docs) { checkDuplicatePaths(docs); for (const doc of docs) { - const outPath = join(OUT_DIR, computeOutputPath(doc)); + const outPath = join(DOCS_SRC_DIR, computeOutputPath(doc)); mkdirSync(dirname(outPath), {recursive: true}); writeFileSync(outPath, rewriteAssetUrls(doc.content, doc)); } } /** - * Wraps a string in double quotes if it contains YAML special characters. - * @param str + * Wraps YAML values that contain special characters. */ function yamlQuote(str) { if (/[:#"'{}[\],&*?|>!%@`]/.test(str)) { @@ -179,9 +164,7 @@ function yamlQuote(str) { } /** - * Generates the `toc.yaml` table of contents for the YFM documentation site. - * @param categories - * @param topLevel + * Generates the table of contents for the documentation site. */ function generateTocYaml(categories, topLevel) { const lines = [ @@ -206,13 +189,11 @@ function generateTocYaml(categories, topLevel) { lines.push(` href: ${computeOutputPath(doc)}`); } - writeFileSync(join(OUT_DIR, 'toc.yaml'), lines.join('\n') + '\n'); + writeFileSync(join(DOCS_SRC_DIR, 'toc.yaml'), lines.join('\n') + '\n'); } /** - * Generates the `index.md` landing page with links to all doc pages. - * @param categories - * @param topLevel + * Generates the landing page with links to all doc pages. */ function generateIndexMd(categories, topLevel) { const lines = [ @@ -237,20 +218,20 @@ function generateIndexMd(categories, topLevel) { lines.push(''); } - writeFileSync(join(OUT_DIR, 'index.md'), lines.join('\n')); + writeFileSync(join(DOCS_SRC_DIR, 'index.md'), lines.join('\n')); } /** Copies the `assets/` directory from source docs to the output directory. */ function copyAssets() { const assetsDir = join(DOCS_DIR, 'assets'); if (existsSync(assetsDir)) { - cpSync(assetsDir, join(OUT_DIR, 'assets'), {recursive: true}); + cpSync(assetsDir, join(DOCS_SRC_DIR, 'assets'), {recursive: true}); } } /** Writes the `.yfm` Diplodoc config into the output directory. */ function writeYfmConfig() { - writeFileSync(join(OUT_DIR, '.yfm'), 'allowHTML: true\n'); + writeFileSync(join(DOCS_SRC_DIR, '.yfm'), 'allowHTML: true\n'); } /** Entry point: cleans output, collects docs, and generates the documentation site. */ diff --git a/infra/docs-gen/src/logger.mjs b/infra/docs-gen/src/logger.mjs new file mode 100644 index 00000000..cdb53897 --- /dev/null +++ b/infra/docs-gen/src/logger.mjs @@ -0,0 +1,33 @@ +/* eslint-disable no-console */ + +export class Logger { + /** + * Writes an informational message. + */ + info(...args) { + console.log(...args); + } + + /** + * Writes a warning message. + */ + warn(...args) { + console.warn('Warning:', ...args); + } + + /** + * Writes an error message. + */ + error(...args) { + console.error('Error:', ...args); + } + + /** + * Writes a successful completion message. + */ + success(...args) { + console.log('Done:', ...args); + } +} + +export const logger = new Logger(); diff --git a/infra/docs-gen/src/utils.mjs b/infra/docs-gen/src/utils.mjs new file mode 100644 index 00000000..2adeeded --- /dev/null +++ b/infra/docs-gen/src/utils.mjs @@ -0,0 +1,42 @@ +import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; + +/** + * Reads a UTF-8 text file. + */ +export function readText(filePath) { + return readFileSync(filePath, 'utf-8'); +} + +/** + * Lists uppercase-named child directories. + */ +export function listDirs(dir) { + if (!existsSync(dir)) return []; + + return readdirSync(dir) + .filter((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() && /^[A-Z]/.test(name); + }) + .sort(); +} + +/** + * Finds recursive directory entries matching a pattern. + */ +export function findFiles(dir, pattern) { + if (!existsSync(dir)) return []; + + return readdirSync(dir, {recursive: true}) + .filter((entry) => pattern.test(entry)) + .map((entry) => join(dir, entry)) + .sort(); +} + +/** + * Reads recursive TypeScript source files. + */ +export function readAllTsFiles(dir) { + return findFiles(dir, /\.tsx?$/).map((path) => ({path, content: readText(path)})); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 410bbfeb..2dc15fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: '@diplodoc/cli': specifier: 5.43.0 version: 5.43.0(@types/markdown-it@13.0.9)(@types/node@25.2.1)(react@18.2.0) + typescript: + specifier: catalog:ts + version: 5.9.3 infra/gulp-tasks: dependencies: From ffde0113883cc24e76ab7a2d333fd2bb20fceff7 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Sun, 14 Jun 2026 01:52:25 +0200 Subject: [PATCH 2/4] refactor(docs-gen): split extractor modules --- infra/docs-gen/EXTRACTION_PIPELINE.md | 11 +- infra/docs-gen/README.md | 11 +- infra/docs-gen/src/config.mjs | 5 + infra/docs-gen/src/extract-extension-data.mjs | 5 + infra/docs-gen/src/extractor/README.md | 59 ++ infra/docs-gen/src/extractor/actions.test.mjs | 5 + infra/docs-gen/src/extractor/ast.mjs | 524 +----------------- infra/docs-gen/src/extractor/ast/actions.mjs | 13 + infra/docs-gen/src/extractor/ast/builder.mjs | 69 +++ infra/docs-gen/src/extractor/ast/core.mjs | 111 ++++ infra/docs-gen/src/extractor/ast/factory.mjs | 48 ++ .../src/extractor/ast/input-rules.mjs | 73 +++ infra/docs-gen/src/extractor/ast/keymaps.mjs | 153 +++++ .../docs-gen/src/extractor/ast/md-plugins.mjs | 32 ++ infra/docs-gen/src/extractor/ast/plugins.mjs | 30 + infra/docs-gen/src/extractor/ast/schema.mjs | 34 ++ .../docs-gen/src/extractor/ast/serializer.mjs | 31 ++ infra/docs-gen/src/extractor/cli.test.mjs | 5 + infra/docs-gen/src/extractor/constants.mjs | 5 + .../docs-gen/src/extractor/constants.test.mjs | 5 + infra/docs-gen/src/extractor/examples.mjs | 5 + .../docs-gen/src/extractor/examples.test.mjs | 5 + .../src/extractor/extension-sources.mjs | 29 + infra/docs-gen/src/extractor/index.mjs | 5 + infra/docs-gen/src/extractor/keymaps.test.mjs | 5 + infra/docs-gen/src/extractor/markdown-gen.mjs | 5 + infra/docs-gen/src/extractor/options.mjs | 216 +------- infra/docs-gen/src/extractor/options.test.mjs | 5 + .../src/extractor/options/declarations.mjs | 47 ++ .../docs-gen/src/extractor/options/fields.mjs | 89 +++ .../src/extractor/options/resolve.mjs | 104 ++++ infra/docs-gen/src/extractor/output.mjs | 5 + infra/docs-gen/src/extractor/presets.mjs | 5 + .../docs-gen/src/extractor/record-fields.mjs | 132 +++++ infra/docs-gen/src/extractor/scan.mjs | 178 +----- infra/docs-gen/src/extractor/schema.mjs | 23 + infra/docs-gen/src/extractor/schema.test.mjs | 5 + infra/docs-gen/src/extractor/source-files.mjs | 5 + infra/docs-gen/src/generate-docs.mjs | 5 + infra/docs-gen/src/logger.mjs | 5 + infra/docs-gen/src/utils.mjs | 5 + 41 files changed, 1221 insertions(+), 891 deletions(-) create mode 100644 infra/docs-gen/src/extractor/README.md create mode 100644 infra/docs-gen/src/extractor/ast/actions.mjs create mode 100644 infra/docs-gen/src/extractor/ast/builder.mjs create mode 100644 infra/docs-gen/src/extractor/ast/core.mjs create mode 100644 infra/docs-gen/src/extractor/ast/factory.mjs create mode 100644 infra/docs-gen/src/extractor/ast/input-rules.mjs create mode 100644 infra/docs-gen/src/extractor/ast/keymaps.mjs create mode 100644 infra/docs-gen/src/extractor/ast/md-plugins.mjs create mode 100644 infra/docs-gen/src/extractor/ast/plugins.mjs create mode 100644 infra/docs-gen/src/extractor/ast/schema.mjs create mode 100644 infra/docs-gen/src/extractor/ast/serializer.mjs create mode 100644 infra/docs-gen/src/extractor/extension-sources.mjs create mode 100644 infra/docs-gen/src/extractor/options/declarations.mjs create mode 100644 infra/docs-gen/src/extractor/options/fields.mjs create mode 100644 infra/docs-gen/src/extractor/options/resolve.mjs create mode 100644 infra/docs-gen/src/extractor/record-fields.mjs create mode 100644 infra/docs-gen/src/extractor/schema.mjs diff --git a/infra/docs-gen/EXTRACTION_PIPELINE.md b/infra/docs-gen/EXTRACTION_PIPELINE.md index 077cbb5f..123df952 100644 --- a/infra/docs-gen/EXTRACTION_PIPELINE.md +++ b/infra/docs-gen/EXTRACTION_PIPELINE.md @@ -9,19 +9,20 @@ flowchart TD Filter --> Scan["scanExtension()
src/extractor/scan.mjs"] Scan --> FieldConfig["Docs field config
EXTENSION_DOC_FIELD_CONFIG"] - Scan --> Files["readExtensionSources()
src/utils.mjs"] + Scan --> Files["readExtensionSources()
src/extractor/extension-sources.mjs"] Files --> Selectors["Source file selectors
src/extractor/source-files.mjs"] Selectors --> SourceText["Production source text"] Selectors --> TestFiles["Test files"] Selectors --> SerializerFiles["Serializer and Specs files"] SourceText --> Constants["extractConstants()
src/extractor/constants.mjs"] - SourceText --> AstScanners["AST source scanners
src/extractor/ast.mjs"] + SourceText --> AstBarrel["AST extractor API
src/extractor/ast.mjs"] + AstBarrel --> AstScanners["Focused AST scanners
src/extractor/ast/*.mjs"] SourceText --> Options["Option declarations
src/extractor/options.mjs"] SerializerFiles --> SerializerHints["Serializer hints"] TestFiles --> MarkupExamples["Markup examples
src/extractor/examples.mjs"] - Constants --> Schema["Schema names
nodes and marks"] + Constants --> Schema["Schema names
src/extractor/schema.mjs"] AstScanners --> ExtractedFields["Actions, keymaps, input rules,
plugins, md plugins"] Options --> ExtractedFields FieldConfig --> IR["Extension IR record"] @@ -39,7 +40,7 @@ flowchart TD The extractor keeps orchestration and parsing separate: - `index.mjs` collects all extension directories, filters them by blacklist and `--only`, and decides when output is written. -- `scan.mjs` builds one extension record from source files and parser results. +- `scan.mjs` coordinates one extension scan; `extension-sources.mjs`, `schema.mjs`, and `record-fields.mjs` own the focused extraction steps. - `source-files.mjs` owns file selection rules. -- `ast.mjs`, `options.mjs`, `examples.mjs`, and `constants.mjs` own TypeScript AST parsing details. +- `ast.mjs` exposes focused AST scanners from `ast/*.mjs`; `options.mjs`, `examples.mjs`, and `constants.mjs` own their TypeScript AST parsing details. - `output.mjs` and `markdown-gen.mjs` own generated artifacts. diff --git a/infra/docs-gen/README.md b/infra/docs-gen/README.md index d0e44334..c53a3229 100644 --- a/infra/docs-gen/README.md +++ b/infra/docs-gen/README.md @@ -24,15 +24,22 @@ ## Extractor Files - `src/extractor/index.mjs` contains `ExtensionExtractor`, the high-level orchestrator that scans extension categories, enriches records with presets, and writes output. +- `src/extractor/README.md` maps extractor modules and field ownership in English and Russian. - `src/extractor/scan.mjs` scans one filtered extension directory and assembles the raw extension IR record from `EXTENSION_DOC_FIELD_CONFIG`. +- `src/extractor/extension-sources.mjs` reads extension files and prepares source text groups. +- `src/extractor/schema.mjs` resolves schema node and mark names from spec files. +- `src/extractor/record-fields.mjs` maps raw IR fields to their extractor functions. - `src/extractor/source-files.mjs` selects source, spec, serializer, root index, specs index, and test files from an extension directory. - `src/extractor/output.mjs` writes extracted JSON and raw Markdown artifacts. - `src/extractor/markdown-gen.mjs` renders one raw extension Markdown file from extracted metadata. - `src/extractor/presets.mjs` parses editor preset files and resolves inherited preset membership. - `src/extractor/constants.mjs` extracts string constants, enum values, object scalar members, and resolves references between them. -- `src/extractor/options.mjs` extracts extension option fields from local TypeScript option declarations. +- `src/extractor/options.mjs` extracts extension option fields through focused modules in `src/extractor/options/`. - `src/extractor/examples.mjs` extracts serializer test markup examples from `same(...)` calls and simple local string expressions. -- `src/extractor/ast.mjs` contains TypeScript AST helpers and the source scanners for schema names, actions, keymaps, plugins, input rules, markdown-it plugins, and serializer hints. +- `src/extractor/ast.mjs` re-exports all AST helpers and source scanners. +- `src/extractor/ast/core.mjs` contains generic TypeScript AST utilities. +- `src/extractor/ast/builder.mjs` recognizes extension builder call chains. +- `src/extractor/ast/actions.mjs`, `schema.mjs`, `keymaps.mjs`, `plugins.mjs`, `input-rules.mjs`, `md-plugins.mjs`, and `serializer.mjs` extract one metadata family each. - `src/extractor/actions.test.mjs` covers action extraction behavior. - `src/extractor/cli.test.mjs` covers extraction CLI argument parsing. - `src/extractor/constants.test.mjs` covers constant extraction and reference resolution behavior. diff --git a/infra/docs-gen/src/config.mjs b/infra/docs-gen/src/config.mjs index b27da08a..b547c18e 100644 --- a/infra/docs-gen/src/config.mjs +++ b/infra/docs-gen/src/config.mjs @@ -1,3 +1,8 @@ +/** + * English: Shared docs-gen paths, field contracts, extension filters, and preset definitions. + * + * Русский: Общие пути docs-gen, контракт полей, фильтры расширений и определения пресетов. + */ import {dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index 3c961439..800af467 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -1,4 +1,9 @@ #!/usr/bin/env node +/** + * English: CLI entry point for raw extension metadata extraction. + * + * Русский: CLI-точка входа для извлечения сырых метаданных расширений. + */ import {isAbsolute, join} from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; diff --git a/infra/docs-gen/src/extractor/README.md b/infra/docs-gen/src/extractor/README.md new file mode 100644 index 00000000..d41d894c --- /dev/null +++ b/infra/docs-gen/src/extractor/README.md @@ -0,0 +1,59 @@ +# Extractor Module Guide + +## English + +`infra/docs-gen/src/extractor` collects raw extension metadata for documentation. The +pipeline intentionally keeps each step narrow: + +- `index.mjs` orchestrates discovery, filtering, scanning, preset enrichment, and output. +- `scan.mjs` builds one extension record from `EXTENSION_DOC_FIELD_CONFIG`. +- `extension-sources.mjs` reads extension source files and builds source text groups. +- `schema.mjs` resolves schema node and mark names from spec source. +- `record-fields.mjs` maps configured raw fields to focused extractor functions. +- `source-files.mjs` chooses source, spec, serializer, and test files. +- `ast.mjs` is the public barrel for AST-based extraction helpers. +- `ast/core.mjs` contains generic TypeScript AST utilities. +- `ast/builder.mjs` recognizes extension builder call chains. +- `ast/actions.mjs`, `ast/schema.mjs`, `ast/keymaps.mjs`, `ast/plugins.mjs`, + `ast/input-rules.mjs`, `ast/md-plugins.mjs`, and `ast/serializer.mjs` extract one + metadata family each. +- `constants.mjs` resolves local string constants, enums, and scalar object members. +- `options.mjs` is the public entry for local `*Options` TypeScript declarations. +- `options/declarations.mjs`, `options/fields.mjs`, and `options/resolve.mjs` split option + parsing, field shaping, and reference resolution. +- `examples.mjs` extracts serializer examples from `same(...)` test helpers. +- `presets.mjs` maps extensions to editor presets. +- `markdown-gen.mjs` and `output.mjs` render and write the raw artifacts. + +`EXTENSION_DOC_FIELD_CONFIG` in `../config.mjs` is the contract for which fields are +written and where each field comes from. Add a field there first, then add the matching +extractor in `scan.mjs`. + +## Русский + +`infra/docs-gen/src/extractor` собирает сырые метаданные расширений для документации. +Пайплайн специально разбит на узкие шаги: + +- `index.mjs` управляет обнаружением, фильтрацией, сканированием, пресетами и выводом. +- `scan.mjs` собирает одну запись расширения по `EXTENSION_DOC_FIELD_CONFIG`. +- `extension-sources.mjs` читает source files расширения и собирает группы source text. +- `schema.mjs` резолвит имена schema node и mark из spec source. +- `record-fields.mjs` связывает сконфигурированные raw fields с узкими extractor-функциями. +- `source-files.mjs` выбирает source, spec, serializer и test файлы. +- `ast.mjs` публично экспортирует AST-based helper-ы. +- `ast/core.mjs` содержит общие утилиты TypeScript AST. +- `ast/builder.mjs` распознает цепочки вызовов extension builder. +- `ast/actions.mjs`, `ast/schema.mjs`, `ast/keymaps.mjs`, `ast/plugins.mjs`, + `ast/input-rules.mjs`, `ast/md-plugins.mjs` и `ast/serializer.mjs` извлекают по + одной группе метаданных. +- `constants.mjs` резолвит локальные строковые константы, enum-ы и scalar object members. +- `options.mjs` публично запускает разбор локальных TypeScript declarations вида `*Options`. +- `options/declarations.mjs`, `options/fields.mjs` и `options/resolve.mjs` разделяют + parsing declarations, формирование fields и reference resolution. +- `examples.mjs` извлекает serializer examples из test helper-ов `same(...)`. +- `presets.mjs` сопоставляет расширения с editor presets. +- `markdown-gen.mjs` и `output.mjs` рендерят и записывают raw artifacts. + +`EXTENSION_DOC_FIELD_CONFIG` в `../config.mjs` задает контракт: какие поля пишутся и +откуда каждое поле берется. Новое поле сначала добавляется туда, затем для него +добавляется extractor в `scan.mjs`. diff --git a/infra/docs-gen/src/extractor/actions.test.mjs b/infra/docs-gen/src/extractor/actions.test.mjs index 410dd16a..46c9269c 100644 --- a/infra/docs-gen/src/extractor/actions.test.mjs +++ b/infra/docs-gen/src/extractor/actions.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for AST-based action extraction. + * + * Русский: Unit-покрытие AST-based извлечения actions. + */ import assert from 'node:assert/strict'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/ast.mjs b/infra/docs-gen/src/extractor/ast.mjs index e373faab..52525c46 100644 --- a/infra/docs-gen/src/extractor/ast.mjs +++ b/infra/docs-gen/src/extractor/ast.mjs @@ -1,504 +1,22 @@ -import ts from 'typescript'; - -const BUILDER_ROOT_NAME = 'builder'; - -const BUILDER_CHAIN_METHODS = new Set([ - 'addAction', - 'addKeymap', - 'addMark', - 'addMarkSpec', - 'addMarkdownTokenParserSpec', - 'addNode', - 'addNodeSerializerSpec', - 'addNodeSpec', - 'addPlugin', - 'configureMd', -]); - -const INPUT_RULE_FACTORIES = new Set([ - 'inlineNodeInputRule', - 'markInputRule', - 'nodeInputRule', - 'textblockTypeInputRule', - 'wrappingInputRule', -]); - /** - * Parses TypeScript or TSX source into a traversable AST. - */ -export function parseSource(content, fileName = 'source.tsx') { - return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -} - -/** - * Visits every AST node depth-first. - */ -export function forEachNode(root, callback) { - const visit = (node) => { - callback(node); - ts.forEachChild(node, visit); - }; - - visit(root); -} - -/** - * Deduplicates values while preserving source order. - */ -export function unique(values) { - return [...new Set(values.filter(Boolean))]; -} - -/** - * Removes syntax wrappers that do not affect a static expression value. - */ -export function unwrapExpression(expression) { - let current = expression; - - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) || - ts.isTypeAssertionExpression(current) - ) { - current = current.expression; - } - - return current; -} - -/** - * Reads a static property name from an object-like AST node. - */ -export function getStaticPropertyName(name, {allowComputedLiteral = false} = {}) { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { - return name.text; - } - - if ( - allowComputedLiteral && - ts.isComputedPropertyName(name) && - ts.isStringLiteralLike(unwrapExpression(name.expression)) - ) { - return unwrapExpression(name.expression).text; - } - - return null; -} - -/** - * Resolves syntax that names a constant, enum member, or string literal. - */ -export function getExpressionName(expression, sourceFile) { - const current = unwrapExpression(expression); - - if (ts.isStringLiteralLike(current)) return current.text; - if (ts.isIdentifier(current)) return current.text; - - if (ts.isPropertyAccessExpression(current)) { - const baseName = getExpressionName(current.expression, sourceFile); - return baseName ? `${baseName}.${current.name.text}` : current.getText(sourceFile).trim(); - } - - if ( - ts.isElementAccessExpression(current) && - ts.isStringLiteralLike(current.argumentExpression) - ) { - const baseName = getExpressionName(current.expression, sourceFile); - return baseName ? `${baseName}.${current.argumentExpression.text}` : null; - } - - return null; -} - -/** - * Resolves a literal string expression. - */ -export function getStringValue(expression) { - const current = unwrapExpression(expression); - return ts.isStringLiteralLike(current) ? current.text : null; -} - -/** - * Checks whether an expression is the root builder or a builder call chain. - */ -function isBuilderExpression(expression) { - const current = unwrapExpression(expression); - - if (ts.isIdentifier(current)) return current.text === BUILDER_ROOT_NAME; - - if (!ts.isCallExpression(current)) return false; - if (!ts.isPropertyAccessExpression(current.expression)) return false; - if (!BUILDER_CHAIN_METHODS.has(current.expression.name.text)) return false; - - return isBuilderExpression(current.expression.expression); -} - -/** - * Returns the called property name for a call expression. - */ -function getCallPropertyName(callExpression) { - const expression = unwrapExpression(callExpression.expression); - return ts.isPropertyAccessExpression(expression) ? expression.name.text : null; -} - -/** - * Checks whether a call is made on the extension builder chain. - */ -function isBuilderMethodCall(callExpression, methodNames) { - const expression = unwrapExpression(callExpression.expression); - - return ( - ts.isPropertyAccessExpression(expression) && - methodNames.has(expression.name.text) && - isBuilderExpression(expression.expression) - ); -} - -/** - * Extracts first arguments from extension builder method calls. - */ -function extractBuilderCallFirstArgs(content, methodNames) { - const sourceFile = parseSource(content); - const names = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, methodNames)) return; - - const firstArg = node.arguments[0]; - const name = firstArg ? getExpressionName(firstArg, sourceFile) : null; - if (name) names.push(name); - }); - - return unique(names); -} - -/** - * Extracts ProseMirror node registrations from builder calls. - */ -export function extractAddNode(content) { - return extractBuilderCallFirstArgs(content, new Set(['addNode'])); -} - -/** - * Extracts ProseMirror mark registrations from builder calls. - */ -export function extractAddMark(content) { - return extractBuilderCallFirstArgs(content, new Set(['addMark'])); -} - -/** - * Extracts node names from granular node spec registrations. - */ -export function extractNodeSpecs(content) { - return extractBuilderCallFirstArgs(content, new Set(['addNodeSpec'])); -} - -/** - * Extracts mark names from granular mark spec registrations. - */ -export function extractMarkSpecs(content) { - return extractBuilderCallFirstArgs(content, new Set(['addMarkSpec'])); -} - -/** - * Extracts editor action identifiers from builder calls. - */ -export function extractActions(content) { - return extractBuilderCallFirstArgs(content, new Set(['addAction'])); -} - -/** - * Reads a returned expression from a function-like callback. - */ -function getStaticReturnExpression(callback) { - const body = callback.body; - if (!ts.isBlock(body)) return unwrapExpression(body); - - for (const statement of body.statements) { - if (ts.isReturnStatement(statement)) return statement.expression || null; - } - - return null; -} - -/** - * Describes a factory callback or plugin expression with a stable identifier. - */ -function describeFactoryExpression(expression, sourceFile) { - const current = unwrapExpression(expression); - - if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) { - return getExpressionName(current, sourceFile); - } - - if (ts.isCallExpression(current)) { - return getExpressionName(current.expression, sourceFile); - } - - if (ts.isNewExpression(current)) { - return getExpressionName(current.expression, sourceFile); - } - - if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { - const returned = getStaticReturnExpression(current); - return returned ? describeFactoryExpression(returned, sourceFile) : null; - } - - return null; -} - -/** - * Extracts ProseMirror plugin factory names. - */ -export function extractPlugins(content) { - const sourceFile = parseSource(content); - const plugins = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addPlugin']))) { - return; - } - - const firstArg = node.arguments[0]; - const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; - if (pluginName) plugins.push(pluginName); - }); - - return unique(plugins); -} - -/** - * Extracts static keys from an object literal. - */ -function extractObjectLiteralKeys(objectLiteral, knownObjects = new Map()) { - const keys = []; - - for (const property of objectLiteral.properties) { - if (ts.isSpreadAssignment(property)) { - const spreadName = ts.isIdentifier(property.expression) - ? property.expression.text - : null; - if (spreadName && knownObjects.has(spreadName)) { - keys.push(...knownObjects.get(spreadName)); - } - continue; - } - - if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) { - continue; - } - - const key = getStaticPropertyName(property.name); - if (key) keys.push(key); - } - - return keys; -} - -/** - * Reads object-literal keys from a return expression or local object reference. - */ -function extractKeymapExpressionKeys(expression, knownObjects = new Map()) { - const current = unwrapExpression(expression); - - if (ts.isObjectLiteralExpression(current)) { - return extractObjectLiteralKeys(current, knownObjects); - } - - if (ts.isIdentifier(current) && knownObjects.has(current.text)) { - return knownObjects.get(current.text); - } - - return []; -} - -/** - * Registers one top-level object literal binding from a callback block. - */ -function collectObjectBinding(statement, knownObjects) { - if (!ts.isVariableStatement(statement)) return; - - for (const declaration of statement.declarationList.declarations) { - if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; - - const initializer = unwrapExpression(declaration.initializer); - if (ts.isObjectLiteralExpression(initializer)) { - knownObjects.set( - declaration.name.text, - unique(extractObjectLiteralKeys(initializer, knownObjects)), - ); - } - } -} - -/** - * Registers a static key assignment into a known object binding. - */ -function collectObjectAssignment(statement, knownObjects) { - if (!ts.isExpressionStatement(statement)) return; - - const expression = unwrapExpression(statement.expression); - if ( - !ts.isBinaryExpression(expression) || - expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken - ) { - return; - } - - const left = unwrapExpression(expression.left); - let objectName = null; - let keyName = null; - - if (ts.isPropertyAccessExpression(left)) { - objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; - keyName = left.name.text; - } else if (ts.isElementAccessExpression(left)) { - objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; - keyName = ts.isStringLiteralLike(left.argumentExpression) - ? left.argumentExpression.text - : null; - } - - if (!objectName || !keyName || !knownObjects.has(objectName)) return; - - knownObjects.set(objectName, unique([...knownObjects.get(objectName), keyName])); -} - -/** - * Extracts static key bindings from an addKeymap callback. - */ -function extractKeymapCallbackKeys(callback) { - const body = unwrapExpression(callback.body); - if (!ts.isBlock(body)) return extractKeymapExpressionKeys(body); - - const knownObjects = new Map(); - for (const statement of body.statements) { - collectObjectBinding(statement, knownObjects); - collectObjectAssignment(statement, knownObjects); - - if (ts.isReturnStatement(statement) && statement.expression) { - return extractKeymapExpressionKeys(statement.expression, knownObjects); - } - } - - return []; -} - -/** - * Extracts static key bindings from addKeymap callbacks. - */ -export function extractKeymaps(content) { - const sourceFile = parseSource(content); - const keymaps = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addKeymap']))) { - return; - } - - const callback = unwrapExpression(node.arguments[0]); - if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) { - keymaps.push(...extractKeymapCallbackKeys(callback)); - } - }); - - return unique(keymaps); -} - -/** - * Extracts one input-rule syntax descriptor from a factory call. - */ -function describeInputRuleCall(callExpression) { - const firstArg = callExpression.arguments[0]; - if (!firstArg) return null; - - const current = unwrapExpression(firstArg); - if (current.kind === ts.SyntaxKind.RegularExpressionLiteral) { - return current.getText(callExpression.getSourceFile()); - } - - if (ts.isObjectLiteralExpression(current)) { - let open = null; - let close = null; - - for (const property of current.properties) { - if (!ts.isPropertyAssignment(property)) continue; - - const key = getStaticPropertyName(property.name); - if (key === 'open') open = getStringValue(property.initializer); - if (key === 'close') close = getStringValue(property.initializer); - } - - return open !== null && close !== null ? `${open}...${close}` : null; - } - - return null; -} - -/** - * Extracts input-rule syntax patterns. - */ -export function extractInputRules(content) { - const sourceFile = parseSource(content); - const rules = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node)) return; - - const expression = unwrapExpression(node.expression); - if (!ts.isIdentifier(expression) || !INPUT_RULE_FACTORIES.has(expression.text)) return; - - const rule = describeInputRuleCall(node); - if (rule) rules.push(rule); - }); - - return unique(rules); -} - -/** - * Extracts markdown-it plugin registrations. - */ -export function extractMdPlugins(content) { - const sourceFile = parseSource(content); - const plugins = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node) || getCallPropertyName(node) !== 'use') return; - - const expression = unwrapExpression(node.expression); - if (!ts.isPropertyAccessExpression(expression) || !ts.isIdentifier(expression.expression)) - return; - if (expression.expression.text !== 'md') return; - - const firstArg = node.arguments[0]; - const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; - if (pluginName) plugins.push(pluginName); - }); - - return unique(plugins); -} - -/** - * Extracts serializer output snippets. - */ -export function extractSerializerSyntax(content) { - const sourceFile = parseSource(content); - const snippets = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isCallExpression(node)) return; - - const expression = unwrapExpression(node.expression); - if (!ts.isPropertyAccessExpression(expression)) return; - if (!ts.isIdentifier(expression.expression) || expression.expression.text !== 'state') - return; - if (expression.name.text !== 'write' && expression.name.text !== 'text') return; - - const snippet = node.arguments[0] ? getStringValue(node.arguments[0]) : null; - if (snippet?.trim()) snippets.push(snippet); - }); - - return unique(snippets); -} + * English: Barrel module that exposes all AST-based extractor helpers. + * + * Русский: Barrel-модуль, экспортирующий все AST-based helper-ы extractor-а. + */ +export {extractActions} from './ast/actions.mjs'; +export { + forEachNode, + getCallPropertyName, + getExpressionName, + getStaticPropertyName, + getStringValue, + parseSource, + unique, + unwrapExpression, +} from './ast/core.mjs'; +export {extractInputRules} from './ast/input-rules.mjs'; +export {extractKeymaps} from './ast/keymaps.mjs'; +export {extractMdPlugins} from './ast/md-plugins.mjs'; +export {extractPlugins} from './ast/plugins.mjs'; +export {extractSerializerSyntax} from './ast/serializer.mjs'; +export {extractAddMark, extractAddNode, extractMarkSpecs, extractNodeSpecs} from './ast/schema.mjs'; diff --git a/infra/docs-gen/src/extractor/ast/actions.mjs b/infra/docs-gen/src/extractor/ast/actions.mjs new file mode 100644 index 00000000..24194403 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/actions.mjs @@ -0,0 +1,13 @@ +/** + * English: AST scanner for editor action identifiers registered by extensions. + * + * Русский: AST-сканер идентификаторов editor actions, регистрируемых расширениями. + */ +import {extractBuilderCallFirstArgs} from './builder.mjs'; + +/** + * Extracts editor action identifiers from builder calls. + */ +export function extractActions(content) { + return extractBuilderCallFirstArgs(content, new Set(['addAction'])); +} diff --git a/infra/docs-gen/src/extractor/ast/builder.mjs b/infra/docs-gen/src/extractor/ast/builder.mjs new file mode 100644 index 00000000..937f9b2d --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/builder.mjs @@ -0,0 +1,69 @@ +/** + * English: Helpers for recognizing extension builder call chains in TypeScript AST. + * + * Русский: Хелперы для распознавания цепочек вызовов extension builder в TypeScript AST. + */ +import ts from 'typescript'; + +import {forEachNode, getExpressionName, parseSource, unique, unwrapExpression} from './core.mjs'; + +const BUILDER_ROOT_NAME = 'builder'; + +const BUILDER_CHAIN_METHODS = new Set([ + 'addAction', + 'addKeymap', + 'addMark', + 'addMarkSpec', + 'addMarkdownTokenParserSpec', + 'addNode', + 'addNodeSerializerSpec', + 'addNodeSpec', + 'addPlugin', + 'configureMd', +]); + +/** + * Checks whether an expression is the root builder or a builder call chain. + */ +function isBuilderExpression(expression) { + const current = unwrapExpression(expression); + + if (ts.isIdentifier(current)) return current.text === BUILDER_ROOT_NAME; + + if (!ts.isCallExpression(current)) return false; + if (!ts.isPropertyAccessExpression(current.expression)) return false; + if (!BUILDER_CHAIN_METHODS.has(current.expression.name.text)) return false; + + return isBuilderExpression(current.expression.expression); +} + +/** + * Checks whether a call is made on the extension builder chain. + */ +export function isBuilderMethodCall(callExpression, methodNames) { + const expression = unwrapExpression(callExpression.expression); + + return ( + ts.isPropertyAccessExpression(expression) && + methodNames.has(expression.name.text) && + isBuilderExpression(expression.expression) + ); +} + +/** + * Extracts first arguments from extension builder method calls. + */ +export function extractBuilderCallFirstArgs(content, methodNames) { + const sourceFile = parseSource(content); + const names = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, methodNames)) return; + + const firstArg = node.arguments[0]; + const name = firstArg ? getExpressionName(firstArg, sourceFile) : null; + if (name) names.push(name); + }); + + return unique(names); +} diff --git a/infra/docs-gen/src/extractor/ast/core.mjs b/infra/docs-gen/src/extractor/ast/core.mjs new file mode 100644 index 00000000..80f880d3 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/core.mjs @@ -0,0 +1,111 @@ +/** + * English: Shared TypeScript AST primitives used by docs-gen source scanners. + * + * Русский: Общие примитивы TypeScript AST для сканеров исходников docs-gen. + */ +import ts from 'typescript'; + +/** + * Parses TypeScript or TSX source into a traversable AST. + */ +export function parseSource(content, fileName = 'source.tsx') { + return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +/** + * Visits every AST node depth-first. + */ +export function forEachNode(root, callback) { + const visit = (node) => { + callback(node); + ts.forEachChild(node, visit); + }; + + visit(root); +} + +/** + * Deduplicates values while preserving source order. + */ +export function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +/** + * Removes syntax wrappers that do not affect a static expression value. + */ +export function unwrapExpression(expression) { + let current = expression; + + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + + return current; +} + +/** + * Reads a static property name from an object-like AST node. + */ +export function getStaticPropertyName(name, {allowComputedLiteral = false} = {}) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + + if ( + allowComputedLiteral && + ts.isComputedPropertyName(name) && + ts.isStringLiteralLike(unwrapExpression(name.expression)) + ) { + return unwrapExpression(name.expression).text; + } + + return null; +} + +/** + * Resolves syntax that names a constant, enum member, or string literal. + */ +export function getExpressionName(expression, sourceFile) { + const current = unwrapExpression(expression); + + if (ts.isStringLiteralLike(current)) return current.text; + if (ts.isIdentifier(current)) return current.text; + + if (ts.isPropertyAccessExpression(current)) { + const baseName = getExpressionName(current.expression, sourceFile); + return baseName ? `${baseName}.${current.name.text}` : current.getText(sourceFile).trim(); + } + + if ( + ts.isElementAccessExpression(current) && + ts.isStringLiteralLike(current.argumentExpression) + ) { + const baseName = getExpressionName(current.expression, sourceFile); + return baseName ? `${baseName}.${current.argumentExpression.text}` : null; + } + + return null; +} + +/** + * Resolves a literal string expression. + */ +export function getStringValue(expression) { + const current = unwrapExpression(expression); + return ts.isStringLiteralLike(current) ? current.text : null; +} + +/** + * Returns the called property name for a call expression. + */ +export function getCallPropertyName(callExpression) { + const expression = unwrapExpression(callExpression.expression); + return ts.isPropertyAccessExpression(expression) ? expression.name.text : null; +} diff --git a/infra/docs-gen/src/extractor/ast/factory.mjs b/infra/docs-gen/src/extractor/ast/factory.mjs new file mode 100644 index 00000000..ca28b957 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/factory.mjs @@ -0,0 +1,48 @@ +/** + * English: Static naming helpers for plugin and factory callback expressions. + * + * Русский: Хелперы статического именования plugin/factory callback выражений. + */ +import ts from 'typescript'; + +import {getExpressionName, unwrapExpression} from './core.mjs'; + +/** + * Reads a returned expression from a function-like callback. + */ +function getStaticReturnExpression(callback) { + const body = callback.body; + if (!ts.isBlock(body)) return unwrapExpression(body); + + for (const statement of body.statements) { + if (ts.isReturnStatement(statement)) return statement.expression || null; + } + + return null; +} + +/** + * Describes a factory callback or plugin expression with a stable identifier. + */ +export function describeFactoryExpression(expression, sourceFile) { + const current = unwrapExpression(expression); + + if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) { + return getExpressionName(current, sourceFile); + } + + if (ts.isCallExpression(current)) { + return getExpressionName(current.expression, sourceFile); + } + + if (ts.isNewExpression(current)) { + return getExpressionName(current.expression, sourceFile); + } + + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const returned = getStaticReturnExpression(current); + return returned ? describeFactoryExpression(returned, sourceFile) : null; + } + + return null; +} diff --git a/infra/docs-gen/src/extractor/ast/input-rules.mjs b/infra/docs-gen/src/extractor/ast/input-rules.mjs new file mode 100644 index 00000000..dde4f066 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/input-rules.mjs @@ -0,0 +1,73 @@ +/** + * English: AST scanner for markdown input-rule syntax hints. + * + * Русский: AST-сканер markdown input-rule подсказок синтаксиса. + */ +import ts from 'typescript'; + +import { + forEachNode, + getStaticPropertyName, + getStringValue, + parseSource, + unique, + unwrapExpression, +} from './core.mjs'; + +const INPUT_RULE_FACTORIES = new Set([ + 'inlineNodeInputRule', + 'markInputRule', + 'nodeInputRule', + 'textblockTypeInputRule', + 'wrappingInputRule', +]); + +/** + * Extracts one input-rule syntax descriptor from a factory call. + */ +function describeInputRuleCall(callExpression) { + const firstArg = callExpression.arguments[0]; + if (!firstArg) return null; + + const current = unwrapExpression(firstArg); + if (current.kind === ts.SyntaxKind.RegularExpressionLiteral) { + return current.getText(callExpression.getSourceFile()); + } + + if (ts.isObjectLiteralExpression(current)) { + let open = null; + let close = null; + + for (const property of current.properties) { + if (!ts.isPropertyAssignment(property)) continue; + + const key = getStaticPropertyName(property.name); + if (key === 'open') open = getStringValue(property.initializer); + if (key === 'close') close = getStringValue(property.initializer); + } + + return open !== null && close !== null ? `${open}...${close}` : null; + } + + return null; +} + +/** + * Extracts input-rule syntax patterns. + */ +export function extractInputRules(content) { + const sourceFile = parseSource(content); + const rules = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + + const expression = unwrapExpression(node.expression); + if (!ts.isIdentifier(expression) || !INPUT_RULE_FACTORIES.has(expression.text)) return; + + const rule = describeInputRuleCall(node); + if (rule) rules.push(rule); + }); + + return unique(rules); +} diff --git a/infra/docs-gen/src/extractor/ast/keymaps.mjs b/infra/docs-gen/src/extractor/ast/keymaps.mjs new file mode 100644 index 00000000..b03e7455 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/keymaps.mjs @@ -0,0 +1,153 @@ +/** + * English: AST scanner for static key bindings returned from addKeymap callbacks. + * + * Русский: AST-сканер статичных key bindings из callback-ов addKeymap. + */ +import ts from 'typescript'; + +import {isBuilderMethodCall} from './builder.mjs'; +import { + forEachNode, + getStaticPropertyName, + parseSource, + unique, + unwrapExpression, +} from './core.mjs'; + +/** + * Extracts static keys from an object literal. + */ +function extractObjectLiteralKeys(objectLiteral, knownObjects = new Map()) { + const keys = []; + + for (const property of objectLiteral.properties) { + if (ts.isSpreadAssignment(property)) { + const spreadName = ts.isIdentifier(property.expression) + ? property.expression.text + : null; + if (spreadName && knownObjects.has(spreadName)) { + keys.push(...knownObjects.get(spreadName)); + } + continue; + } + + if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) { + continue; + } + + const key = getStaticPropertyName(property.name); + if (key) keys.push(key); + } + + return keys; +} + +/** + * Reads object-literal keys from a return expression or local object reference. + */ +function extractKeymapExpressionKeys(expression, knownObjects = new Map()) { + const current = unwrapExpression(expression); + + if (ts.isObjectLiteralExpression(current)) { + return extractObjectLiteralKeys(current, knownObjects); + } + + if (ts.isIdentifier(current) && knownObjects.has(current.text)) { + return knownObjects.get(current.text); + } + + return []; +} + +/** + * Registers one top-level object literal binding from a callback block. + */ +function collectObjectBinding(statement, knownObjects) { + if (!ts.isVariableStatement(statement)) return; + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + + const initializer = unwrapExpression(declaration.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + knownObjects.set( + declaration.name.text, + unique(extractObjectLiteralKeys(initializer, knownObjects)), + ); + } + } +} + +/** + * Registers a static key assignment into a known object binding. + */ +function collectObjectAssignment(statement, knownObjects) { + if (!ts.isExpressionStatement(statement)) return; + + const expression = unwrapExpression(statement.expression); + if ( + !ts.isBinaryExpression(expression) || + expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) { + return; + } + + const left = unwrapExpression(expression.left); + let objectName = null; + let keyName = null; + + if (ts.isPropertyAccessExpression(left)) { + objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; + keyName = left.name.text; + } else if (ts.isElementAccessExpression(left)) { + objectName = ts.isIdentifier(left.expression) ? left.expression.text : null; + keyName = ts.isStringLiteralLike(left.argumentExpression) + ? left.argumentExpression.text + : null; + } + + if (!objectName || !keyName || !knownObjects.has(objectName)) return; + + knownObjects.set(objectName, unique([...knownObjects.get(objectName), keyName])); +} + +/** + * Extracts static key bindings from an addKeymap callback. + */ +function extractKeymapCallbackKeys(callback) { + const body = unwrapExpression(callback.body); + if (!ts.isBlock(body)) return extractKeymapExpressionKeys(body); + + const knownObjects = new Map(); + for (const statement of body.statements) { + collectObjectBinding(statement, knownObjects); + collectObjectAssignment(statement, knownObjects); + + if (ts.isReturnStatement(statement) && statement.expression) { + return extractKeymapExpressionKeys(statement.expression, knownObjects); + } + } + + return []; +} + +/** + * Extracts static key bindings from addKeymap callbacks. + */ +export function extractKeymaps(content) { + const sourceFile = parseSource(content); + const keymaps = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addKeymap']))) { + return; + } + + const callback = unwrapExpression(node.arguments[0]); + if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) { + keymaps.push(...extractKeymapCallbackKeys(callback)); + } + }); + + return unique(keymaps); +} diff --git a/infra/docs-gen/src/extractor/ast/md-plugins.mjs b/infra/docs-gen/src/extractor/ast/md-plugins.mjs new file mode 100644 index 00000000..88b7410b --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/md-plugins.mjs @@ -0,0 +1,32 @@ +/** + * English: AST scanner for markdown-it plugin registrations inside configureMd callbacks. + * + * Русский: AST-сканер регистраций markdown-it plugins внутри callback-ов configureMd. + */ +import ts from 'typescript'; + +import {forEachNode, getCallPropertyName, parseSource, unique, unwrapExpression} from './core.mjs'; +import {describeFactoryExpression} from './factory.mjs'; + +/** + * Extracts markdown-it plugin registrations. + */ +export function extractMdPlugins(content) { + const sourceFile = parseSource(content); + const plugins = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || getCallPropertyName(node) !== 'use') return; + + const expression = unwrapExpression(node.expression); + if (!ts.isPropertyAccessExpression(expression) || !ts.isIdentifier(expression.expression)) + return; + if (expression.expression.text !== 'md') return; + + const firstArg = node.arguments[0]; + const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; + if (pluginName) plugins.push(pluginName); + }); + + return unique(plugins); +} diff --git a/infra/docs-gen/src/extractor/ast/plugins.mjs b/infra/docs-gen/src/extractor/ast/plugins.mjs new file mode 100644 index 00000000..486b3b8a --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/plugins.mjs @@ -0,0 +1,30 @@ +/** + * English: AST scanner for ProseMirror plugin registrations. + * + * Русский: AST-сканер регистраций ProseMirror plugins. + */ +import ts from 'typescript'; + +import {isBuilderMethodCall} from './builder.mjs'; +import {forEachNode, parseSource, unique} from './core.mjs'; +import {describeFactoryExpression} from './factory.mjs'; + +/** + * Extracts ProseMirror plugin factory names. + */ +export function extractPlugins(content) { + const sourceFile = parseSource(content); + const plugins = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !isBuilderMethodCall(node, new Set(['addPlugin']))) { + return; + } + + const firstArg = node.arguments[0]; + const pluginName = firstArg ? describeFactoryExpression(firstArg, sourceFile) : null; + if (pluginName) plugins.push(pluginName); + }); + + return unique(plugins); +} diff --git a/infra/docs-gen/src/extractor/ast/schema.mjs b/infra/docs-gen/src/extractor/ast/schema.mjs new file mode 100644 index 00000000..deddccbc --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/schema.mjs @@ -0,0 +1,34 @@ +/** + * English: AST scanners for schema node and mark registrations. + * + * Русский: AST-сканеры регистраций schema node и mark. + */ +import {extractBuilderCallFirstArgs} from './builder.mjs'; + +/** + * Extracts ProseMirror node registrations from builder calls. + */ +export function extractAddNode(content) { + return extractBuilderCallFirstArgs(content, new Set(['addNode'])); +} + +/** + * Extracts ProseMirror mark registrations from builder calls. + */ +export function extractAddMark(content) { + return extractBuilderCallFirstArgs(content, new Set(['addMark'])); +} + +/** + * Extracts node names from granular node spec registrations. + */ +export function extractNodeSpecs(content) { + return extractBuilderCallFirstArgs(content, new Set(['addNodeSpec'])); +} + +/** + * Extracts mark names from granular mark spec registrations. + */ +export function extractMarkSpecs(content) { + return extractBuilderCallFirstArgs(content, new Set(['addMarkSpec'])); +} diff --git a/infra/docs-gen/src/extractor/ast/serializer.mjs b/infra/docs-gen/src/extractor/ast/serializer.mjs new file mode 100644 index 00000000..21c7b91d --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/serializer.mjs @@ -0,0 +1,31 @@ +/** + * English: AST scanner for serializer markdown output hints. + * + * Русский: AST-сканер markdown-подсказок из serializer output. + */ +import ts from 'typescript'; + +import {forEachNode, getStringValue, parseSource, unique, unwrapExpression} from './core.mjs'; + +/** + * Extracts serializer output snippets. + */ +export function extractSerializerSyntax(content) { + const sourceFile = parseSource(content); + const snippets = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + + const expression = unwrapExpression(node.expression); + if (!ts.isPropertyAccessExpression(expression)) return; + if (!ts.isIdentifier(expression.expression) || expression.expression.text !== 'state') + return; + if (expression.name.text !== 'write' && expression.name.text !== 'text') return; + + const snippet = node.arguments[0] ? getStringValue(node.arguments[0]) : null; + if (snippet?.trim()) snippets.push(snippet); + }); + + return unique(snippets); +} diff --git a/infra/docs-gen/src/extractor/cli.test.mjs b/infra/docs-gen/src/extractor/cli.test.mjs index 02f8f9e4..1069233c 100644 --- a/infra/docs-gen/src/extractor/cli.test.mjs +++ b/infra/docs-gen/src/extractor/cli.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for extension extraction CLI argument parsing. + * + * Русский: Unit-покрытие парсинга аргументов CLI извлечения расширений. + */ import assert from 'node:assert/strict'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/constants.mjs b/infra/docs-gen/src/extractor/constants.mjs index a4a0f9e8..ddfab6f9 100644 --- a/infra/docs-gen/src/extractor/constants.mjs +++ b/infra/docs-gen/src/extractor/constants.mjs @@ -1,3 +1,8 @@ +/** + * English: Resolves local constants used by AST extractor fields. + * + * Русский: Резолвит локальные константы, используемые полями AST extractor-а. + */ import ts from 'typescript'; import { diff --git a/infra/docs-gen/src/extractor/constants.test.mjs b/infra/docs-gen/src/extractor/constants.test.mjs index 30fbd40e..838fc3ea 100644 --- a/infra/docs-gen/src/extractor/constants.test.mjs +++ b/infra/docs-gen/src/extractor/constants.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for constant extraction and reference resolution. + * + * Русский: Unit-покрытие извлечения констант и резолва ссылок. + */ import assert from 'node:assert/strict'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/examples.mjs b/infra/docs-gen/src/extractor/examples.mjs index efc9ae83..8df050dc 100644 --- a/infra/docs-gen/src/extractor/examples.mjs +++ b/infra/docs-gen/src/extractor/examples.mjs @@ -1,3 +1,8 @@ +/** + * English: Extracts markdown examples from serializer tests using TypeScript AST. + * + * Русский: Извлекает markdown examples из serializer tests через TypeScript AST. + */ import ts from 'typescript'; import {forEachNode, parseSource, unwrapExpression} from './ast.mjs'; diff --git a/infra/docs-gen/src/extractor/examples.test.mjs b/infra/docs-gen/src/extractor/examples.test.mjs index 82bf82bd..647d760f 100644 --- a/infra/docs-gen/src/extractor/examples.test.mjs +++ b/infra/docs-gen/src/extractor/examples.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for serializer markdown example extraction. + * + * Русский: Unit-покрытие извлечения markdown examples из serializer tests. + */ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/extension-sources.mjs b/infra/docs-gen/src/extractor/extension-sources.mjs new file mode 100644 index 00000000..3b59c662 --- /dev/null +++ b/infra/docs-gen/src/extractor/extension-sources.mjs @@ -0,0 +1,29 @@ +/** + * English: Reads extension files and prepares source groups for metadata scanning. + * + * Русский: Читает файлы расширения и готовит группы source files для metadata scanning. + */ +import {readAllTsFiles} from '../utils.mjs'; + +import {joinContents, selectSourceFiles, selectSpecFiles} from './source-files.mjs'; + +/** + * Reads extension files and separates production sources from tests. + */ +export function readExtensionSources(extDir) { + const allFiles = readAllTsFiles(extDir); + const sourceFiles = selectSourceFiles(allFiles); + + return { + allFiles, + sourceFiles, + allContent: joinContents(sourceFiles), + }; +} + +/** + * Builds the source text used for schema extraction. + */ +export function buildSchemaContent(sourceFiles, extDir) { + return joinContents(selectSpecFiles(sourceFiles, extDir)); +} diff --git a/infra/docs-gen/src/extractor/index.mjs b/infra/docs-gen/src/extractor/index.mjs index 826615f4..2203a241 100644 --- a/infra/docs-gen/src/extractor/index.mjs +++ b/infra/docs-gen/src/extractor/index.mjs @@ -1,3 +1,8 @@ +/** + * English: Orchestrates extension discovery, filtering, scanning, enrichment, and output. + * + * Русский: Управляет обнаружением, фильтрацией, сканированием, обогащением и выводом расширений. + */ import {existsSync, mkdirSync, rmSync} from 'node:fs'; import {join} from 'node:path'; diff --git a/infra/docs-gen/src/extractor/keymaps.test.mjs b/infra/docs-gen/src/extractor/keymaps.test.mjs index 640f2cb3..ebdfe1ff 100644 --- a/infra/docs-gen/src/extractor/keymaps.test.mjs +++ b/infra/docs-gen/src/extractor/keymaps.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for AST-based keymap extraction. + * + * Русский: Unit-покрытие AST-based извлечения keymap. + */ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/markdown-gen.mjs b/infra/docs-gen/src/extractor/markdown-gen.mjs index 310348a3..0025a67a 100644 --- a/infra/docs-gen/src/extractor/markdown-gen.mjs +++ b/infra/docs-gen/src/extractor/markdown-gen.mjs @@ -1,3 +1,8 @@ +/** + * English: Renders raw extension metadata records into markdown files. + * + * Русский: Рендерит сырые metadata records расширений в markdown-файлы. + */ import {getPresetsForExtension} from './presets.mjs'; /** diff --git a/infra/docs-gen/src/extractor/options.mjs b/infra/docs-gen/src/extractor/options.mjs index 26ed82b5..a25fedb0 100644 --- a/infra/docs-gen/src/extractor/options.mjs +++ b/infra/docs-gen/src/extractor/options.mjs @@ -1,216 +1,10 @@ -import ts from 'typescript'; - -import {getStaticPropertyName, parseSource} from './ast.mjs'; - -/** - * Normalizes whitespace in extracted type snippets. - */ -function normalizeWhitespace(content) { - return content.trim().replace(/\s+/g, ' '); -} - -/** - * Creates a type declaration record. - */ -function createDeclaration(name, kind, node, sourceFile) { - return { - name, - kind, - node, - sourceFile, - }; -} - -/** - * Parses local *Options declarations from TypeScript source. - */ -function parseOptionDeclarations(content) { - const sourceFile = parseSource(content); - const declarations = new Map(); - - for (const statement of sourceFile.statements) { - if ( - (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && - statement.name.text.endsWith('Options') - ) { - declarations.set( - statement.name.text, - createDeclaration( - statement.name.text, - ts.isInterfaceDeclaration(statement) ? 'interface' : 'type', - statement, - sourceFile, - ), - ); - } - } - - return declarations; -} - -/** - * Deduplicates option fields by name. - */ -function uniqueOptionFields(fields) { - const result = new Map(); - - for (const field of fields) { - if (!result.has(field.name)) { - result.set(field.name, field); - } - } - - return [...result.values()]; -} - -/** - * Reads a field type as source text. - */ -function readFieldType(member, sourceFile) { - return member.type ? normalizeWhitespace(member.type.getText(sourceFile)) : 'unknown'; -} - -/** - * Reads field names from Pick/Omit string literal unions. - */ -function readStringLiteralUnion(typeNode) { - if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteralLike(typeNode.literal)) { - return [typeNode.literal.text]; - } - - if (ts.isUnionTypeNode(typeNode)) { - return typeNode.types.flatMap(readStringLiteralUnion); - } - - return []; -} - -/** - * Reads fields declared inside an inline object type. - */ -function readTypeLiteralFields(typeLiteral, sourceFile) { - const fields = []; - - for (const member of typeLiteral.members) { - if (!ts.isPropertySignature(member) || !member.name) continue; - - const name = getStaticPropertyName(member.name); - if (!name) continue; - - fields.push({ - name, - type: readFieldType(member, sourceFile), - }); - } - - return fields; -} - -/** - * Filters fields to names from a utility type. - */ -function pickOptionFields(fields, names) { - const allowedNames = new Set(names); - return fields.filter((field) => allowedNames.has(field.name)); -} - -/** - * Excludes fields by names from a utility type. - */ -function omitOptionFields(fields, names) { - const omittedNames = new Set(names); - return fields.filter((field) => !omittedNames.has(field.name)); -} - /** - * Resolves a type reference to another local declaration or utility type. + * English: Public entry point for extracting local extension option declarations. + * + * Русский: Публичная точка входа для извлечения локальных option declarations расширений. */ -function resolveTypeReference(typeNode, declarations, seen, sourceFile) { - const typeName = typeNode.typeName.getText(sourceFile); - const typeArguments = typeNode.typeArguments || []; - - if ((typeName === 'Pick' || typeName === 'Omit') && typeArguments.length >= 2) { - const fields = resolveTypeNode(typeArguments[0], declarations, seen, sourceFile); - const names = readStringLiteralUnion(typeArguments[1]); - - return typeName === 'Pick' - ? pickOptionFields(fields, names) - : omitOptionFields(fields, names); - } - - if (!declarations.has(typeName)) return []; - - return resolveOptionDeclaration(typeName, declarations, seen); -} - -/** - * Resolves supported TypeScript option type nodes. - */ -function resolveTypeNode(typeNode, declarations, seen, sourceFile) { - if (!typeNode) return []; - - if (ts.isParenthesizedTypeNode(typeNode)) { - return resolveTypeNode(typeNode.type, declarations, seen, sourceFile); - } - - if (ts.isTypeLiteralNode(typeNode)) { - return readTypeLiteralFields(typeNode, sourceFile); - } - - if (ts.isIntersectionTypeNode(typeNode)) { - return typeNode.types.flatMap((part) => - resolveTypeNode(part, declarations, seen, sourceFile), - ); - } - - if (ts.isTypeReferenceNode(typeNode)) { - return resolveTypeReference(typeNode, declarations, seen, sourceFile); - } - - return []; -} - -/** - * Resolves fields inherited by an interface declaration. - */ -function resolveInterfaceHeritage(interfaceNode, declarations, seen, sourceFile) { - const clauses = interfaceNode.heritageClauses || []; - const fields = []; - - for (const clause of clauses) { - if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue; - - for (const type of clause.types) { - const typeName = type.expression.getText(sourceFile); - if (!declarations.has(typeName)) continue; - - fields.push(...resolveOptionDeclaration(typeName, declarations, seen)); - } - } - - return fields; -} - -/** - * Resolves fields from a declaration by name. - */ -function resolveOptionDeclaration(name, declarations, seen = new Set()) { - if (seen.has(name)) return []; - seen.add(name); - - const declaration = declarations.get(name); - if (!declaration) return []; - - const {node, sourceFile} = declaration; - const inheritedFields = ts.isInterfaceDeclaration(node) - ? resolveInterfaceHeritage(node, declarations, seen, sourceFile) - : resolveTypeNode(node.type, declarations, seen, sourceFile); - const ownFields = ts.isInterfaceDeclaration(node) - ? readTypeLiteralFields(node, sourceFile) - : []; - - return uniqueOptionFields([...inheritedFields, ...ownFields]); -} +import {parseOptionDeclarations} from './options/declarations.mjs'; +import {resolveOptionDeclaration} from './options/resolve.mjs'; /** * Selects preferred option declaration names. diff --git a/infra/docs-gen/src/extractor/options.test.mjs b/infra/docs-gen/src/extractor/options.test.mjs index 6531db73..e8c2d12a 100644 --- a/infra/docs-gen/src/extractor/options.test.mjs +++ b/infra/docs-gen/src/extractor/options.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for TypeScript option declaration extraction. + * + * Русский: Unit-покрытие извлечения TypeScript option declarations. + */ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/options/declarations.mjs b/infra/docs-gen/src/extractor/options/declarations.mjs new file mode 100644 index 00000000..5222b496 --- /dev/null +++ b/infra/docs-gen/src/extractor/options/declarations.mjs @@ -0,0 +1,47 @@ +/** + * English: Parses local TypeScript declarations that can describe extension options. + * + * Русский: Парсит локальные TypeScript declarations, описывающие options расширений. + */ +import ts from 'typescript'; + +import {parseSource} from '../ast.mjs'; + +/** + * Creates a type declaration record. + */ +function createDeclaration(name, kind, node, sourceFile) { + return { + name, + kind, + node, + sourceFile, + }; +} + +/** + * Parses local *Options declarations from TypeScript source. + */ +export function parseOptionDeclarations(content) { + const sourceFile = parseSource(content); + const declarations = new Map(); + + for (const statement of sourceFile.statements) { + if ( + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && + statement.name.text.endsWith('Options') + ) { + declarations.set( + statement.name.text, + createDeclaration( + statement.name.text, + ts.isInterfaceDeclaration(statement) ? 'interface' : 'type', + statement, + sourceFile, + ), + ); + } + } + + return declarations; +} diff --git a/infra/docs-gen/src/extractor/options/fields.mjs b/infra/docs-gen/src/extractor/options/fields.mjs new file mode 100644 index 00000000..67df90cb --- /dev/null +++ b/infra/docs-gen/src/extractor/options/fields.mjs @@ -0,0 +1,89 @@ +/** + * English: Reads and transforms option field descriptors from TypeScript type nodes. + * + * Русский: Читает и преобразует descriptors option fields из TypeScript type nodes. + */ +import ts from 'typescript'; + +import {getStaticPropertyName} from '../ast.mjs'; + +/** + * Normalizes whitespace in extracted type snippets. + */ +function normalizeWhitespace(content) { + return content.trim().replace(/\s+/g, ' '); +} + +/** + * Deduplicates option fields by name. + */ +export function uniqueOptionFields(fields) { + const result = new Map(); + + for (const field of fields) { + if (!result.has(field.name)) { + result.set(field.name, field); + } + } + + return [...result.values()]; +} + +/** + * Reads a field type as source text. + */ +function readFieldType(member, sourceFile) { + return member.type ? normalizeWhitespace(member.type.getText(sourceFile)) : 'unknown'; +} + +/** + * Reads field names from Pick/Omit string literal unions. + */ +export function readStringLiteralUnion(typeNode) { + if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteralLike(typeNode.literal)) { + return [typeNode.literal.text]; + } + + if (ts.isUnionTypeNode(typeNode)) { + return typeNode.types.flatMap(readStringLiteralUnion); + } + + return []; +} + +/** + * Reads fields declared inside an inline object type. + */ +export function readTypeLiteralFields(typeLiteral, sourceFile) { + const fields = []; + + for (const member of typeLiteral.members) { + if (!ts.isPropertySignature(member) || !member.name) continue; + + const name = getStaticPropertyName(member.name); + if (!name) continue; + + fields.push({ + name, + type: readFieldType(member, sourceFile), + }); + } + + return fields; +} + +/** + * Filters fields to names from a utility type. + */ +export function pickOptionFields(fields, names) { + const allowedNames = new Set(names); + return fields.filter((field) => allowedNames.has(field.name)); +} + +/** + * Excludes fields by names from a utility type. + */ +export function omitOptionFields(fields, names) { + const omittedNames = new Set(names); + return fields.filter((field) => !omittedNames.has(field.name)); +} diff --git a/infra/docs-gen/src/extractor/options/resolve.mjs b/infra/docs-gen/src/extractor/options/resolve.mjs new file mode 100644 index 00000000..e202ffeb --- /dev/null +++ b/infra/docs-gen/src/extractor/options/resolve.mjs @@ -0,0 +1,104 @@ +/** + * English: Resolves local option declarations into flat field lists. + * + * Русский: Резолвит локальные option declarations в плоские списки fields. + */ +import ts from 'typescript'; + +import { + omitOptionFields, + pickOptionFields, + readStringLiteralUnion, + readTypeLiteralFields, + uniqueOptionFields, +} from './fields.mjs'; + +/** + * Resolves a type reference to another local declaration or utility type. + */ +function resolveTypeReference(typeNode, declarations, seen, sourceFile) { + const typeName = typeNode.typeName.getText(sourceFile); + const typeArguments = typeNode.typeArguments || []; + + if ((typeName === 'Pick' || typeName === 'Omit') && typeArguments.length >= 2) { + const fields = resolveTypeNode(typeArguments[0], declarations, seen, sourceFile); + const names = readStringLiteralUnion(typeArguments[1]); + + return typeName === 'Pick' + ? pickOptionFields(fields, names) + : omitOptionFields(fields, names); + } + + if (!declarations.has(typeName)) return []; + + return resolveOptionDeclaration(typeName, declarations, seen); +} + +/** + * Resolves supported TypeScript option type nodes. + */ +function resolveTypeNode(typeNode, declarations, seen, sourceFile) { + if (!typeNode) return []; + + if (ts.isParenthesizedTypeNode(typeNode)) { + return resolveTypeNode(typeNode.type, declarations, seen, sourceFile); + } + + if (ts.isTypeLiteralNode(typeNode)) { + return readTypeLiteralFields(typeNode, sourceFile); + } + + if (ts.isIntersectionTypeNode(typeNode)) { + return typeNode.types.flatMap((part) => + resolveTypeNode(part, declarations, seen, sourceFile), + ); + } + + if (ts.isTypeReferenceNode(typeNode)) { + return resolveTypeReference(typeNode, declarations, seen, sourceFile); + } + + return []; +} + +/** + * Resolves fields inherited by an interface declaration. + */ +function resolveInterfaceHeritage(interfaceNode, declarations, seen, sourceFile) { + const clauses = interfaceNode.heritageClauses || []; + const fields = []; + + for (const clause of clauses) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue; + + for (const type of clause.types) { + const typeName = type.expression.getText(sourceFile); + if (!declarations.has(typeName)) continue; + + fields.push(...resolveOptionDeclaration(typeName, declarations, seen)); + } + } + + return fields; +} + +/** + * Resolves fields from a declaration by name. + */ +export function resolveOptionDeclaration(name, declarations, seen = new Set()) { + if (seen.has(name)) return []; + seen.add(name); + + const declaration = declarations.get(name); + if (!declaration) return []; + + const {node, sourceFile} = declaration; + const inheritedFields = ts.isInterfaceDeclaration(node) + ? resolveInterfaceHeritage(node, declarations, seen, sourceFile) + : resolveTypeNode(node.type, declarations, seen, sourceFile); + const ownFields = ts.isInterfaceDeclaration(node) + ? readTypeLiteralFields(node, sourceFile) + : []; + + return uniqueOptionFields([...inheritedFields, ...ownFields]); +} diff --git a/infra/docs-gen/src/extractor/output.mjs b/infra/docs-gen/src/extractor/output.mjs index d4d021ca..7977da74 100644 --- a/infra/docs-gen/src/extractor/output.mjs +++ b/infra/docs-gen/src/extractor/output.mjs @@ -1,3 +1,8 @@ +/** + * English: Writes extracted raw JSON and markdown artifacts to disk. + * + * Русский: Записывает извлеченные raw JSON и markdown artifacts на диск. + */ import {writeFileSync} from 'node:fs'; import {join} from 'node:path'; diff --git a/infra/docs-gen/src/extractor/presets.mjs b/infra/docs-gen/src/extractor/presets.mjs index 91f9826d..2219a362 100644 --- a/infra/docs-gen/src/extractor/presets.mjs +++ b/infra/docs-gen/src/extractor/presets.mjs @@ -1,3 +1,8 @@ +/** + * English: Maps extensions to editor presets by scanning preset source files. + * + * Русский: Сопоставляет расширения с editor presets через сканирование исходников пресетов. + */ import {existsSync} from 'node:fs'; import {join} from 'node:path'; diff --git a/infra/docs-gen/src/extractor/record-fields.mjs b/infra/docs-gen/src/extractor/record-fields.mjs new file mode 100644 index 00000000..4d83c121 --- /dev/null +++ b/infra/docs-gen/src/extractor/record-fields.mjs @@ -0,0 +1,132 @@ +/** + * English: Maps raw extension record fields to focused extractor functions. + * + * Русский: Связывает поля raw-записи расширения с узкими extractor-функциями. + */ +import {EXTENSION_DOC_FIELD_CONFIG} from '../config.mjs'; + +import { + extractActions, + extractInputRules, + extractKeymaps, + extractMdPlugins, + extractPlugins, + extractSerializerSyntax, +} from './ast.mjs'; +import {resolveAllConstants} from './constants.mjs'; +import {extractTestExamples} from './examples.mjs'; +import {extractOptionsType} from './options.mjs'; +import { + findRootIndexFile, + findSpecsIndexFile, + isTestFile, + joinContents, + selectSerializerFiles, +} from './source-files.mjs'; + +/** + * Deduplicates extracted values while keeping the source order. + */ +function unique(values) { + return [...new Set(values)]; +} + +/** + * Builds option declaration names preferred for an extension. + */ +function buildPreferredOptionNames(extensionName) { + return [`${extensionName}Options`, `${extensionName}SpecsOptions`]; +} + +/** + * Extracts extension options from local source declarations. + */ +export function extractOptions(sourceFiles, extDir, extensionName) { + const rootIndexFile = findRootIndexFile(sourceFiles, extDir); + const specsIndexFile = findSpecsIndexFile(sourceFiles); + const preferredNames = buildPreferredOptionNames(extensionName); + const allOptions = extractOptionsType(joinContents(sourceFiles), preferredNames); + + if (allOptions.length > 0) return allOptions; + if (rootIndexFile) return extractOptionsType(rootIndexFile.content, preferredNames); + if (specsIndexFile) return extractOptionsType(specsIndexFile.content, preferredNames); + + return []; +} + +/** + * Extracts unique markup examples from test files. + */ +export function extractMarkupExamples(files) { + return [ + ...new Set( + files + .filter((file) => isTestFile(file.path)) + .flatMap((file) => extractTestExamples(file.content)), + ), + ]; +} + +/** + * Extracts raw action identifiers. + */ +function extractActionNames(content, constants) { + return resolveAllConstants(extractActions(content), constants); +} + +/** + * Extracts unique ProseMirror plugin names. + */ +function extractPluginNames(content) { + return unique(extractPlugins(content)); +} + +/** + * Extracts unique markdown-it plugin names. + */ +function extractMdPluginNames(content) { + return unique(extractMdPlugins(content)); +} + +/** + * Extracts unique serializer output snippets. + */ +function extractSerializerHints(sourceFiles) { + const serializerContent = joinContents(selectSerializerFiles(sourceFiles)); + return unique(extractSerializerSyntax(serializerContent)); +} + +const FIELD_EXTRACTORS = { + name: ({name}) => name, + sourcePath: ({sourcePath}) => sourcePath, + category: ({category}) => category, + nodes: ({schema}) => schema.nodes, + marks: ({schema}) => schema.marks, + actions: ({allContent, constants}) => extractActionNames(allContent, constants), + keymaps: ({allContent}) => extractKeymaps(allContent), + inputRules: ({allContent}) => extractInputRules(allContent), + plugins: ({allContent}) => extractPluginNames(allContent), + mdPlugins: ({allContent}) => extractMdPluginNames(allContent), + serializerHints: ({sourceFiles}) => extractSerializerHints(sourceFiles), + options: ({sourceFiles, extDir, name}) => extractOptions(sourceFiles, extDir, name), + markupExamples: ({allFiles}) => extractMarkupExamples(allFiles), + presets: () => [], +}; + +/** + * Builds the final extension IR record. + */ +export function createExtensionRecord(context) { + const record = {}; + + for (const fieldName of Object.keys(EXTENSION_DOC_FIELD_CONFIG)) { + const extractField = FIELD_EXTRACTORS[fieldName]; + if (!extractField) { + throw new Error(`Missing docs-gen field extractor: ${fieldName}`); + } + + record[fieldName] = extractField(context); + } + + return record; +} diff --git a/infra/docs-gen/src/extractor/scan.mjs b/infra/docs-gen/src/extractor/scan.mjs index 1197f190..15898cdd 100644 --- a/infra/docs-gen/src/extractor/scan.mjs +++ b/infra/docs-gen/src/extractor/scan.mjs @@ -1,176 +1,14 @@ -import {basename, relative} from 'node:path'; - -import {EXTENSION_DOC_FIELD_CONFIG} from '../config.mjs'; -import {readAllTsFiles} from '../utils.mjs'; - -import { - extractActions, - extractAddMark, - extractAddNode, - extractInputRules, - extractKeymaps, - extractMarkSpecs, - extractMdPlugins, - extractNodeSpecs, - extractPlugins, - extractSerializerSyntax, -} from './ast.mjs'; -import {extractConstants, resolveAllConstants} from './constants.mjs'; -import {extractTestExamples} from './examples.mjs'; -import {extractOptionsType} from './options.mjs'; -import { - findRootIndexFile, - findSpecsIndexFile, - isTestFile, - joinContents, - selectSerializerFiles, - selectSourceFiles, - selectSpecFiles, -} from './source-files.mjs'; - -/** - * Deduplicates extracted values while keeping the source order. - */ -function unique(values) { - return [...new Set(values)]; -} - -/** - * Reads extension files and separates production sources from tests. - */ -function readExtensionSources(extDir) { - const allFiles = readAllTsFiles(extDir); - const sourceFiles = selectSourceFiles(allFiles); - - return { - allFiles, - sourceFiles, - allContent: joinContents(sourceFiles), - }; -} - -/** - * Builds the source text used for schema extraction. - */ -function buildSchemaContent(sourceFiles, extDir) { - return joinContents(selectSpecFiles(sourceFiles, extDir)); -} - -/** - * Extracts schema nodes and marks. - */ -export function extractSchema(specContent, constants) { - return { - nodes: resolveAllConstants( - [...extractAddNode(specContent), ...extractNodeSpecs(specContent)], - constants, - ), - marks: resolveAllConstants( - [...extractAddMark(specContent), ...extractMarkSpecs(specContent)], - constants, - ), - }; -} - -/** - * Builds option declaration names preferred for an extension. - */ -function buildPreferredOptionNames(extensionName) { - return [`${extensionName}Options`, `${extensionName}SpecsOptions`]; -} - -/** - * Extracts extension options from local source declarations. - */ -export function extractOptions(sourceFiles, extDir, extensionName) { - const rootIndexFile = findRootIndexFile(sourceFiles, extDir); - const specsIndexFile = findSpecsIndexFile(sourceFiles); - const preferredNames = buildPreferredOptionNames(extensionName); - const allOptions = extractOptionsType(joinContents(sourceFiles), preferredNames); - - if (allOptions.length > 0) return allOptions; - if (rootIndexFile) return extractOptionsType(rootIndexFile.content, preferredNames); - if (specsIndexFile) return extractOptionsType(specsIndexFile.content, preferredNames); - - return []; -} - -/** - * Extracts unique markup examples from test files. - */ -export function extractMarkupExamples(files) { - return [ - ...new Set( - files - .filter((file) => isTestFile(file.path)) - .flatMap((file) => extractTestExamples(file.content)), - ), - ]; -} - /** - * Extracts raw action identifiers. + * English: Builds one raw extension record from filtered extension source files. + * + * Русский: Собирает одну raw-запись расширения из отфильтрованных source files. */ -function extractActionNames(content, constants) { - return resolveAllConstants(extractActions(content), constants); -} - -/** - * Extracts unique ProseMirror plugin names. - */ -function extractPluginNames(content) { - return unique(extractPlugins(content)); -} - -/** - * Extracts unique markdown-it plugin names. - */ -function extractMdPluginNames(content) { - return unique(extractMdPlugins(content)); -} - -/** - * Extracts unique serializer output snippets. - */ -function extractSerializerHints(sourceFiles) { - const serializerContent = joinContents(selectSerializerFiles(sourceFiles)); - return unique(extractSerializerSyntax(serializerContent)); -} - -const FIELD_EXTRACTORS = { - name: ({name}) => name, - sourcePath: ({sourcePath}) => sourcePath, - category: ({category}) => category, - nodes: ({schema}) => schema.nodes, - marks: ({schema}) => schema.marks, - actions: ({allContent, constants}) => extractActionNames(allContent, constants), - keymaps: ({allContent}) => extractKeymaps(allContent), - inputRules: ({allContent}) => extractInputRules(allContent), - plugins: ({allContent}) => extractPluginNames(allContent), - mdPlugins: ({allContent}) => extractMdPluginNames(allContent), - serializerHints: ({sourceFiles}) => extractSerializerHints(sourceFiles), - options: ({sourceFiles, extDir, name}) => extractOptions(sourceFiles, extDir, name), - markupExamples: ({allFiles}) => extractMarkupExamples(allFiles), - presets: () => [], -}; - -/** - * Builds the final extension IR record. - */ -function createExtensionRecord(context) { - const record = {}; - - for (const fieldName of Object.keys(EXTENSION_DOC_FIELD_CONFIG)) { - const extractField = FIELD_EXTRACTORS[fieldName]; - if (!extractField) { - throw new Error(`Missing docs-gen field extractor: ${fieldName}`); - } - - record[fieldName] = extractField(context); - } +import {basename, relative} from 'node:path'; - return record; -} +import {extractConstants} from './constants.mjs'; +import {buildSchemaContent, readExtensionSources} from './extension-sources.mjs'; +import {createExtensionRecord} from './record-fields.mjs'; +import {extractSchema} from './schema.mjs'; /** * Scans one extension directory into raw metadata. diff --git a/infra/docs-gen/src/extractor/schema.mjs b/infra/docs-gen/src/extractor/schema.mjs new file mode 100644 index 00000000..ead0d709 --- /dev/null +++ b/infra/docs-gen/src/extractor/schema.mjs @@ -0,0 +1,23 @@ +/** + * English: Resolves extension schema nodes and marks from spec source content. + * + * Русский: Резолвит schema nodes и marks расширения из spec source content. + */ +import {extractAddMark, extractAddNode, extractMarkSpecs, extractNodeSpecs} from './ast.mjs'; +import {resolveAllConstants} from './constants.mjs'; + +/** + * Extracts schema nodes and marks. + */ +export function extractSchema(specContent, constants) { + return { + nodes: resolveAllConstants( + [...extractAddNode(specContent), ...extractNodeSpecs(specContent)], + constants, + ), + marks: resolveAllConstants( + [...extractAddMark(specContent), ...extractMarkSpecs(specContent)], + constants, + ), + }; +} diff --git a/infra/docs-gen/src/extractor/schema.test.mjs b/infra/docs-gen/src/extractor/schema.test.mjs index 175566b2..d354ad00 100644 --- a/infra/docs-gen/src/extractor/schema.test.mjs +++ b/infra/docs-gen/src/extractor/schema.test.mjs @@ -1,3 +1,8 @@ +/** + * English: Unit coverage for AST-based schema registration extraction. + * + * Русский: Unit-покрытие AST-based извлечения schema registrations. + */ import assert from 'node:assert/strict'; import {test} from 'node:test'; diff --git a/infra/docs-gen/src/extractor/source-files.mjs b/infra/docs-gen/src/extractor/source-files.mjs index 06f3afe8..00b7781e 100644 --- a/infra/docs-gen/src/extractor/source-files.mjs +++ b/infra/docs-gen/src/extractor/source-files.mjs @@ -1,3 +1,8 @@ +/** + * English: Selects relevant extension source, spec, serializer, and test files. + * + * Русский: Выбирает релевантные source, spec, serializer и test файлы расширения. + */ import {dirname} from 'node:path'; /** diff --git a/infra/docs-gen/src/generate-docs.mjs b/infra/docs-gen/src/generate-docs.mjs index f3ef8ddc..c323923c 100644 --- a/infra/docs-gen/src/generate-docs.mjs +++ b/infra/docs-gen/src/generate-docs.mjs @@ -1,3 +1,8 @@ +/** + * English: Builds Diplodoc input files from repository markdown documentation. + * + * Русский: Собирает входные файлы Diplodoc из markdown-документации репозитория. + */ import { cpSync, existsSync, diff --git a/infra/docs-gen/src/logger.mjs b/infra/docs-gen/src/logger.mjs index cdb53897..f24c4be8 100644 --- a/infra/docs-gen/src/logger.mjs +++ b/infra/docs-gen/src/logger.mjs @@ -1,3 +1,8 @@ +/** + * English: Minimal console logger shared by docs-gen command-line scripts. + * + * Русский: Минимальный console logger для command-line скриптов docs-gen. + */ /* eslint-disable no-console */ export class Logger { diff --git a/infra/docs-gen/src/utils.mjs b/infra/docs-gen/src/utils.mjs index 2adeeded..78502c30 100644 --- a/infra/docs-gen/src/utils.mjs +++ b/infra/docs-gen/src/utils.mjs @@ -1,3 +1,8 @@ +/** + * English: Filesystem helpers shared by docs-gen build and extraction flows. + * + * Русский: Filesystem helper-ы, общие для build и extraction flow docs-gen. + */ import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; import {join} from 'node:path'; From 33c955e8f2d7de4edfe6cefa4e22cb50f51f120e Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Sun, 14 Jun 2026 02:17:05 +0200 Subject: [PATCH 3/4] fix(docs-gen): scan configured extension entry points --- infra/docs-gen/EXTRACTION_PIPELINE.md | 10 ++- infra/docs-gen/README.md | 7 +- infra/docs-gen/src/config.mjs | 41 +++++++++ infra/docs-gen/src/extract-extension-data.mjs | 7 +- infra/docs-gen/src/extractor/README.md | 20 +++-- .../docs-gen/src/extractor/extension-refs.mjs | 85 +++++++++++++++++++ infra/docs-gen/src/extractor/index.mjs | 69 +++++---------- infra/docs-gen/src/extractor/markdown-gen.mjs | 6 +- .../docs-gen/src/extractor/record-fields.mjs | 2 + infra/docs-gen/src/extractor/scan.mjs | 12 ++- 10 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 infra/docs-gen/src/extractor/extension-refs.mjs diff --git a/infra/docs-gen/EXTRACTION_PIPELINE.md b/infra/docs-gen/EXTRACTION_PIPELINE.md index 123df952..aeda0325 100644 --- a/infra/docs-gen/EXTRACTION_PIPELINE.md +++ b/infra/docs-gen/EXTRACTION_PIPELINE.md @@ -3,9 +3,10 @@ ```mermaid flowchart TD CLI["extract-extension-data.mjs
Parse CLI options"] --> Extractor["ExtensionExtractor
src/extractor/index.mjs"] - Extractor --> Categories["Extension categories
src/config.mjs"] - Categories --> Collect["collectExtensionRefs()
Collect all extension dirs"] - Collect --> Filter["filterExtensionRefs()
Apply blacklist and --only"] + Extractor --> EntryPoints["Extension entry points
EXTENSION_ENTRY_POINTS"] + EntryPoints --> Collect["collectExtensionRefs()
src/extractor/extension-refs.mjs"] + Collect --> AllRefs["Full extension ref list"] + AllRefs --> Filter["filterExtensionRefs()
Apply blacklist and --only"] Filter --> Scan["scanExtension()
src/extractor/scan.mjs"] Scan --> FieldConfig["Docs field config
EXTENSION_DOC_FIELD_CONFIG"] @@ -39,7 +40,8 @@ flowchart TD The extractor keeps orchestration and parsing separate: -- `index.mjs` collects all extension directories, filters them by blacklist and `--only`, and decides when output is written. +- `index.mjs` asks `extension-refs.mjs` to collect all configured extension entry points, + filters them by blacklist and `--only`, and only then scans each remaining extension. - `scan.mjs` coordinates one extension scan; `extension-sources.mjs`, `schema.mjs`, and `record-fields.mjs` own the focused extraction steps. - `source-files.mjs` owns file selection rules. - `ast.mjs` exposes focused AST scanners from `ast/*.mjs`; `options.mjs`, `examples.mjs`, and `constants.mjs` own their TypeScript AST parsing details. diff --git a/infra/docs-gen/README.md b/infra/docs-gen/README.md index c53a3229..48e7b893 100644 --- a/infra/docs-gen/README.md +++ b/infra/docs-gen/README.md @@ -3,7 +3,7 @@ `infra/docs-gen` contains tooling for two documentation flows: - building Diplodoc input from `docs/*.md`; -- extracting raw extension metadata from `packages/editor/src/extensions`. +- extracting raw extension metadata from configured extension entry points. ## Commands @@ -15,7 +15,7 @@ - `package.json` defines the local docs-gen package, scripts, and Nx target. - `EXTRACTION_PIPELINE.md` documents the raw extension extraction flow with a Mermaid diagram. -- `src/config.mjs` stores shared paths, extension categories, extension blacklist entries, raw docs field sources, preset definitions, and generator constants. +- `src/config.mjs` stores shared paths, extension entry points, extension categories, extension blacklist entries, raw docs field sources, preset definitions, and generator constants. - `src/extract-extension-data.mjs` provides the CLI entry point for raw extension extraction. - `src/generate-docs.mjs` builds the Diplodoc source tree from repository markdown files. - `src/logger.mjs` contains the small console logger used by docs-gen CLIs. @@ -23,8 +23,9 @@ ## Extractor Files -- `src/extractor/index.mjs` contains `ExtensionExtractor`, the high-level orchestrator that scans extension categories, enriches records with presets, and writes output. +- `src/extractor/index.mjs` contains `ExtensionExtractor`, the high-level orchestrator that collects configured extension entry points, enriches records with presets, and writes output. - `src/extractor/README.md` maps extractor modules and field ownership in English and Russian. +- `src/extractor/extension-refs.mjs` collects all configured extension references and applies blacklist and `--only` filters before scanning. - `src/extractor/scan.mjs` scans one filtered extension directory and assembles the raw extension IR record from `EXTENSION_DOC_FIELD_CONFIG`. - `src/extractor/extension-sources.mjs` reads extension files and prepares source text groups. - `src/extractor/schema.mjs` resolves schema node and mark names from spec files. diff --git a/infra/docs-gen/src/config.mjs b/infra/docs-gen/src/config.mjs index b547c18e..bfb00ebe 100644 --- a/infra/docs-gen/src/config.mjs +++ b/infra/docs-gen/src/config.mjs @@ -11,6 +11,10 @@ export const DOCS_DIR = join(REPO_ROOT, 'docs'); export const DOCS_SRC_DIR = join(REPO_ROOT, 'tmp/docs-src'); export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); export const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); +export const PAGE_CONSTRUCTOR_EXTENSION_PKG_DIR = join( + REPO_ROOT, + 'packages/page-constructor-extension', +); export const GITHUB_RAW_RE = /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; @@ -18,6 +22,27 @@ export const HEADER_RE = /^#{5}\s+(.+)$/; export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; +export const EXTENSION_ENTRY_POINTS = [ + { + id: 'editor', + packageName: '@gravity-ui/markdown-editor', + packageDir: EDITOR_PKG_DIR, + kind: 'category-dirs', + extensionsDir: 'src/extensions', + categories: EXTENSION_CATEGORIES, + presetsDir: 'src/presets', + }, + { + id: 'page-constructor-extension', + packageName: '@gravity-ui/markdown-editor-page-constructor-extension', + packageDir: PAGE_CONSTRUCTOR_EXTENSION_PKG_DIR, + kind: 'single-extension', + extensionName: 'YfmPageConstructorExtension', + extensionDir: 'src/extension', + category: 'external', + }, +]; + export const INTERNAL_EXTENSION_BLACKLIST = [ 'BaseInputRules', 'BaseKeymap', @@ -33,6 +58,8 @@ export const EXTENSION_BLACKLIST = [ export const EXTENSION_DOC_FIELD_CONFIG = { name: {from: 'extension directory name'}, + packageName: {from: 'configured extension entry point package name'}, + entryPoint: {from: 'configured extension entry point id'}, sourcePath: {from: 'extension directory path relative to repository root'}, category: {from: 'configured extension category directory'}, nodes: {from: 'extension spec files: addNode and addNodeSpec calls'}, @@ -62,3 +89,17 @@ export const PRESET_DEFS = [ export function isBlacklistedExtension(name) { return [...INTERNAL_EXTENSION_BLACKLIST, ...EXTENSION_BLACKLIST].includes(name); } + +/** + * Creates extension entry points with optional CLI path overrides. + */ +export function createExtensionEntryPoints({editorPkg = EDITOR_PKG_DIR} = {}) { + return EXTENSION_ENTRY_POINTS.map((entryPoint) => { + if (entryPoint.id !== 'editor') return entryPoint; + + return { + ...entryPoint, + packageDir: editorPkg, + }; + }); +} diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index 800af467..6fb03bfd 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -8,7 +8,7 @@ import {isAbsolute, join} from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; -import {DOCS_GEN_DIR, EDITOR_PKG_DIR, REPO_ROOT} from './config.mjs'; +import {DOCS_GEN_DIR, EDITOR_PKG_DIR, REPO_ROOT, createExtensionEntryPoints} from './config.mjs'; import {ExtensionExtractor} from './extractor/index.mjs'; import {logger} from './logger.mjs'; @@ -108,7 +108,7 @@ function printHelp() { logger.info('Options:'); logger.info(' --only Bold,Link Extract selected extension names'); logger.info(' --out-dir tmp/docs-gen Override output directory'); - logger.info(' --editor-pkg path Override packages/editor path'); + logger.info(' --editor-pkg path Override configured editor package path'); } /** @@ -122,9 +122,10 @@ export function main(args = process.argv.slice(2)) { } new ExtensionExtractor({ - editorPkg: opts.editorPkg, + entryPoints: createExtensionEntryPoints({editorPkg: opts.editorPkg}), outDir: opts.outDir, repoRoot: REPO_ROOT, + versionPackageDir: opts.editorPkg, }).run({only: opts.only}); } diff --git a/infra/docs-gen/src/extractor/README.md b/infra/docs-gen/src/extractor/README.md index d41d894c..17398663 100644 --- a/infra/docs-gen/src/extractor/README.md +++ b/infra/docs-gen/src/extractor/README.md @@ -6,6 +6,8 @@ pipeline intentionally keeps each step narrow: - `index.mjs` orchestrates discovery, filtering, scanning, preset enrichment, and output. +- `extension-refs.mjs` collects all configured package entry points, then applies blacklist + and `--only` filters before any extension scan starts. - `scan.mjs` builds one extension record from `EXTENSION_DOC_FIELD_CONFIG`. - `extension-sources.mjs` reads extension source files and builds source text groups. - `schema.mjs` resolves schema node and mark names from spec source. @@ -25,9 +27,11 @@ pipeline intentionally keeps each step narrow: - `presets.mjs` maps extensions to editor presets. - `markdown-gen.mjs` and `output.mjs` render and write the raw artifacts. -`EXTENSION_DOC_FIELD_CONFIG` in `../config.mjs` is the contract for which fields are -written and where each field comes from. Add a field there first, then add the matching -extractor in `scan.mjs`. +`EXTENSION_ENTRY_POINTS` in `../config.mjs` defines where extension entry points come +from. It currently covers the editor package category directories and the +page-constructor extension package. `EXTENSION_DOC_FIELD_CONFIG` is the contract for +which fields are written and where each field comes from. Add a field there first, then +add the matching extractor in `record-fields.mjs`. ## Русский @@ -35,6 +39,8 @@ extractor in `scan.mjs`. Пайплайн специально разбит на узкие шаги: - `index.mjs` управляет обнаружением, фильтрацией, сканированием, пресетами и выводом. +- `extension-refs.mjs` собирает все сконфигурированные package entry points, затем + применяет blacklist и `--only` до запуска scan по расширениям. - `scan.mjs` собирает одну запись расширения по `EXTENSION_DOC_FIELD_CONFIG`. - `extension-sources.mjs` читает source files расширения и собирает группы source text. - `schema.mjs` резолвит имена schema node и mark из spec source. @@ -54,6 +60,8 @@ extractor in `scan.mjs`. - `presets.mjs` сопоставляет расширения с editor presets. - `markdown-gen.mjs` и `output.mjs` рендерят и записывают raw artifacts. -`EXTENSION_DOC_FIELD_CONFIG` в `../config.mjs` задает контракт: какие поля пишутся и -откуда каждое поле берется. Новое поле сначала добавляется туда, затем для него -добавляется extractor в `scan.mjs`. +`EXTENSION_ENTRY_POINTS` в `../config.mjs` задает, откуда брать extension entry points. +Сейчас там есть category directories пакета editor и отдельный пакет page-constructor +extension. `EXTENSION_DOC_FIELD_CONFIG` задает контракт: какие поля пишутся и откуда +каждое поле берется. Новое поле сначала добавляется туда, затем для него добавляется +extractor в `record-fields.mjs`. diff --git a/infra/docs-gen/src/extractor/extension-refs.mjs b/infra/docs-gen/src/extractor/extension-refs.mjs new file mode 100644 index 00000000..f628d15a --- /dev/null +++ b/infra/docs-gen/src/extractor/extension-refs.mjs @@ -0,0 +1,85 @@ +/** + * English: Collects configured extension entry points into scan references. + * + * Русский: Собирает сконфигурированные entry points расширений в scan references. + */ +import {join} from 'node:path'; + +import {isBlacklistedExtension} from '../config.mjs'; +import {listDirs} from '../utils.mjs'; + +/** + * Creates one scan reference for an extension directory. + */ +function createExtensionRef(entryPoint, {name, category, extDir}) { + return { + name, + packageName: entryPoint.packageName, + entryPoint: entryPoint.id, + category, + extDir, + }; +} + +/** + * Collects extension references from category-based editor package directories. + */ +function collectCategoryExtensionRefs(entryPoint) { + const refs = []; + + for (const category of entryPoint.categories) { + const categoryDir = join(entryPoint.packageDir, entryPoint.extensionsDir, category); + for (const dirName of listDirs(categoryDir)) { + refs.push( + createExtensionRef(entryPoint, { + name: dirName, + category, + extDir: join(categoryDir, dirName), + }), + ); + } + } + + return refs; +} + +/** + * Collects a package that exposes one extension entry point. + */ +function collectSingleExtensionRef(entryPoint) { + return [ + createExtensionRef(entryPoint, { + name: entryPoint.extensionName, + category: entryPoint.category, + extDir: join(entryPoint.packageDir, entryPoint.extensionDir), + }), + ]; +} + +/** + * Collects all extension references from configured entry points. + */ +export function collectExtensionRefs(entryPoints) { + return entryPoints.flatMap((entryPoint) => { + switch (entryPoint.kind) { + case 'category-dirs': + return collectCategoryExtensionRefs(entryPoint); + case 'single-extension': + return collectSingleExtensionRef(entryPoint); + default: + throw new Error(`Unknown extension entry point kind: ${entryPoint.kind}`); + } + }); +} + +/** + * Applies configured filters after the full extension list is known. + */ +export function filterExtensionRefs(refs, {only} = {}) { + const onlySet = only?.length ? new Set(only) : null; + + return refs.filter((ref) => { + if (isBlacklistedExtension(ref.name)) return false; + return !onlySet || onlySet.has(ref.name); + }); +} diff --git a/infra/docs-gen/src/extractor/index.mjs b/infra/docs-gen/src/extractor/index.mjs index 2203a241..af1fe68b 100644 --- a/infra/docs-gen/src/extractor/index.mjs +++ b/infra/docs-gen/src/extractor/index.mjs @@ -6,23 +6,22 @@ import {existsSync, mkdirSync, rmSync} from 'node:fs'; import {join} from 'node:path'; -import {EXTENSION_CATEGORIES, isBlacklistedExtension} from '../config.mjs'; import {logger} from '../logger.mjs'; -import {listDirs, readText} from '../utils.mjs'; +import {readText} from '../utils.mjs'; +import {collectExtensionRefs, filterExtensionRefs} from './extension-refs.mjs'; import {writeExtensionsJson, writeRawMarkdownFiles} from './output.mjs'; import {getPresetsForExtension, parsePresets} from './presets.mjs'; import {scanExtension} from './scan.mjs'; export class ExtensionExtractor { /** - * Creates an extension extractor for editor source paths. + * Creates an extension extractor for configured source paths. */ - constructor({editorPkg, outDir, repoRoot}) { - this.editorPkg = editorPkg; + constructor({entryPoints, outDir, repoRoot, versionPackageDir}) { + this.entryPoints = entryPoints; this.repoRoot = repoRoot; - this.extensionsDir = join(editorPkg, 'src/extensions'); - this.presetsDir = join(editorPkg, 'src/presets'); + this.versionPackageDir = versionPackageDir; this.outDir = outDir; this.rawDir = join(outDir, 'raw'); } @@ -30,53 +29,31 @@ export class ExtensionExtractor { /** * Scans one extension directory into raw metadata. */ - scan(extDir, category) { - return scanExtension({extDir, category, repoRoot: this.repoRoot}); + scan(ref) { + return scanExtension({...ref, repoRoot: this.repoRoot}); } /** - * Collects all configured extension directories before filtering. + * Scans all configured extension entry points. */ - collectExtensionRefs() { - const refs = []; - - for (const category of EXTENSION_CATEGORIES) { - const categoryDir = join(this.extensionsDir, category); - for (const dirName of listDirs(categoryDir)) { - refs.push({ - name: dirName, - category, - extDir: join(categoryDir, dirName), - }); - } - } - - return refs; - } - - /** - * Applies configured extension filters after the full list is known. - */ - filterExtensionRefs(refs, {only} = {}) { - const onlySet = only?.length ? new Set(only) : null; + scanAll({only} = {}) { + const allRefs = collectExtensionRefs(this.entryPoints); + const extensionRefs = filterExtensionRefs(allRefs, {only}); - return refs.filter((ref) => { - if (isBlacklistedExtension(ref.name)) return false; - return !onlySet || onlySet.has(ref.name); - }); + return { + totalCount: allRefs.length, + extensions: extensionRefs.map((ref) => this.scan(ref)), + }; } /** - * Scans all configured extension categories. + * Parses presets from the configured editor entry point. */ - scanAll({only} = {}) { - const allRefs = this.collectExtensionRefs(); - const extensionRefs = this.filterExtensionRefs(allRefs, {only}); + getPresetMap() { + const presetEntryPoint = this.entryPoints.find((entryPoint) => entryPoint.presetsDir); + if (!presetEntryPoint) return new Map(); - return { - totalCount: allRefs.length, - extensions: extensionRefs.map((ref) => this.scan(ref.extDir, ref.category)), - }; + return parsePresets(join(presetEntryPoint.packageDir, presetEntryPoint.presetsDir)); } /** @@ -92,7 +69,7 @@ export class ExtensionExtractor { const version = this.getEditorVersion(); const {totalCount, extensions} = this.scanAll({only}); - const presetMap = parsePresets(this.presetsDir); + const presetMap = this.getPresetMap(); for (const extension of extensions) { extension.presets = getPresetsForExtension(presetMap, extension.name); @@ -112,7 +89,7 @@ export class ExtensionExtractor { * Reads the editor package version. */ getEditorVersion() { - const pkg = JSON.parse(readText(join(this.editorPkg, 'package.json'))); + const pkg = JSON.parse(readText(join(this.versionPackageDir, 'package.json'))); return pkg.version; } } diff --git a/infra/docs-gen/src/extractor/markdown-gen.mjs b/infra/docs-gen/src/extractor/markdown-gen.mjs index 0025a67a..0d73f7c1 100644 --- a/infra/docs-gen/src/extractor/markdown-gen.mjs +++ b/infra/docs-gen/src/extractor/markdown-gen.mjs @@ -34,6 +34,8 @@ export function generateRawMd(extension, presetMap, version) { '---', `extension: ${extension.name}`, `version: ${version}`, + `packageName: ${JSON.stringify(extension.packageName)}`, + `entryPoint: ${JSON.stringify(extension.entryPoint)}`, `category: ${extension.category}`, `source: ${extension.sourcePath}`, '---', @@ -42,7 +44,9 @@ export function generateRawMd(extension, presetMap, version) { '', '## Source', '', - `- ${code(extension.sourcePath)}`, + `- Package: ${code(extension.packageName)}`, + `- Entry point: ${code(extension.entryPoint)}`, + `- Path: ${code(extension.sourcePath)}`, '', '## Presets', '', diff --git a/infra/docs-gen/src/extractor/record-fields.mjs b/infra/docs-gen/src/extractor/record-fields.mjs index 4d83c121..96793ca2 100644 --- a/infra/docs-gen/src/extractor/record-fields.mjs +++ b/infra/docs-gen/src/extractor/record-fields.mjs @@ -98,6 +98,8 @@ function extractSerializerHints(sourceFiles) { const FIELD_EXTRACTORS = { name: ({name}) => name, + packageName: ({packageName}) => packageName, + entryPoint: ({entryPoint}) => entryPoint, sourcePath: ({sourcePath}) => sourcePath, category: ({category}) => category, nodes: ({schema}) => schema.nodes, diff --git a/infra/docs-gen/src/extractor/scan.mjs b/infra/docs-gen/src/extractor/scan.mjs index 15898cdd..52705482 100644 --- a/infra/docs-gen/src/extractor/scan.mjs +++ b/infra/docs-gen/src/extractor/scan.mjs @@ -13,8 +13,14 @@ import {extractSchema} from './schema.mjs'; /** * Scans one extension directory into raw metadata. */ -export function scanExtension({extDir, category, repoRoot}) { - const name = basename(extDir); +export function scanExtension({ + extDir, + name = basename(extDir), + packageName, + entryPoint, + category, + repoRoot, +}) { const {allFiles, sourceFiles, allContent} = readExtensionSources(extDir); const constants = extractConstants(allContent); const schema = extractSchema(buildSchemaContent(sourceFiles, extDir), constants); @@ -22,6 +28,8 @@ export function scanExtension({extDir, category, repoRoot}) { return createExtensionRecord({ extDir, name, + packageName, + entryPoint, sourcePath: relative(repoRoot, extDir), category, allFiles, From 3d7aba6ac93c8dac864247b8ded862b9985df5ed Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 16 Jun 2026 12:35:57 +0200 Subject: [PATCH 4/4] fix(docs-gen): keep extractor AST-only --- infra/docs-gen/src/config.mjs | 6 -- .../src/extractor/ast/input-rules.mjs | 4 -- infra/docs-gen/src/extractor/cli.test.mjs | 8 ++- infra/docs-gen/src/extractor/markdown-gen.mjs | 2 +- .../docs-gen/src/extractor/options/fields.mjs | 25 +++++++- infra/docs-gen/src/extractor/source-files.mjs | 2 +- infra/docs-gen/src/generate-docs.mjs | 64 +++++++++++-------- infra/docs-gen/src/utils.mjs | 18 ++++-- 8 files changed, 84 insertions(+), 45 deletions(-) diff --git a/infra/docs-gen/src/config.mjs b/infra/docs-gen/src/config.mjs index bfb00ebe..15e16424 100644 --- a/infra/docs-gen/src/config.mjs +++ b/infra/docs-gen/src/config.mjs @@ -7,8 +7,6 @@ import {dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -export const DOCS_DIR = join(REPO_ROOT, 'docs'); -export const DOCS_SRC_DIR = join(REPO_ROOT, 'tmp/docs-src'); export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); export const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); export const PAGE_CONSTRUCTOR_EXTENSION_PKG_DIR = join( @@ -16,10 +14,6 @@ export const PAGE_CONSTRUCTOR_EXTENSION_PKG_DIR = join( 'packages/page-constructor-extension', ); -export const GITHUB_RAW_RE = - /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; -export const HEADER_RE = /^#{5}\s+(.+)$/; - export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; export const EXTENSION_ENTRY_POINTS = [ diff --git a/infra/docs-gen/src/extractor/ast/input-rules.mjs b/infra/docs-gen/src/extractor/ast/input-rules.mjs index dde4f066..336311c1 100644 --- a/infra/docs-gen/src/extractor/ast/input-rules.mjs +++ b/infra/docs-gen/src/extractor/ast/input-rules.mjs @@ -30,10 +30,6 @@ function describeInputRuleCall(callExpression) { if (!firstArg) return null; const current = unwrapExpression(firstArg); - if (current.kind === ts.SyntaxKind.RegularExpressionLiteral) { - return current.getText(callExpression.getSourceFile()); - } - if (ts.isObjectLiteralExpression(current)) { let open = null; let close = null; diff --git a/infra/docs-gen/src/extractor/cli.test.mjs b/infra/docs-gen/src/extractor/cli.test.mjs index 1069233c..cd0d2dee 100644 --- a/infra/docs-gen/src/extractor/cli.test.mjs +++ b/infra/docs-gen/src/extractor/cli.test.mjs @@ -17,6 +17,10 @@ test('parseArgs stops option parsing after separator', () => { }); test('parseArgs rejects missing option values', () => { - assert.throws(() => parseArgs(['--out-dir']), /Missing value for --out-dir/u); - assert.throws(() => parseArgs(['--only', '--help']), /Missing value for --only/u); + assert.throws(() => parseArgs(['--out-dir']), { + message: 'Missing value for --out-dir', + }); + assert.throws(() => parseArgs(['--only', '--help']), { + message: 'Missing value for --only', + }); }); diff --git a/infra/docs-gen/src/extractor/markdown-gen.mjs b/infra/docs-gen/src/extractor/markdown-gen.mjs index 0d73f7c1..68159023 100644 --- a/infra/docs-gen/src/extractor/markdown-gen.mjs +++ b/infra/docs-gen/src/extractor/markdown-gen.mjs @@ -9,7 +9,7 @@ import {getPresetsForExtension} from './presets.mjs'; * Formats a value as inline Markdown code. */ function code(value) { - return `\`${String(value).replace(/\|/g, '\\|').replace(/\n/g, '\\n')}\``; + return `\`${String(value).split('|').join('\\|').split('\n').join('\\n')}\``; } /** diff --git a/infra/docs-gen/src/extractor/options/fields.mjs b/infra/docs-gen/src/extractor/options/fields.mjs index 67df90cb..9a41de11 100644 --- a/infra/docs-gen/src/extractor/options/fields.mjs +++ b/infra/docs-gen/src/extractor/options/fields.mjs @@ -7,11 +7,34 @@ import ts from 'typescript'; import {getStaticPropertyName} from '../ast.mjs'; +/** + * Checks whether a character is a whitespace separator. + */ +function isWhitespace(char) { + return char === ' ' || char === '\n' || char === '\r' || char === '\t' || char === '\f'; +} + /** * Normalizes whitespace in extracted type snippets. */ function normalizeWhitespace(content) { - return content.trim().replace(/\s+/g, ' '); + const trimmed = content.trim(); + let result = ''; + let isInsideWhitespace = false; + + for (const char of trimmed) { + if (isWhitespace(char)) { + if (!isInsideWhitespace) { + result += ' '; + } + isInsideWhitespace = true; + } else { + result += char; + isInsideWhitespace = false; + } + } + + return result; } /** diff --git a/infra/docs-gen/src/extractor/source-files.mjs b/infra/docs-gen/src/extractor/source-files.mjs index 00b7781e..c8f5b40d 100644 --- a/infra/docs-gen/src/extractor/source-files.mjs +++ b/infra/docs-gen/src/extractor/source-files.mjs @@ -9,7 +9,7 @@ import {dirname} from 'node:path'; * Checks whether a file path points to a TypeScript test file. */ export function isTestFile(path) { - return /\.test\.tsx?$/.test(path); + return path.endsWith('.test.ts') || path.endsWith('.test.tsx'); } /** diff --git a/infra/docs-gen/src/generate-docs.mjs b/infra/docs-gen/src/generate-docs.mjs index c323923c..ffb42097 100644 --- a/infra/docs-gen/src/generate-docs.mjs +++ b/infra/docs-gen/src/generate-docs.mjs @@ -1,8 +1,3 @@ -/** - * English: Builds Diplodoc input files from repository markdown documentation. - * - * Русский: Собирает входные файлы Diplodoc из markdown-документации репозитория. - */ import { cpSync, existsSync, @@ -12,17 +7,24 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import {dirname, join} from 'node:path'; +import {dirname, join, resolve} from 'node:path'; import process from 'node:process'; +import {fileURLToPath} from 'node:url'; -import {DOCS_DIR, DOCS_SRC_DIR, GITHUB_RAW_RE, HEADER_RE} from './config.mjs'; +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const DOCS_DIR = join(REPO_ROOT, 'docs'); +const OUT_DIR = join(REPO_ROOT, 'tmp/docs-src'); +const GITHUB_RAW_RE = + /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; // Source docs use ##### as a metadata header (not rendered). // Format: "##### Category / Title" or "##### Title" (no category). // This line is stripped from the output; the rest becomes the page content. +const HEADER_RE = /^#{5}\s+(.+)$/; /** - * Converts a string to a URL-friendly slug. + * Converts a string to a URL-friendly slug (lowercase, alphanumeric, hyphens). + * @param str */ function slugify(str) { return str @@ -32,7 +34,8 @@ function slugify(str) { } /** - * Extracts category and title from a metadata header. + * Extracts category and title from a `##### Category / Title` header line. + * @param firstLine */ function parseHeader(firstLine) { const match = firstLine.match(HEADER_RE); @@ -49,10 +52,10 @@ function parseHeader(firstLine) { /** Removes all generated content from the output directory. */ function cleanOutDir() { - if (existsSync(DOCS_SRC_DIR)) { - rmSync(DOCS_SRC_DIR, {recursive: true, force: true}); + if (existsSync(OUT_DIR)) { + rmSync(OUT_DIR, {recursive: true, force: true}); } - mkdirSync(DOCS_SRC_DIR, {recursive: true}); + mkdirSync(OUT_DIR, {recursive: true}); } /** Reads all markdown files from the source directory and parses their headers. */ @@ -91,7 +94,8 @@ function collectDocs() { } /** - * Splits docs into categorized and top-level groups. + * Splits docs into a category map and a top-level (uncategorized) list. + * @param docs */ function groupByCategory(docs) { const categories = new Map(); @@ -112,7 +116,8 @@ function groupByCategory(docs) { } /** - * Builds a relative output file path. + * Builds a relative output file path from the doc's category and title slugs. + * @param doc */ function computeOutputPath(doc) { if (doc.category) { @@ -122,7 +127,8 @@ function computeOutputPath(doc) { } /** - * Ensures no two docs resolve to the same output path. + * Ensures no two docs resolve to the same output path; exits on collision. + * @param docs */ function checkDuplicatePaths(docs) { const seen = new Map(); @@ -139,7 +145,9 @@ function checkDuplicatePaths(docs) { } /** - * Rewrites absolute GitHub raw URLs to relative asset paths. + * Rewrites absolute GitHub raw URLs to relative paths based on doc nesting depth. + * @param content + * @param doc */ function rewriteAssetUrls(content, doc) { const prefix = doc.category ? '../' : './'; @@ -147,19 +155,21 @@ function rewriteAssetUrls(content, doc) { } /** - * Writes stripped Markdown content to categorized output paths. + * Writes stripped markdown content to categorized output paths. + * @param docs */ function writeDocFiles(docs) { checkDuplicatePaths(docs); for (const doc of docs) { - const outPath = join(DOCS_SRC_DIR, computeOutputPath(doc)); + const outPath = join(OUT_DIR, computeOutputPath(doc)); mkdirSync(dirname(outPath), {recursive: true}); writeFileSync(outPath, rewriteAssetUrls(doc.content, doc)); } } /** - * Wraps YAML values that contain special characters. + * Wraps a string in double quotes if it contains YAML special characters. + * @param str */ function yamlQuote(str) { if (/[:#"'{}[\],&*?|>!%@`]/.test(str)) { @@ -169,7 +179,9 @@ function yamlQuote(str) { } /** - * Generates the table of contents for the documentation site. + * Generates the `toc.yaml` table of contents for the YFM documentation site. + * @param categories + * @param topLevel */ function generateTocYaml(categories, topLevel) { const lines = [ @@ -194,11 +206,13 @@ function generateTocYaml(categories, topLevel) { lines.push(` href: ${computeOutputPath(doc)}`); } - writeFileSync(join(DOCS_SRC_DIR, 'toc.yaml'), lines.join('\n') + '\n'); + writeFileSync(join(OUT_DIR, 'toc.yaml'), lines.join('\n') + '\n'); } /** - * Generates the landing page with links to all doc pages. + * Generates the `index.md` landing page with links to all doc pages. + * @param categories + * @param topLevel */ function generateIndexMd(categories, topLevel) { const lines = [ @@ -223,20 +237,20 @@ function generateIndexMd(categories, topLevel) { lines.push(''); } - writeFileSync(join(DOCS_SRC_DIR, 'index.md'), lines.join('\n')); + writeFileSync(join(OUT_DIR, 'index.md'), lines.join('\n')); } /** Copies the `assets/` directory from source docs to the output directory. */ function copyAssets() { const assetsDir = join(DOCS_DIR, 'assets'); if (existsSync(assetsDir)) { - cpSync(assetsDir, join(DOCS_SRC_DIR, 'assets'), {recursive: true}); + cpSync(assetsDir, join(OUT_DIR, 'assets'), {recursive: true}); } } /** Writes the `.yfm` Diplodoc config into the output directory. */ function writeYfmConfig() { - writeFileSync(join(DOCS_SRC_DIR, '.yfm'), 'allowHTML: true\n'); + writeFileSync(join(OUT_DIR, '.yfm'), 'allowHTML: true\n'); } /** Entry point: cleans output, collects docs, and generates the documentation site. */ diff --git a/infra/docs-gen/src/utils.mjs b/infra/docs-gen/src/utils.mjs index 78502c30..d637d6dc 100644 --- a/infra/docs-gen/src/utils.mjs +++ b/infra/docs-gen/src/utils.mjs @@ -22,19 +22,25 @@ export function listDirs(dir) { return readdirSync(dir) .filter((name) => { const full = join(dir, name); - return statSync(full).isDirectory() && /^[A-Z]/.test(name); + const firstChar = name.charAt(0); + const startsWithUppercaseLetter = + firstChar !== '' && + firstChar === firstChar.toUpperCase() && + firstChar !== firstChar.toLowerCase(); + + return statSync(full).isDirectory() && startsWithUppercaseLetter; }) .sort(); } /** - * Finds recursive directory entries matching a pattern. + * Finds recursive directory entries matching a predicate. */ -export function findFiles(dir, pattern) { +export function findFiles(dir, predicate) { if (!existsSync(dir)) return []; return readdirSync(dir, {recursive: true}) - .filter((entry) => pattern.test(entry)) + .filter((entry) => predicate(entry)) .map((entry) => join(dir, entry)) .sort(); } @@ -43,5 +49,7 @@ export function findFiles(dir, pattern) { * Reads recursive TypeScript source files. */ export function readAllTsFiles(dir) { - return findFiles(dir, /\.tsx?$/).map((path) => ({path, content: readText(path)})); + return findFiles(dir, (entry) => entry.endsWith('.ts') || entry.endsWith('.tsx')).map( + (path) => ({path, content: readText(path)}), + ); }