From 758c20985005882911354969f4326bf15e268e93 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 16 Jun 2026 13:23:24 +0200 Subject: [PATCH 1/7] build(docs-gen): add extension name extractor --- infra/docs-gen/package.json | 7 +- .../docs-gen/src/extract-extension-names.mjs | 267 ++++++++++++++++++ .../src/extract-extension-names.test.mjs | 35 +++ pnpm-lock.yaml | 3 + 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 infra/docs-gen/src/extract-extension-names.mjs create mode 100644 infra/docs-gen/src/extract-extension-names.test.mjs diff --git a/infra/docs-gen/package.json b/infra/docs-gen/package.json index de6aa64c6..1a5d01e01 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:names": "node src/extract-extension-names.mjs", + "test": "node --test src/*.test.mjs" }, "dependencies": { - "@diplodoc/cli": "5.43.0" + "@diplodoc/cli": "5.43.0", + "typescript": "catalog:ts" } } diff --git a/infra/docs-gen/src/extract-extension-names.mjs b/infra/docs-gen/src/extract-extension-names.mjs new file mode 100644 index 000000000..1923fbb34 --- /dev/null +++ b/infra/docs-gen/src/extract-extension-names.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import {basename, dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import ts from 'typescript'; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); + +const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); +const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( + REPO_ROOT, + 'packages/page-constructor-extension/src/extension', +); +const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; +const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); + +export const EXTENSION_NAME_BLACKLIST = [ + 'BaseInputRules', + 'BaseKeymap', + 'BaseStyles', + 'ReactRenderer', + 'SharedState', + 'YfmCut', +]; + +const EXTENSION_ENTRY_POINTS = [ + { + id: 'editor', + kind: 'category-dirs', + packageDir: EDITOR_PKG_DIR, + extensionsDir: 'src/extensions', + categories: EXTENSION_CATEGORIES, + }, + { + id: 'page-constructor-extension', + kind: 'single-extension', + extensionDir: PAGE_CONSTRUCTOR_EXTENSION_DIR, + extensionName: 'YfmPageConstructorExtension', + }, +]; + +function parseSource(content, fileName = 'source.tsx') { + return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +function forEachNode(root, callback) { + const visit = (node) => { + callback(node); + ts.forEachChild(node, visit); + }; + + visit(root); +} + +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; +} + +function getTypeReferenceName(typeName) { + if (ts.isIdentifier(typeName)) return typeName.text; + if (ts.isQualifiedName(typeName)) return typeName.right.text; + + return null; +} + +function isExtensionType(typeNode) { + return ( + typeNode && + ts.isTypeReferenceNode(typeNode) && + EXTENSION_TYPE_NAMES.has(getTypeReferenceName(typeNode.typeName)) + ); +} + +function hasExportModifier(node) { + return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { + if (!initializer) return false; + + const current = unwrapExpression(initializer); + if (!ts.isCallExpression(current) || !ts.isPropertyAccessExpression(current.expression)) { + return false; + } + + const callee = current.expression; + if ( + !ts.isIdentifier(callee.expression) || + callee.expression.text !== 'Object' || + callee.name.text !== 'assign' + ) { + return false; + } + + const firstArg = current.arguments[0]; + return ( + Boolean(firstArg) && + ts.isIdentifier(firstArg) && + extensionImplementations.has(firstArg.text) + ); +} + +function readVariableDeclarations(sourceFile) { + const declarations = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return; + + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + declarations.push({statement: node, declaration}); + } + } + }); + + return declarations; +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function extractExtensionNamesFromSource(content, fileName) { + const sourceFile = parseSource(content, fileName); + const declarations = readVariableDeclarations(sourceFile); + const extensionImplementations = new Set( + declarations + .filter(({declaration}) => isExtensionType(declaration.type)) + .map(({declaration}) => declaration.name.text), + ); + const names = []; + + for (const {statement, declaration} of declarations) { + if (!hasExportModifier(statement)) continue; + + if ( + isExtensionType(declaration.type) || + isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) + ) { + names.push(declaration.name.text); + } + } + + return unique(names); +} + +export function filterExtensionNames(names, blacklist = EXTENSION_NAME_BLACKLIST) { + const blockedNames = new Set(blacklist); + + return names.filter((name) => !blockedNames.has(name)); +} + +function startsWithUppercaseLetter(name) { + const firstChar = name.charAt(0); + + return ( + firstChar !== '' && + firstChar === firstChar.toUpperCase() && + firstChar !== firstChar.toLowerCase() + ); +} + +function listExtensionDirs(dir) { + if (!existsSync(dir)) return []; + + return readdirSync(dir) + .filter((name) => { + const fullPath = join(dir, name); + return statSync(fullPath).isDirectory() && startsWithUppercaseLetter(name); + }) + .map((name) => join(dir, name)) + .sort(); +} + +function readSourceFiles(dir) { + if (!existsSync(dir)) return []; + + const files = []; + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...readSourceFiles(fullPath)); + } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) { + files.push({path: fullPath, content: readFileSync(fullPath, 'utf-8')}); + } + } + + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +function extractExpectedExtensionName(extensionDir, expectedName = basename(extensionDir)) { + const names = unique( + readSourceFiles(extensionDir).flatMap((file) => + extractExtensionNamesFromSource(file.content, file.path), + ), + ); + + return names.includes(expectedName) ? expectedName : null; +} + +function collectCategoryExtensionNames(entryPoint) { + const names = []; + const extensionsRoot = join(entryPoint.packageDir, entryPoint.extensionsDir); + + for (const category of entryPoint.categories) { + const categoryDir = join(extensionsRoot, category); + + for (const extensionDir of listExtensionDirs(categoryDir)) { + names.push(extractExpectedExtensionName(extensionDir)); + } + } + + return names; +} + +function collectSingleExtensionName(entryPoint) { + return [extractExpectedExtensionName(entryPoint.extensionDir, entryPoint.extensionName)]; +} + +export function collectExtensionNames(entryPoints = EXTENSION_ENTRY_POINTS) { + const names = entryPoints.flatMap((entryPoint) => { + if (entryPoint.kind === 'category-dirs') return collectCategoryExtensionNames(entryPoint); + if (entryPoint.kind === 'single-extension') return collectSingleExtensionName(entryPoint); + + return []; + }); + + return filterExtensionNames(unique(names)); +} + +function writeExtensionNames(outDir, names) { + mkdirSync(outDir, {recursive: true}); + writeFileSync( + join(outDir, 'extensions.json'), + `${JSON.stringify({extensions: names}, null, 2)}\n`, + ); +} + +export function main() { + writeExtensionNames(DOCS_GEN_DIR, collectExtensionNames()); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/infra/docs-gen/src/extract-extension-names.test.mjs b/infra/docs-gen/src/extract-extension-names.test.mjs new file mode 100644 index 000000000..48f48b251 --- /dev/null +++ b/infra/docs-gen/src/extract-extension-names.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import { + EXTENSION_NAME_BLACKLIST, + extractExtensionNamesFromSource, + filterExtensionNames, +} from './extract-extension-names.mjs'; + +test('extractExtensionNamesFromSource reads exported extension const names from TypeScript AST', () => { + const content = [ + "import type {ExtensionAuto} from '../../../core';", + 'const InternalExtension: ExtensionAuto = (builder) => {', + ' builder.addAction("internal", () => null);', + '};', + 'export const PublicExtension = Object.assign(InternalExtension, {});', + 'export const DirectExtension: ExtensionAuto = (builder) => {', + ' builder.addAction("direct", () => null);', + '};', + 'export const NotAnExtension = "value";', + ].join('\n'); + + assert.deepEqual(extractExtensionNamesFromSource(content), [ + 'PublicExtension', + 'DirectExtension', + ]); +}); + +test('filterExtensionNames removes blacklisted extension names', () => { + assert.deepEqual(filterExtensionNames(['Bold', 'BaseKeymap', 'YfmCut', 'Italic']), [ + 'Bold', + 'Italic', + ]); + assert.equal(EXTENSION_NAME_BLACKLIST.includes('YfmCut'), true); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 410bbfebc..2dc15fbdd 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 b7881f5b763d6bb5c1e59a94c88f79ed2053be6b Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 16 Jun 2026 13:36:19 +0200 Subject: [PATCH 2/7] refactor(docs-gen): modularize extension extractor --- infra/docs-gen/package.json | 4 +- infra/docs-gen/src/extract-extension-data.mjs | 14 + .../docs-gen/src/extract-extension-names.mjs | 267 ------------------ .../src/extract-extension-names.test.mjs | 35 --- infra/docs-gen/src/extractor/README.md | 18 ++ infra/docs-gen/src/extractor/ast/core.mjs | 38 +++ .../src/extractor/ast/extension-name.mjs | 85 ++++++ infra/docs-gen/src/extractor/blacklist.mjs | 13 + infra/docs-gen/src/extractor/config.mjs | 30 ++ infra/docs-gen/src/extractor/entry-points.mjs | 72 +++++ .../docs-gen/src/extractor/extractor.test.mjs | 55 ++++ infra/docs-gen/src/extractor/field-config.mjs | 22 ++ infra/docs-gen/src/extractor/fields/name.mjs | 12 + infra/docs-gen/src/extractor/index.mjs | 23 ++ infra/docs-gen/src/extractor/output.mjs | 7 + infra/docs-gen/src/extractor/source-files.mjs | 19 ++ 16 files changed, 410 insertions(+), 304 deletions(-) create mode 100644 infra/docs-gen/src/extract-extension-data.mjs delete mode 100644 infra/docs-gen/src/extract-extension-names.mjs delete mode 100644 infra/docs-gen/src/extract-extension-names.test.mjs create mode 100644 infra/docs-gen/src/extractor/README.md create mode 100644 infra/docs-gen/src/extractor/ast/core.mjs create mode 100644 infra/docs-gen/src/extractor/ast/extension-name.mjs create mode 100644 infra/docs-gen/src/extractor/blacklist.mjs create mode 100644 infra/docs-gen/src/extractor/config.mjs create mode 100644 infra/docs-gen/src/extractor/entry-points.mjs create mode 100644 infra/docs-gen/src/extractor/extractor.test.mjs create mode 100644 infra/docs-gen/src/extractor/field-config.mjs create mode 100644 infra/docs-gen/src/extractor/fields/name.mjs create mode 100644 infra/docs-gen/src/extractor/index.mjs create mode 100644 infra/docs-gen/src/extractor/output.mjs create mode 100644 infra/docs-gen/src/extractor/source-files.mjs diff --git a/infra/docs-gen/package.json b/infra/docs-gen/package.json index 1a5d01e01..404f34ac0 100644 --- a/infra/docs-gen/package.json +++ b/infra/docs-gen/package.json @@ -15,8 +15,8 @@ }, "scripts": { "build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs", - "extract:names": "node src/extract-extension-names.mjs", - "test": "node --test src/*.test.mjs" + "extract": "node src/extract-extension-data.mjs", + "test": "node --test src/extractor/*.test.mjs" }, "dependencies": { "@diplodoc/cli": "5.43.0", 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 000000000..0fe583199 --- /dev/null +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import {fileURLToPath} from 'node:url'; + +import {DOCS_GEN_DIR} from './extractor/config.mjs'; +import {extractExtensionData} from './extractor/index.mjs'; +import {writeExtensionsJson} from './extractor/output.mjs'; + +export function main() { + writeExtensionsJson(DOCS_GEN_DIR, extractExtensionData()); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/infra/docs-gen/src/extract-extension-names.mjs b/infra/docs-gen/src/extract-extension-names.mjs deleted file mode 100644 index 1923fbb34..000000000 --- a/infra/docs-gen/src/extract-extension-names.mjs +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env node -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - statSync, - writeFileSync, -} from 'node:fs'; -import {basename, dirname, join, resolve} from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import ts from 'typescript'; - -export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); - -const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); -const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( - REPO_ROOT, - 'packages/page-constructor-extension/src/extension', -); -const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; -const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); - -export const EXTENSION_NAME_BLACKLIST = [ - 'BaseInputRules', - 'BaseKeymap', - 'BaseStyles', - 'ReactRenderer', - 'SharedState', - 'YfmCut', -]; - -const EXTENSION_ENTRY_POINTS = [ - { - id: 'editor', - kind: 'category-dirs', - packageDir: EDITOR_PKG_DIR, - extensionsDir: 'src/extensions', - categories: EXTENSION_CATEGORIES, - }, - { - id: 'page-constructor-extension', - kind: 'single-extension', - extensionDir: PAGE_CONSTRUCTOR_EXTENSION_DIR, - extensionName: 'YfmPageConstructorExtension', - }, -]; - -function parseSource(content, fileName = 'source.tsx') { - return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -} - -function forEachNode(root, callback) { - const visit = (node) => { - callback(node); - ts.forEachChild(node, visit); - }; - - visit(root); -} - -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; -} - -function getTypeReferenceName(typeName) { - if (ts.isIdentifier(typeName)) return typeName.text; - if (ts.isQualifiedName(typeName)) return typeName.right.text; - - return null; -} - -function isExtensionType(typeNode) { - return ( - typeNode && - ts.isTypeReferenceNode(typeNode) && - EXTENSION_TYPE_NAMES.has(getTypeReferenceName(typeNode.typeName)) - ); -} - -function hasExportModifier(node) { - return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); -} - -function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { - if (!initializer) return false; - - const current = unwrapExpression(initializer); - if (!ts.isCallExpression(current) || !ts.isPropertyAccessExpression(current.expression)) { - return false; - } - - const callee = current.expression; - if ( - !ts.isIdentifier(callee.expression) || - callee.expression.text !== 'Object' || - callee.name.text !== 'assign' - ) { - return false; - } - - const firstArg = current.arguments[0]; - return ( - Boolean(firstArg) && - ts.isIdentifier(firstArg) && - extensionImplementations.has(firstArg.text) - ); -} - -function readVariableDeclarations(sourceFile) { - const declarations = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isVariableStatement(node)) return; - - for (const declaration of node.declarationList.declarations) { - if (ts.isIdentifier(declaration.name)) { - declarations.push({statement: node, declaration}); - } - } - }); - - return declarations; -} - -function unique(values) { - return [...new Set(values.filter(Boolean))]; -} - -export function extractExtensionNamesFromSource(content, fileName) { - const sourceFile = parseSource(content, fileName); - const declarations = readVariableDeclarations(sourceFile); - const extensionImplementations = new Set( - declarations - .filter(({declaration}) => isExtensionType(declaration.type)) - .map(({declaration}) => declaration.name.text), - ); - const names = []; - - for (const {statement, declaration} of declarations) { - if (!hasExportModifier(statement)) continue; - - if ( - isExtensionType(declaration.type) || - isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) - ) { - names.push(declaration.name.text); - } - } - - return unique(names); -} - -export function filterExtensionNames(names, blacklist = EXTENSION_NAME_BLACKLIST) { - const blockedNames = new Set(blacklist); - - return names.filter((name) => !blockedNames.has(name)); -} - -function startsWithUppercaseLetter(name) { - const firstChar = name.charAt(0); - - return ( - firstChar !== '' && - firstChar === firstChar.toUpperCase() && - firstChar !== firstChar.toLowerCase() - ); -} - -function listExtensionDirs(dir) { - if (!existsSync(dir)) return []; - - return readdirSync(dir) - .filter((name) => { - const fullPath = join(dir, name); - return statSync(fullPath).isDirectory() && startsWithUppercaseLetter(name); - }) - .map((name) => join(dir, name)) - .sort(); -} - -function readSourceFiles(dir) { - if (!existsSync(dir)) return []; - - const files = []; - for (const entry of readdirSync(dir, {withFileTypes: true})) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - files.push(...readSourceFiles(fullPath)); - } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) { - files.push({path: fullPath, content: readFileSync(fullPath, 'utf-8')}); - } - } - - return files.sort((left, right) => left.path.localeCompare(right.path)); -} - -function extractExpectedExtensionName(extensionDir, expectedName = basename(extensionDir)) { - const names = unique( - readSourceFiles(extensionDir).flatMap((file) => - extractExtensionNamesFromSource(file.content, file.path), - ), - ); - - return names.includes(expectedName) ? expectedName : null; -} - -function collectCategoryExtensionNames(entryPoint) { - const names = []; - const extensionsRoot = join(entryPoint.packageDir, entryPoint.extensionsDir); - - for (const category of entryPoint.categories) { - const categoryDir = join(extensionsRoot, category); - - for (const extensionDir of listExtensionDirs(categoryDir)) { - names.push(extractExpectedExtensionName(extensionDir)); - } - } - - return names; -} - -function collectSingleExtensionName(entryPoint) { - return [extractExpectedExtensionName(entryPoint.extensionDir, entryPoint.extensionName)]; -} - -export function collectExtensionNames(entryPoints = EXTENSION_ENTRY_POINTS) { - const names = entryPoints.flatMap((entryPoint) => { - if (entryPoint.kind === 'category-dirs') return collectCategoryExtensionNames(entryPoint); - if (entryPoint.kind === 'single-extension') return collectSingleExtensionName(entryPoint); - - return []; - }); - - return filterExtensionNames(unique(names)); -} - -function writeExtensionNames(outDir, names) { - mkdirSync(outDir, {recursive: true}); - writeFileSync( - join(outDir, 'extensions.json'), - `${JSON.stringify({extensions: names}, null, 2)}\n`, - ); -} - -export function main() { - writeExtensionNames(DOCS_GEN_DIR, collectExtensionNames()); -} - -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - main(); -} diff --git a/infra/docs-gen/src/extract-extension-names.test.mjs b/infra/docs-gen/src/extract-extension-names.test.mjs deleted file mode 100644 index 48f48b251..000000000 --- a/infra/docs-gen/src/extract-extension-names.test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import {test} from 'node:test'; - -import { - EXTENSION_NAME_BLACKLIST, - extractExtensionNamesFromSource, - filterExtensionNames, -} from './extract-extension-names.mjs'; - -test('extractExtensionNamesFromSource reads exported extension const names from TypeScript AST', () => { - const content = [ - "import type {ExtensionAuto} from '../../../core';", - 'const InternalExtension: ExtensionAuto = (builder) => {', - ' builder.addAction("internal", () => null);', - '};', - 'export const PublicExtension = Object.assign(InternalExtension, {});', - 'export const DirectExtension: ExtensionAuto = (builder) => {', - ' builder.addAction("direct", () => null);', - '};', - 'export const NotAnExtension = "value";', - ].join('\n'); - - assert.deepEqual(extractExtensionNamesFromSource(content), [ - 'PublicExtension', - 'DirectExtension', - ]); -}); - -test('filterExtensionNames removes blacklisted extension names', () => { - assert.deepEqual(filterExtensionNames(['Bold', 'BaseKeymap', 'YfmCut', 'Italic']), [ - 'Bold', - 'Italic', - ]); - assert.equal(EXTENSION_NAME_BLACKLIST.includes('YfmCut'), true); -}); diff --git a/infra/docs-gen/src/extractor/README.md b/infra/docs-gen/src/extractor/README.md new file mode 100644 index 000000000..e89234646 --- /dev/null +++ b/infra/docs-gen/src/extractor/README.md @@ -0,0 +1,18 @@ +# Extension Extractor Layout + +This extractor is intentionally modular from the first PR, even though it currently writes only +the `name` field. + +- `config.mjs` defines repository paths and extension entry points. +- `blacklist.mjs` owns internal and public extension skip lists. +- `entry-points.mjs` turns configured entry points into extension refs and applies blacklist + filtering. +- `source-files.mjs` reads TypeScript/TSX sources for one extension ref. +- `ast/` contains TypeScript AST primitives and source scanners. +- `fields/` contains one controller per extracted field. +- `field-config.mjs` declares the active output fields and connects each field to its controller. +- `index.mjs` orchestrates ref collection, source reading, field extraction, and record creation. +- `output.mjs` writes extracted records to `tmp/docs-gen/extensions.json`. + +To add another field later, add its controller under `fields/`, add any focused AST scanner under +`ast/`, then register the field in `field-config.mjs`. 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 000000000..144f1e513 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/core.mjs @@ -0,0 +1,38 @@ +import ts from 'typescript'; + +export function parseSource(content, fileName = 'source.tsx') { + return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +export function forEachNode(root, callback) { + const visit = (node) => { + callback(node); + ts.forEachChild(node, visit); + }; + + visit(root); +} + +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; +} + +export function hasExportModifier(node) { + return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +export function unique(values) { + return [...new Set(values.filter(Boolean))]; +} diff --git a/infra/docs-gen/src/extractor/ast/extension-name.mjs b/infra/docs-gen/src/extractor/ast/extension-name.mjs new file mode 100644 index 000000000..042b138a9 --- /dev/null +++ b/infra/docs-gen/src/extractor/ast/extension-name.mjs @@ -0,0 +1,85 @@ +import ts from 'typescript'; + +import {forEachNode, hasExportModifier, parseSource, unique, unwrapExpression} from './core.mjs'; + +const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); + +function getTypeReferenceName(typeName) { + if (ts.isIdentifier(typeName)) return typeName.text; + if (ts.isQualifiedName(typeName)) return typeName.right.text; + + return null; +} + +function isExtensionType(typeNode) { + return ( + typeNode && + ts.isTypeReferenceNode(typeNode) && + EXTENSION_TYPE_NAMES.has(getTypeReferenceName(typeNode.typeName)) + ); +} + +function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { + if (!initializer) return false; + + const current = unwrapExpression(initializer); + if (!ts.isCallExpression(current) || !ts.isPropertyAccessExpression(current.expression)) { + return false; + } + + const callee = current.expression; + if ( + !ts.isIdentifier(callee.expression) || + callee.expression.text !== 'Object' || + callee.name.text !== 'assign' + ) { + return false; + } + + const firstArg = current.arguments[0]; + return ( + Boolean(firstArg) && + ts.isIdentifier(firstArg) && + extensionImplementations.has(firstArg.text) + ); +} + +function readVariableDeclarations(sourceFile) { + const declarations = []; + + forEachNode(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return; + + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + declarations.push({statement: node, declaration}); + } + } + }); + + return declarations; +} + +export function extractExtensionNamesFromSource(content, fileName) { + const sourceFile = parseSource(content, fileName); + const declarations = readVariableDeclarations(sourceFile); + const extensionImplementations = new Set( + declarations + .filter(({declaration}) => isExtensionType(declaration.type)) + .map(({declaration}) => declaration.name.text), + ); + const names = []; + + for (const {statement, declaration} of declarations) { + if (!hasExportModifier(statement)) continue; + + if ( + isExtensionType(declaration.type) || + isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) + ) { + names.push(declaration.name.text); + } + } + + return unique(names); +} diff --git a/infra/docs-gen/src/extractor/blacklist.mjs b/infra/docs-gen/src/extractor/blacklist.mjs new file mode 100644 index 000000000..3864bd0e6 --- /dev/null +++ b/infra/docs-gen/src/extractor/blacklist.mjs @@ -0,0 +1,13 @@ +export const INTERNAL_EXTENSION_BLACKLIST = [ + 'BaseInputRules', + 'BaseKeymap', + 'BaseStyles', + 'ReactRenderer', + 'SharedState', +]; + +export const EXTENSION_BLACKLIST = ['YfmCut']; + +export function isBlacklistedExtension(name) { + return [...INTERNAL_EXTENSION_BLACKLIST, ...EXTENSION_BLACKLIST].includes(name); +} diff --git a/infra/docs-gen/src/extractor/config.mjs b/infra/docs-gen/src/extractor/config.mjs new file mode 100644 index 000000000..f9d0f5745 --- /dev/null +++ b/infra/docs-gen/src/extractor/config.mjs @@ -0,0 +1,30 @@ +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_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); +export const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); +export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( + REPO_ROOT, + 'packages/page-constructor-extension/src/extension', +); + +export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; + +export const EXTENSION_ENTRY_POINTS = [ + { + id: 'editor', + packageName: '@gravity-ui/markdown-editor', + kind: 'category-dirs', + packageDir: EDITOR_PKG_DIR, + extensionsDir: 'src/extensions', + categories: EXTENSION_CATEGORIES, + }, + { + id: 'page-constructor-extension', + packageName: '@gravity-ui/markdown-editor-page-constructor-extension', + kind: 'single-extension', + extensionDir: PAGE_CONSTRUCTOR_EXTENSION_DIR, + extensionName: 'YfmPageConstructorExtension', + }, +]; diff --git a/infra/docs-gen/src/extractor/entry-points.mjs b/infra/docs-gen/src/extractor/entry-points.mjs new file mode 100644 index 000000000..0d2849dd5 --- /dev/null +++ b/infra/docs-gen/src/extractor/entry-points.mjs @@ -0,0 +1,72 @@ +import {existsSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; + +import {isBlacklistedExtension} from './blacklist.mjs'; + +function startsWithUppercaseLetter(name) { + const firstChar = name.charAt(0); + + return ( + firstChar !== '' && + firstChar === firstChar.toUpperCase() && + firstChar !== firstChar.toLowerCase() + ); +} + +function listExtensionDirs(dir) { + if (!existsSync(dir)) return []; + + return readdirSync(dir) + .filter((name) => { + const fullPath = join(dir, name); + return statSync(fullPath).isDirectory() && startsWithUppercaseLetter(name); + }) + .map((name) => ({name, dir: join(dir, name)})) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function collectCategoryExtensionRefs(entryPoint) { + const extensionsRoot = join(entryPoint.packageDir, entryPoint.extensionsDir); + const refs = []; + + for (const category of entryPoint.categories) { + const categoryDir = join(extensionsRoot, category); + + for (const extensionDir of listExtensionDirs(categoryDir)) { + refs.push({ + name: extensionDir.name, + sourceDir: extensionDir.dir, + category, + entryPoint: entryPoint.id, + packageName: entryPoint.packageName, + }); + } + } + + return refs; +} + +function collectSingleExtensionRef(entryPoint) { + return [ + { + name: entryPoint.extensionName, + sourceDir: entryPoint.extensionDir, + category: 'external', + entryPoint: entryPoint.id, + packageName: entryPoint.packageName, + }, + ]; +} + +export function collectExtensionRefs(entryPoints) { + return entryPoints.flatMap((entryPoint) => { + if (entryPoint.kind === 'category-dirs') return collectCategoryExtensionRefs(entryPoint); + if (entryPoint.kind === 'single-extension') return collectSingleExtensionRef(entryPoint); + + return []; + }); +} + +export function filterExtensionRefs(extensionRefs) { + return extensionRefs.filter((extensionRef) => !isBlacklistedExtension(extensionRef.name)); +} diff --git a/infra/docs-gen/src/extractor/extractor.test.mjs b/infra/docs-gen/src/extractor/extractor.test.mjs new file mode 100644 index 000000000..8936f3f7c --- /dev/null +++ b/infra/docs-gen/src/extractor/extractor.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {extractExtensionNamesFromSource} from './ast/extension-name.mjs'; +import {EXTENSION_BLACKLIST, INTERNAL_EXTENSION_BLACKLIST} from './blacklist.mjs'; +import {filterExtensionRefs} from './entry-points.mjs'; +import {createExtensionRecord, EXTENSION_FIELD_CONFIG} from './field-config.mjs'; + +function createSourceFile(content) { + return {path: 'extension.ts', content}; +} + +test('extractExtensionNamesFromSource reads exported extension const names from TypeScript AST', () => { + const content = [ + "import type {ExtensionAuto} from '../../../core';", + 'const InternalExtension: ExtensionAuto = (builder) => {', + ' builder.addAction("internal", () => null);', + '};', + 'export const PublicExtension = Object.assign(InternalExtension, {});', + 'export const DirectExtension: ExtensionAuto = (builder) => {', + ' builder.addAction("direct", () => null);', + '};', + 'export const NotAnExtension = "value";', + ].join('\n'); + + assert.deepEqual(extractExtensionNamesFromSource(content), [ + 'PublicExtension', + 'DirectExtension', + ]); +}); + +test('filterExtensionRefs removes internal and public blacklist entries', () => { + assert.deepEqual( + filterExtensionRefs([ + {name: 'Bold'}, + {name: 'BaseKeymap'}, + {name: 'YfmCut'}, + {name: 'Italic'}, + ]).map((extensionRef) => extensionRef.name), + ['Bold', 'Italic'], + ); + assert.equal(INTERNAL_EXTENSION_BLACKLIST.includes('BaseKeymap'), true); + assert.equal(EXTENSION_BLACKLIST.includes('YfmCut'), true); +}); + +test('createExtensionRecord extracts only configured fields', () => { + const content = 'export const Bold: ExtensionAuto = (builder) => builder;'; + const record = createExtensionRecord({ + extensionRef: {name: 'Bold'}, + sourceFiles: [createSourceFile(content)], + }); + + assert.deepEqual(Object.keys(EXTENSION_FIELD_CONFIG), ['name']); + assert.deepEqual(record, {name: 'Bold'}); +}); diff --git a/infra/docs-gen/src/extractor/field-config.mjs b/infra/docs-gen/src/extractor/field-config.mjs new file mode 100644 index 000000000..c5281ae42 --- /dev/null +++ b/infra/docs-gen/src/extractor/field-config.mjs @@ -0,0 +1,22 @@ +import {extractNameField} from './fields/name.mjs'; + +export const EXTENSION_FIELD_CONFIG = { + name: { + from: 'extension source files: exported Extension/ExtensionAuto/ExtensionWithOptions const', + required: true, + extract: extractNameField, + }, +}; + +export function createExtensionRecord(context, fieldConfig = EXTENSION_FIELD_CONFIG) { + const record = {}; + + for (const [fieldName, config] of Object.entries(fieldConfig)) { + const value = config.extract(context); + if (config.required && value === null) return null; + + record[fieldName] = value; + } + + return record; +} diff --git a/infra/docs-gen/src/extractor/fields/name.mjs b/infra/docs-gen/src/extractor/fields/name.mjs new file mode 100644 index 000000000..a30ad4910 --- /dev/null +++ b/infra/docs-gen/src/extractor/fields/name.mjs @@ -0,0 +1,12 @@ +import {extractExtensionNamesFromSource} from '../ast/extension-name.mjs'; +import {unique} from '../ast/core.mjs'; + +export function extractNameField({extensionRef, sourceFiles}) { + const names = unique( + sourceFiles.flatMap((sourceFile) => + extractExtensionNamesFromSource(sourceFile.content, sourceFile.path), + ), + ); + + return names.includes(extensionRef.name) ? extensionRef.name : null; +} diff --git a/infra/docs-gen/src/extractor/index.mjs b/infra/docs-gen/src/extractor/index.mjs new file mode 100644 index 000000000..5d83ef87b --- /dev/null +++ b/infra/docs-gen/src/extractor/index.mjs @@ -0,0 +1,23 @@ +import {EXTENSION_ENTRY_POINTS} from './config.mjs'; +import {collectExtensionRefs, filterExtensionRefs} from './entry-points.mjs'; +import {createExtensionRecord, EXTENSION_FIELD_CONFIG} from './field-config.mjs'; +import {readSourceFiles} from './source-files.mjs'; + +function scanExtension(extensionRef, fieldConfig) { + return createExtensionRecord( + { + extensionRef, + sourceFiles: readSourceFiles(extensionRef.sourceDir), + }, + fieldConfig, + ); +} + +export function extractExtensionData({ + entryPoints = EXTENSION_ENTRY_POINTS, + fieldConfig = EXTENSION_FIELD_CONFIG, +} = {}) { + return filterExtensionRefs(collectExtensionRefs(entryPoints)) + .map((extensionRef) => scanExtension(extensionRef, fieldConfig)) + .filter(Boolean); +} diff --git a/infra/docs-gen/src/extractor/output.mjs b/infra/docs-gen/src/extractor/output.mjs new file mode 100644 index 000000000..8799f0dc5 --- /dev/null +++ b/infra/docs-gen/src/extractor/output.mjs @@ -0,0 +1,7 @@ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; + +export function writeExtensionsJson(outDir, extensions) { + mkdirSync(outDir, {recursive: true}); + writeFileSync(join(outDir, 'extensions.json'), `${JSON.stringify({extensions}, null, 2)}\n`); +} 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 000000000..1a3381214 --- /dev/null +++ b/infra/docs-gen/src/extractor/source-files.mjs @@ -0,0 +1,19 @@ +import {existsSync, readFileSync, readdirSync} from 'node:fs'; +import {join} from 'node:path'; + +export function readSourceFiles(dir) { + if (!existsSync(dir)) return []; + + const files = []; + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...readSourceFiles(fullPath)); + } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) { + files.push({path: fullPath, content: readFileSync(fullPath, 'utf-8')}); + } + } + + return files.sort((left, right) => left.path.localeCompare(right.path)); +} From abe5aee23af1d8f8c9afc0964f4771b9e4c8944e Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 23 Jun 2026 00:55:44 +0200 Subject: [PATCH 3/7] build(docs-gen): add simple extension list extractor --- infra/docs-gen/package.json | 2 +- infra/docs-gen/src/extension-ast.mjs | 102 ++++++++++++++++++ infra/docs-gen/src/extract-extension-data.mjs | 96 ++++++++++++++++- .../src/extract-extension-data.test.mjs | 82 ++++++++++++++ infra/docs-gen/src/extractor/README.md | 18 ---- infra/docs-gen/src/extractor/ast/core.mjs | 38 ------- .../src/extractor/ast/extension-name.mjs | 85 --------------- infra/docs-gen/src/extractor/blacklist.mjs | 13 --- infra/docs-gen/src/extractor/config.mjs | 30 ------ infra/docs-gen/src/extractor/entry-points.mjs | 72 ------------- .../docs-gen/src/extractor/extractor.test.mjs | 55 ---------- infra/docs-gen/src/extractor/field-config.mjs | 22 ---- infra/docs-gen/src/extractor/fields/name.mjs | 12 --- infra/docs-gen/src/extractor/index.mjs | 23 ---- infra/docs-gen/src/extractor/output.mjs | 7 -- infra/docs-gen/src/extractor/source-files.mjs | 19 ---- infra/docs-gen/src/generate-docs.mjs | 13 ++- 17 files changed, 289 insertions(+), 400 deletions(-) create mode 100644 infra/docs-gen/src/extension-ast.mjs create mode 100644 infra/docs-gen/src/extract-extension-data.test.mjs delete mode 100644 infra/docs-gen/src/extractor/README.md delete mode 100644 infra/docs-gen/src/extractor/ast/core.mjs delete mode 100644 infra/docs-gen/src/extractor/ast/extension-name.mjs delete mode 100644 infra/docs-gen/src/extractor/blacklist.mjs delete mode 100644 infra/docs-gen/src/extractor/config.mjs delete mode 100644 infra/docs-gen/src/extractor/entry-points.mjs delete mode 100644 infra/docs-gen/src/extractor/extractor.test.mjs delete mode 100644 infra/docs-gen/src/extractor/field-config.mjs delete mode 100644 infra/docs-gen/src/extractor/fields/name.mjs delete mode 100644 infra/docs-gen/src/extractor/index.mjs delete mode 100644 infra/docs-gen/src/extractor/output.mjs delete mode 100644 infra/docs-gen/src/extractor/source-files.mjs diff --git a/infra/docs-gen/package.json b/infra/docs-gen/package.json index 404f34ac0..db478c6d3 100644 --- a/infra/docs-gen/package.json +++ b/infra/docs-gen/package.json @@ -16,7 +16,7 @@ "scripts": { "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" + "test": "node --test src/*.test.mjs" }, "dependencies": { "@diplodoc/cli": "5.43.0", diff --git a/infra/docs-gen/src/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs new file mode 100644 index 000000000..26f61b10f --- /dev/null +++ b/infra/docs-gen/src/extension-ast.mjs @@ -0,0 +1,102 @@ +import ts from 'typescript'; + +const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); +const EXTENSION_BUILDER_TYPE_NAMES = new Set(['ExtensionBuilder']); + +function getTypeReferenceName(typeName) { + if (ts.isIdentifier(typeName)) return typeName.text; + if (ts.isQualifiedName(typeName)) return typeName.right.text; + + return null; +} + +function isTypeReferenceTo(typeNode, names) { + return ( + typeNode && + ts.isTypeReferenceNode(typeNode) && + names.has(getTypeReferenceName(typeNode.typeName)) + ); +} + +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; +} + +function hasExportModifier(node) { + return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +function readVariableDeclarations(sourceFile) { + return sourceFile.statements.flatMap((statement) => { + if (!ts.isVariableStatement(statement)) return []; + + return statement.declarationList.declarations + .filter((declaration) => ts.isIdentifier(declaration.name)) + .map((declaration) => ({statement, declaration})); + }); +} + +function isExtensionInitializer(initializer) { + const current = initializer && unwrapExpression(initializer); + + return ( + current && + ts.isArrowFunction(current) && + isTypeReferenceTo(current.parameters[0]?.type, EXTENSION_BUILDER_TYPE_NAMES) + ); +} + +function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { + const current = initializer && unwrapExpression(initializer); + if ( + !current || + !ts.isCallExpression(current) || + !ts.isPropertyAccessExpression(current.expression) + ) { + return false; + } + + const callee = current.expression; + const firstArg = current.arguments[0]; + + return ( + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' && + callee.name.text === 'assign' && + firstArg && + ts.isIdentifier(firstArg) && + extensionImplementations.has(firstArg.text) + ); +} + +export function sourceHasExtensionExport(content) { + const sourceFile = ts.createSourceFile('source.tsx', content, ts.ScriptTarget.Latest, true); + const declarations = readVariableDeclarations(sourceFile); + const extensionImplementations = new Set( + declarations + .filter(({declaration}) => isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES)) + .map(({declaration}) => declaration.name.text), + ); + + return declarations.some(({statement, declaration}) => { + if (!hasExportModifier(statement)) return false; + + return ( + isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES) || + isExtensionInitializer(declaration.initializer) || + isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) + ); + }); +} diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index 0fe583199..bf23e2f3e 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -1,12 +1,100 @@ #!/usr/bin/env node +import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {dirname, join, resolve} from 'node:path'; +import process from 'node:process'; import {fileURLToPath} from 'node:url'; -import {DOCS_GEN_DIR} from './extractor/config.mjs'; -import {extractExtensionData} from './extractor/index.mjs'; -import {writeExtensionsJson} from './extractor/output.mjs'; +import {sourceHasExtensionExport} from './extension-ast.mjs'; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); +export const EDITOR_EXTENSIONS_DIR = join(REPO_ROOT, 'packages/editor/src/extensions'); +export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( + REPO_ROOT, + 'packages/page-constructor-extension/src/extension', +); +export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; +export const EXTRA_EXTENSION_REFS = [ + {name: 'YfmPageConstructorExtension', dir: PAGE_CONSTRUCTOR_EXTENSION_DIR}, +]; +export const EXTENSION_BLACKLIST = [ + 'BaseInputRules', + 'BaseKeymap', + 'BaseStyles', + 'ReactRenderer', + 'Resizable', + 'SharedState', + 'YfmCut', +]; + +function startsWithUppercaseLetter(name) { + const firstChar = name.charAt(0); + + return ( + firstChar !== '' && + firstChar === firstChar.toUpperCase() && + firstChar !== firstChar.toLowerCase() + ); +} + +function listExtensionRefs(dir) { + if (!existsSync(dir)) return []; + + return readdirSync(dir, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() && startsWithUppercaseLetter(entry.name)) + .map((entry) => ({name: entry.name, dir: join(dir, entry.name)})) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function readSourceFiles(dir) { + if (!existsSync(dir)) return []; + + const files = []; + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...readSourceFiles(fullPath)); + } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) { + files.push(readFileSync(fullPath, 'utf-8')); + } + } + + return files; +} + +function refHasExtensionExport(ref) { + return readSourceFiles(ref.dir).some(sourceHasExtensionExport); +} + +export function listExtensionNames({ + extensionsDir = EDITOR_EXTENSIONS_DIR, + categories = EXTENSION_CATEGORIES, + extraExtensionRefs = EXTRA_EXTENSION_REFS, + blacklist = EXTENSION_BLACKLIST, +} = {}) { + const blacklistSet = new Set(blacklist); + const refs = categories.flatMap((category) => listExtensionRefs(join(extensionsDir, category))); + + return [...refs, ...extraExtensionRefs] + .filter((ref) => !blacklistSet.has(ref.name) && refHasExtensionExport(ref)) + .map((ref) => ref.name); +} + +export function createExtensionRecords(names) { + return names.map((name) => ({name})); +} + +export function writeExtensionsJson(outDir = DOCS_GEN_DIR, names = listExtensionNames()) { + mkdirSync(outDir, {recursive: true}); + writeFileSync( + join(outDir, 'extensions.json'), + `${JSON.stringify({extensions: createExtensionRecords(names)}, null, 2)}\n`, + ); +} export function main() { - writeExtensionsJson(DOCS_GEN_DIR, extractExtensionData()); + writeExtensionsJson(); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/infra/docs-gen/src/extract-extension-data.test.mjs b/infra/docs-gen/src/extract-extension-data.test.mjs new file mode 100644 index 000000000..861ac22ad --- /dev/null +++ b/infra/docs-gen/src/extract-extension-data.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, test} from 'node:test'; + +import { + createExtensionRecords, + listExtensionNames, + writeExtensionsJson, +} from './extract-extension-data.mjs'; + +const cleanupDirs = []; + +function makeExtensionsRoot() { + const root = mkdtempSync(join(tmpdir(), 'docs-gen-extensions-')); + cleanupDirs.push(root); + + return root; +} + +function addExtensionFile(root, category, name, content) { + const dir = join(root, category, name); + + mkdirSync(dir, {recursive: true}); + writeFileSync(join(dir, 'index.ts'), content); +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, {recursive: true, force: true}); + } +}); + +test('listExtensionNames keeps AST-backed extension dirs and applies blacklist', () => { + const extensionsDir = makeExtensionsRoot(); + const extraDir = makeExtensionsRoot(); + + addExtensionFile( + extensionsDir, + 'base', + 'BaseKeymap', + 'export const BaseKeymap: ExtensionAuto = () => {};', + ); + addExtensionFile(extensionsDir, 'base', 'Bold', 'export const Bold: ExtensionAuto = () => {};'); + addExtensionFile( + extensionsDir, + 'behavior', + 'Resizable', + 'export const Resizable: React.FC = () => null;', + ); + addExtensionFile( + extensionsDir, + 'additional', + 'GPT', + 'export const gptExtension = (builder: ExtensionBuilder) => builder;', + ); + addExtensionFile(extensionsDir, 'additional', 'Widget', 'export const Widget = () => null;'); + writeFileSync( + join(extraDir, 'index.ts'), + 'export const YfmPageConstructorExtension: ExtensionAuto = () => {};', + ); + + assert.deepEqual( + listExtensionNames({ + extensionsDir, + categories: ['base', 'behavior', 'additional'], + extraExtensionRefs: [{name: 'YfmPageConstructorExtension', dir: extraDir}], + }), + ['Bold', 'GPT', 'YfmPageConstructorExtension'], + ); +}); + +test('writeExtensionsJson writes extension name records', () => { + const outDir = makeExtensionsRoot(); + + writeExtensionsJson(outDir, ['Bold', 'GPT']); + + assert.deepEqual(JSON.parse(readFileSync(join(outDir, 'extensions.json'), 'utf-8')), { + extensions: createExtensionRecords(['Bold', 'GPT']), + }); +}); diff --git a/infra/docs-gen/src/extractor/README.md b/infra/docs-gen/src/extractor/README.md deleted file mode 100644 index e89234646..000000000 --- a/infra/docs-gen/src/extractor/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Extension Extractor Layout - -This extractor is intentionally modular from the first PR, even though it currently writes only -the `name` field. - -- `config.mjs` defines repository paths and extension entry points. -- `blacklist.mjs` owns internal and public extension skip lists. -- `entry-points.mjs` turns configured entry points into extension refs and applies blacklist - filtering. -- `source-files.mjs` reads TypeScript/TSX sources for one extension ref. -- `ast/` contains TypeScript AST primitives and source scanners. -- `fields/` contains one controller per extracted field. -- `field-config.mjs` declares the active output fields and connects each field to its controller. -- `index.mjs` orchestrates ref collection, source reading, field extraction, and record creation. -- `output.mjs` writes extracted records to `tmp/docs-gen/extensions.json`. - -To add another field later, add its controller under `fields/`, add any focused AST scanner under -`ast/`, then register the field in `field-config.mjs`. diff --git a/infra/docs-gen/src/extractor/ast/core.mjs b/infra/docs-gen/src/extractor/ast/core.mjs deleted file mode 100644 index 144f1e513..000000000 --- a/infra/docs-gen/src/extractor/ast/core.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import ts from 'typescript'; - -export function parseSource(content, fileName = 'source.tsx') { - return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -} - -export function forEachNode(root, callback) { - const visit = (node) => { - callback(node); - ts.forEachChild(node, visit); - }; - - visit(root); -} - -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; -} - -export function hasExportModifier(node) { - return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); -} - -export function unique(values) { - return [...new Set(values.filter(Boolean))]; -} diff --git a/infra/docs-gen/src/extractor/ast/extension-name.mjs b/infra/docs-gen/src/extractor/ast/extension-name.mjs deleted file mode 100644 index 042b138a9..000000000 --- a/infra/docs-gen/src/extractor/ast/extension-name.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import ts from 'typescript'; - -import {forEachNode, hasExportModifier, parseSource, unique, unwrapExpression} from './core.mjs'; - -const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); - -function getTypeReferenceName(typeName) { - if (ts.isIdentifier(typeName)) return typeName.text; - if (ts.isQualifiedName(typeName)) return typeName.right.text; - - return null; -} - -function isExtensionType(typeNode) { - return ( - typeNode && - ts.isTypeReferenceNode(typeNode) && - EXTENSION_TYPE_NAMES.has(getTypeReferenceName(typeNode.typeName)) - ); -} - -function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { - if (!initializer) return false; - - const current = unwrapExpression(initializer); - if (!ts.isCallExpression(current) || !ts.isPropertyAccessExpression(current.expression)) { - return false; - } - - const callee = current.expression; - if ( - !ts.isIdentifier(callee.expression) || - callee.expression.text !== 'Object' || - callee.name.text !== 'assign' - ) { - return false; - } - - const firstArg = current.arguments[0]; - return ( - Boolean(firstArg) && - ts.isIdentifier(firstArg) && - extensionImplementations.has(firstArg.text) - ); -} - -function readVariableDeclarations(sourceFile) { - const declarations = []; - - forEachNode(sourceFile, (node) => { - if (!ts.isVariableStatement(node)) return; - - for (const declaration of node.declarationList.declarations) { - if (ts.isIdentifier(declaration.name)) { - declarations.push({statement: node, declaration}); - } - } - }); - - return declarations; -} - -export function extractExtensionNamesFromSource(content, fileName) { - const sourceFile = parseSource(content, fileName); - const declarations = readVariableDeclarations(sourceFile); - const extensionImplementations = new Set( - declarations - .filter(({declaration}) => isExtensionType(declaration.type)) - .map(({declaration}) => declaration.name.text), - ); - const names = []; - - for (const {statement, declaration} of declarations) { - if (!hasExportModifier(statement)) continue; - - if ( - isExtensionType(declaration.type) || - isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) - ) { - names.push(declaration.name.text); - } - } - - return unique(names); -} diff --git a/infra/docs-gen/src/extractor/blacklist.mjs b/infra/docs-gen/src/extractor/blacklist.mjs deleted file mode 100644 index 3864bd0e6..000000000 --- a/infra/docs-gen/src/extractor/blacklist.mjs +++ /dev/null @@ -1,13 +0,0 @@ -export const INTERNAL_EXTENSION_BLACKLIST = [ - 'BaseInputRules', - 'BaseKeymap', - 'BaseStyles', - 'ReactRenderer', - 'SharedState', -]; - -export const EXTENSION_BLACKLIST = ['YfmCut']; - -export function isBlacklistedExtension(name) { - return [...INTERNAL_EXTENSION_BLACKLIST, ...EXTENSION_BLACKLIST].includes(name); -} diff --git a/infra/docs-gen/src/extractor/config.mjs b/infra/docs-gen/src/extractor/config.mjs deleted file mode 100644 index f9d0f5745..000000000 --- a/infra/docs-gen/src/extractor/config.mjs +++ /dev/null @@ -1,30 +0,0 @@ -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_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); -export const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor'); -export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( - REPO_ROOT, - 'packages/page-constructor-extension/src/extension', -); - -export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; - -export const EXTENSION_ENTRY_POINTS = [ - { - id: 'editor', - packageName: '@gravity-ui/markdown-editor', - kind: 'category-dirs', - packageDir: EDITOR_PKG_DIR, - extensionsDir: 'src/extensions', - categories: EXTENSION_CATEGORIES, - }, - { - id: 'page-constructor-extension', - packageName: '@gravity-ui/markdown-editor-page-constructor-extension', - kind: 'single-extension', - extensionDir: PAGE_CONSTRUCTOR_EXTENSION_DIR, - extensionName: 'YfmPageConstructorExtension', - }, -]; diff --git a/infra/docs-gen/src/extractor/entry-points.mjs b/infra/docs-gen/src/extractor/entry-points.mjs deleted file mode 100644 index 0d2849dd5..000000000 --- a/infra/docs-gen/src/extractor/entry-points.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import {existsSync, readdirSync, statSync} from 'node:fs'; -import {join} from 'node:path'; - -import {isBlacklistedExtension} from './blacklist.mjs'; - -function startsWithUppercaseLetter(name) { - const firstChar = name.charAt(0); - - return ( - firstChar !== '' && - firstChar === firstChar.toUpperCase() && - firstChar !== firstChar.toLowerCase() - ); -} - -function listExtensionDirs(dir) { - if (!existsSync(dir)) return []; - - return readdirSync(dir) - .filter((name) => { - const fullPath = join(dir, name); - return statSync(fullPath).isDirectory() && startsWithUppercaseLetter(name); - }) - .map((name) => ({name, dir: join(dir, name)})) - .sort((left, right) => left.name.localeCompare(right.name)); -} - -function collectCategoryExtensionRefs(entryPoint) { - const extensionsRoot = join(entryPoint.packageDir, entryPoint.extensionsDir); - const refs = []; - - for (const category of entryPoint.categories) { - const categoryDir = join(extensionsRoot, category); - - for (const extensionDir of listExtensionDirs(categoryDir)) { - refs.push({ - name: extensionDir.name, - sourceDir: extensionDir.dir, - category, - entryPoint: entryPoint.id, - packageName: entryPoint.packageName, - }); - } - } - - return refs; -} - -function collectSingleExtensionRef(entryPoint) { - return [ - { - name: entryPoint.extensionName, - sourceDir: entryPoint.extensionDir, - category: 'external', - entryPoint: entryPoint.id, - packageName: entryPoint.packageName, - }, - ]; -} - -export function collectExtensionRefs(entryPoints) { - return entryPoints.flatMap((entryPoint) => { - if (entryPoint.kind === 'category-dirs') return collectCategoryExtensionRefs(entryPoint); - if (entryPoint.kind === 'single-extension') return collectSingleExtensionRef(entryPoint); - - return []; - }); -} - -export function filterExtensionRefs(extensionRefs) { - return extensionRefs.filter((extensionRef) => !isBlacklistedExtension(extensionRef.name)); -} diff --git a/infra/docs-gen/src/extractor/extractor.test.mjs b/infra/docs-gen/src/extractor/extractor.test.mjs deleted file mode 100644 index 8936f3f7c..000000000 --- a/infra/docs-gen/src/extractor/extractor.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict'; -import {test} from 'node:test'; - -import {extractExtensionNamesFromSource} from './ast/extension-name.mjs'; -import {EXTENSION_BLACKLIST, INTERNAL_EXTENSION_BLACKLIST} from './blacklist.mjs'; -import {filterExtensionRefs} from './entry-points.mjs'; -import {createExtensionRecord, EXTENSION_FIELD_CONFIG} from './field-config.mjs'; - -function createSourceFile(content) { - return {path: 'extension.ts', content}; -} - -test('extractExtensionNamesFromSource reads exported extension const names from TypeScript AST', () => { - const content = [ - "import type {ExtensionAuto} from '../../../core';", - 'const InternalExtension: ExtensionAuto = (builder) => {', - ' builder.addAction("internal", () => null);', - '};', - 'export const PublicExtension = Object.assign(InternalExtension, {});', - 'export const DirectExtension: ExtensionAuto = (builder) => {', - ' builder.addAction("direct", () => null);', - '};', - 'export const NotAnExtension = "value";', - ].join('\n'); - - assert.deepEqual(extractExtensionNamesFromSource(content), [ - 'PublicExtension', - 'DirectExtension', - ]); -}); - -test('filterExtensionRefs removes internal and public blacklist entries', () => { - assert.deepEqual( - filterExtensionRefs([ - {name: 'Bold'}, - {name: 'BaseKeymap'}, - {name: 'YfmCut'}, - {name: 'Italic'}, - ]).map((extensionRef) => extensionRef.name), - ['Bold', 'Italic'], - ); - assert.equal(INTERNAL_EXTENSION_BLACKLIST.includes('BaseKeymap'), true); - assert.equal(EXTENSION_BLACKLIST.includes('YfmCut'), true); -}); - -test('createExtensionRecord extracts only configured fields', () => { - const content = 'export const Bold: ExtensionAuto = (builder) => builder;'; - const record = createExtensionRecord({ - extensionRef: {name: 'Bold'}, - sourceFiles: [createSourceFile(content)], - }); - - assert.deepEqual(Object.keys(EXTENSION_FIELD_CONFIG), ['name']); - assert.deepEqual(record, {name: 'Bold'}); -}); diff --git a/infra/docs-gen/src/extractor/field-config.mjs b/infra/docs-gen/src/extractor/field-config.mjs deleted file mode 100644 index c5281ae42..000000000 --- a/infra/docs-gen/src/extractor/field-config.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import {extractNameField} from './fields/name.mjs'; - -export const EXTENSION_FIELD_CONFIG = { - name: { - from: 'extension source files: exported Extension/ExtensionAuto/ExtensionWithOptions const', - required: true, - extract: extractNameField, - }, -}; - -export function createExtensionRecord(context, fieldConfig = EXTENSION_FIELD_CONFIG) { - const record = {}; - - for (const [fieldName, config] of Object.entries(fieldConfig)) { - const value = config.extract(context); - if (config.required && value === null) return null; - - record[fieldName] = value; - } - - return record; -} diff --git a/infra/docs-gen/src/extractor/fields/name.mjs b/infra/docs-gen/src/extractor/fields/name.mjs deleted file mode 100644 index a30ad4910..000000000 --- a/infra/docs-gen/src/extractor/fields/name.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import {extractExtensionNamesFromSource} from '../ast/extension-name.mjs'; -import {unique} from '../ast/core.mjs'; - -export function extractNameField({extensionRef, sourceFiles}) { - const names = unique( - sourceFiles.flatMap((sourceFile) => - extractExtensionNamesFromSource(sourceFile.content, sourceFile.path), - ), - ); - - return names.includes(extensionRef.name) ? extensionRef.name : null; -} diff --git a/infra/docs-gen/src/extractor/index.mjs b/infra/docs-gen/src/extractor/index.mjs deleted file mode 100644 index 5d83ef87b..000000000 --- a/infra/docs-gen/src/extractor/index.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import {EXTENSION_ENTRY_POINTS} from './config.mjs'; -import {collectExtensionRefs, filterExtensionRefs} from './entry-points.mjs'; -import {createExtensionRecord, EXTENSION_FIELD_CONFIG} from './field-config.mjs'; -import {readSourceFiles} from './source-files.mjs'; - -function scanExtension(extensionRef, fieldConfig) { - return createExtensionRecord( - { - extensionRef, - sourceFiles: readSourceFiles(extensionRef.sourceDir), - }, - fieldConfig, - ); -} - -export function extractExtensionData({ - entryPoints = EXTENSION_ENTRY_POINTS, - fieldConfig = EXTENSION_FIELD_CONFIG, -} = {}) { - return filterExtensionRefs(collectExtensionRefs(entryPoints)) - .map((extensionRef) => scanExtension(extensionRef, fieldConfig)) - .filter(Boolean); -} diff --git a/infra/docs-gen/src/extractor/output.mjs b/infra/docs-gen/src/extractor/output.mjs deleted file mode 100644 index 8799f0dc5..000000000 --- a/infra/docs-gen/src/extractor/output.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import {mkdirSync, writeFileSync} from 'node:fs'; -import {join} from 'node:path'; - -export function writeExtensionsJson(outDir, extensions) { - mkdirSync(outDir, {recursive: true}); - writeFileSync(join(outDir, 'extensions.json'), `${JSON.stringify({extensions}, null, 2)}\n`); -} diff --git a/infra/docs-gen/src/extractor/source-files.mjs b/infra/docs-gen/src/extractor/source-files.mjs deleted file mode 100644 index 1a3381214..000000000 --- a/infra/docs-gen/src/extractor/source-files.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import {existsSync, readFileSync, readdirSync} from 'node:fs'; -import {join} from 'node:path'; - -export function readSourceFiles(dir) { - if (!existsSync(dir)) return []; - - const files = []; - for (const entry of readdirSync(dir, {withFileTypes: true})) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - files.push(...readSourceFiles(fullPath)); - } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) { - files.push({path: fullPath, content: readFileSync(fullPath, 'utf-8')}); - } - } - - return files.sort((left, right) => left.path.localeCompare(right.path)); -} diff --git a/infra/docs-gen/src/generate-docs.mjs b/infra/docs-gen/src/generate-docs.mjs index ffb42097a..b4b8935df 100644 --- a/infra/docs-gen/src/generate-docs.mjs +++ b/infra/docs-gen/src/generate-docs.mjs @@ -11,6 +11,8 @@ import {dirname, join, resolve} from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; +import {listExtensionNames} from './extract-extension-data.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'); @@ -126,6 +128,15 @@ function computeOutputPath(doc) { return slugify(doc.title) + '.md'; } +function collectExtensionDocs() { + return listExtensionNames().map((name) => ({ + sourceFile: `generated:extension:${name}`, + category: 'Extension Reference', + title: name, + content: `# ${name}\n`, + })); +} + /** * Ensures no two docs resolve to the same output path; exits on collision. * @param docs @@ -257,7 +268,7 @@ function writeYfmConfig() { function main() { cleanOutDir(); - const docs = collectDocs(); + const docs = [...collectDocs(), ...collectExtensionDocs()]; const {categories, topLevel} = groupByCategory(docs); writeYfmConfig(); From f1b082a0bc2a5116281b212abe651d6c05f163d4 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 23 Jun 2026 16:25:59 +0200 Subject: [PATCH 4/7] refactor(docs-gen): move extension extractor config --- infra/docs-gen/src/extension-ast.mjs | 3 +- infra/docs-gen/src/extension-config.mjs | 27 +++++++++++++ infra/docs-gen/src/extract-extension-data.mjs | 38 +++++++++---------- 3 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 infra/docs-gen/src/extension-config.mjs diff --git a/infra/docs-gen/src/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs index 26f61b10f..cf4aafc63 100644 --- a/infra/docs-gen/src/extension-ast.mjs +++ b/infra/docs-gen/src/extension-ast.mjs @@ -1,7 +1,6 @@ import ts from 'typescript'; -const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); -const EXTENSION_BUILDER_TYPE_NAMES = new Set(['ExtensionBuilder']); +import {EXTENSION_BUILDER_TYPE_NAMES, EXTENSION_TYPE_NAMES} from './extension-config.mjs'; function getTypeReferenceName(typeName) { if (ts.isIdentifier(typeName)) return typeName.text; diff --git a/infra/docs-gen/src/extension-config.mjs b/infra/docs-gen/src/extension-config.mjs new file mode 100644 index 000000000..9e6053d86 --- /dev/null +++ b/infra/docs-gen/src/extension-config.mjs @@ -0,0 +1,27 @@ +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_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); +export const EDITOR_EXTENSIONS_DIR = join(REPO_ROOT, 'packages/editor/src/extensions'); +export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( + REPO_ROOT, + 'packages/page-constructor-extension/src/extension', +); + +export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; +export const EXTRA_EXTENSION_REFS = [ + {name: 'YfmPageConstructorExtension', dir: PAGE_CONSTRUCTOR_EXTENSION_DIR}, +]; +export const EXTENSION_BLACKLIST = [ + 'BaseInputRules', + 'BaseKeymap', + 'BaseStyles', + 'ReactRenderer', + 'Resizable', + 'SharedState', + 'YfmCut', +]; + +export const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); +export const EXTENSION_BUILDER_TYPE_NAMES = new Set(['ExtensionBuilder']); diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index bf23e2f3e..c37d1f6f8 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -1,31 +1,27 @@ #!/usr/bin/env node import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; -import {dirname, join, resolve} from 'node:path'; +import {join} from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; import {sourceHasExtensionExport} from './extension-ast.mjs'; - -export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen'); -export const EDITOR_EXTENSIONS_DIR = join(REPO_ROOT, 'packages/editor/src/extensions'); -export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( +import { + DOCS_GEN_DIR, + EDITOR_EXTENSIONS_DIR, + EXTENSION_BLACKLIST, + EXTENSION_CATEGORIES, + EXTRA_EXTENSION_REFS, +} from './extension-config.mjs'; + +export { + DOCS_GEN_DIR, + EDITOR_EXTENSIONS_DIR, + EXTENSION_BLACKLIST, + EXTENSION_CATEGORIES, + EXTRA_EXTENSION_REFS, + PAGE_CONSTRUCTOR_EXTENSION_DIR, REPO_ROOT, - 'packages/page-constructor-extension/src/extension', -); -export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; -export const EXTRA_EXTENSION_REFS = [ - {name: 'YfmPageConstructorExtension', dir: PAGE_CONSTRUCTOR_EXTENSION_DIR}, -]; -export const EXTENSION_BLACKLIST = [ - 'BaseInputRules', - 'BaseKeymap', - 'BaseStyles', - 'ReactRenderer', - 'Resizable', - 'SharedState', - 'YfmCut', -]; +} from './extension-config.mjs'; function startsWithUppercaseLetter(name) { const firstChar = name.charAt(0); From 289cd22ab032714af50e2eae74dba33d88955512 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 23 Jun 2026 16:30:11 +0200 Subject: [PATCH 5/7] docs(docs-gen): document extractor functions --- infra/docs-gen/src/extension-ast.mjs | 9 +++++++++ infra/docs-gen/src/extract-extension-data.mjs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/infra/docs-gen/src/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs index cf4aafc63..6d7368822 100644 --- a/infra/docs-gen/src/extension-ast.mjs +++ b/infra/docs-gen/src/extension-ast.mjs @@ -1,7 +1,9 @@ +/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ import ts from 'typescript'; import {EXTENSION_BUILDER_TYPE_NAMES, EXTENSION_TYPE_NAMES} from './extension-config.mjs'; +/** Reads the visible name from a TypeScript type reference. */ function getTypeReferenceName(typeName) { if (ts.isIdentifier(typeName)) return typeName.text; if (ts.isQualifiedName(typeName)) return typeName.right.text; @@ -9,6 +11,7 @@ function getTypeReferenceName(typeName) { return null; } +/** Checks that a type annotation references one of the configured names. */ function isTypeReferenceTo(typeNode, names) { return ( typeNode && @@ -17,6 +20,7 @@ function isTypeReferenceTo(typeNode, names) { ); } +/** Removes syntax wrappers that do not change the checked expression. */ function unwrapExpression(expression) { let current = expression; @@ -33,10 +37,12 @@ function unwrapExpression(expression) { return current; } +/** Detects direct `export` modifiers on a top-level declaration statement. */ function hasExportModifier(node) { return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); } +/** Collects top-level variable declarations from a parsed source file. */ function readVariableDeclarations(sourceFile) { return sourceFile.statements.flatMap((statement) => { if (!ts.isVariableStatement(statement)) return []; @@ -47,6 +53,7 @@ function readVariableDeclarations(sourceFile) { }); } +/** Detects builder-style extension initializers. */ function isExtensionInitializer(initializer) { const current = initializer && unwrapExpression(initializer); @@ -57,6 +64,7 @@ function isExtensionInitializer(initializer) { ); } +/** Detects public exports built from a known extension implementation. */ function isObjectAssignFromKnownExtension(initializer, extensionImplementations) { const current = initializer && unwrapExpression(initializer); if ( @@ -80,6 +88,7 @@ function isObjectAssignFromKnownExtension(initializer, extensionImplementations) ); } +/** Checks whether a source file exports an extension-like declaration. */ export function sourceHasExtensionExport(content) { const sourceFile = ts.createSourceFile('source.tsx', content, ts.ScriptTarget.Latest, true); const declarations = readVariableDeclarations(sourceFile); diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index c37d1f6f8..802bc3bf9 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import process from 'node:process'; @@ -23,6 +24,7 @@ export { REPO_ROOT, } from './extension-config.mjs'; +/** Checks that a candidate directory starts with an uppercase letter. */ function startsWithUppercaseLetter(name) { const firstChar = name.charAt(0); @@ -33,6 +35,7 @@ function startsWithUppercaseLetter(name) { ); } +/** Lists uppercase extension candidate directories under one category. */ function listExtensionRefs(dir) { if (!existsSync(dir)) return []; @@ -42,6 +45,7 @@ function listExtensionRefs(dir) { .sort((left, right) => left.name.localeCompare(right.name)); } +/** Reads TypeScript sources from a candidate directory recursively. */ function readSourceFiles(dir) { if (!existsSync(dir)) return []; @@ -59,10 +63,12 @@ function readSourceFiles(dir) { return files; } +/** Checks whether any source in a candidate directory exports an extension. */ function refHasExtensionExport(ref) { return readSourceFiles(ref.dir).some(sourceHasExtensionExport); } +/** Builds the filtered list of public extension names. */ export function listExtensionNames({ extensionsDir = EDITOR_EXTENSIONS_DIR, categories = EXTENSION_CATEGORIES, @@ -77,10 +83,12 @@ export function listExtensionNames({ .map((ref) => ref.name); } +/** Converts extension names into JSON records. */ export function createExtensionRecords(names) { return names.map((name) => ({name})); } +/** Writes generated extension records into the docs-gen output directory. */ export function writeExtensionsJson(outDir = DOCS_GEN_DIR, names = listExtensionNames()) { mkdirSync(outDir, {recursive: true}); writeFileSync( @@ -89,6 +97,7 @@ export function writeExtensionsJson(outDir = DOCS_GEN_DIR, names = listExtension ); } +/** Runs the extension data extraction CLI. */ export function main() { writeExtensionsJson(); } From b609f8919af92ef6b00f75198655a5d991b1efe4 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 23 Jun 2026 17:48:11 +0200 Subject: [PATCH 6/7] refactor(docs-gen): simplify extension extractor flow --- infra/docs-gen/src/extension-ast.mjs | 13 ++++++++---- infra/docs-gen/src/extract-extension-data.mjs | 20 +++++++------------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/infra/docs-gen/src/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs index 6d7368822..48c4edbd8 100644 --- a/infra/docs-gen/src/extension-ast.mjs +++ b/infra/docs-gen/src/extension-ast.mjs @@ -53,8 +53,13 @@ function readVariableDeclarations(sourceFile) { }); } +/** Detects declarations typed as configured extensions. */ +function isExtensionDeclaration(declaration) { + return isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES); +} + /** Detects builder-style extension initializers. */ -function isExtensionInitializer(initializer) { +function isBuilderExtensionInitializer(initializer) { const current = initializer && unwrapExpression(initializer); return ( @@ -94,7 +99,7 @@ export function sourceHasExtensionExport(content) { const declarations = readVariableDeclarations(sourceFile); const extensionImplementations = new Set( declarations - .filter(({declaration}) => isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES)) + .filter(({declaration}) => isExtensionDeclaration(declaration)) .map(({declaration}) => declaration.name.text), ); @@ -102,8 +107,8 @@ export function sourceHasExtensionExport(content) { if (!hasExportModifier(statement)) return false; return ( - isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES) || - isExtensionInitializer(declaration.initializer) || + isExtensionDeclaration(declaration) || + isBuilderExtensionInitializer(declaration.initializer) || isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) ); }); diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index 802bc3bf9..40d0a149d 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -14,16 +14,6 @@ import { EXTRA_EXTENSION_REFS, } from './extension-config.mjs'; -export { - DOCS_GEN_DIR, - EDITOR_EXTENSIONS_DIR, - EXTENSION_BLACKLIST, - EXTENSION_CATEGORIES, - EXTRA_EXTENSION_REFS, - PAGE_CONSTRUCTOR_EXTENSION_DIR, - REPO_ROOT, -} from './extension-config.mjs'; - /** Checks that a candidate directory starts with an uppercase letter. */ function startsWithUppercaseLetter(name) { const firstChar = name.charAt(0); @@ -76,10 +66,14 @@ export function listExtensionNames({ blacklist = EXTENSION_BLACKLIST, } = {}) { const blacklistSet = new Set(blacklist); - const refs = categories.flatMap((category) => listExtensionRefs(join(extensionsDir, category))); + const categoryRefs = categories.flatMap((category) => + listExtensionRefs(join(extensionsDir, category)), + ); + const refs = categoryRefs.concat(extraExtensionRefs); - return [...refs, ...extraExtensionRefs] - .filter((ref) => !blacklistSet.has(ref.name) && refHasExtensionExport(ref)) + return refs + .filter((ref) => !blacklistSet.has(ref.name)) + .filter(refHasExtensionExport) .map((ref) => ref.name); } From d16d020ddfa148bf139c8ad28742b1751c5c495b Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 23 Jun 2026 19:32:58 +0200 Subject: [PATCH 7/7] refactor(docs-gen): filter extracted extension exports --- infra/docs-gen/src/extension-ast.mjs | 27 +++++----- infra/docs-gen/src/extension-config.mjs | 6 +-- infra/docs-gen/src/extract-extension-data.mjs | 49 +++++-------------- .../src/extract-extension-data.test.mjs | 19 +++++-- 4 files changed, 45 insertions(+), 56 deletions(-) diff --git a/infra/docs-gen/src/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs index 48c4edbd8..021df6f29 100644 --- a/infra/docs-gen/src/extension-ast.mjs +++ b/infra/docs-gen/src/extension-ast.mjs @@ -93,8 +93,19 @@ function isObjectAssignFromKnownExtension(initializer, extensionImplementations) ); } -/** Checks whether a source file exports an extension-like declaration. */ -export function sourceHasExtensionExport(content) { +/** Detects exported declarations that represent extensions. */ +function isExtensionExport({statement, declaration}, extensionImplementations) { + if (!hasExportModifier(statement)) return false; + + return ( + isExtensionDeclaration(declaration) || + isBuilderExtensionInitializer(declaration.initializer) || + isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) + ); +} + +/** Reads extension export names from a source file. */ +export function readExtensionExportNames(content) { const sourceFile = ts.createSourceFile('source.tsx', content, ts.ScriptTarget.Latest, true); const declarations = readVariableDeclarations(sourceFile); const extensionImplementations = new Set( @@ -103,13 +114,7 @@ export function sourceHasExtensionExport(content) { .map(({declaration}) => declaration.name.text), ); - return declarations.some(({statement, declaration}) => { - if (!hasExportModifier(statement)) return false; - - return ( - isExtensionDeclaration(declaration) || - isBuilderExtensionInitializer(declaration.initializer) || - isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations) - ); - }); + return declarations + .filter((entry) => isExtensionExport(entry, extensionImplementations)) + .map(({declaration}) => declaration.name.text); } diff --git a/infra/docs-gen/src/extension-config.mjs b/infra/docs-gen/src/extension-config.mjs index 9e6053d86..0c860c347 100644 --- a/infra/docs-gen/src/extension-config.mjs +++ b/infra/docs-gen/src/extension-config.mjs @@ -10,14 +10,12 @@ export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join( ); export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; -export const EXTRA_EXTENSION_REFS = [ - {name: 'YfmPageConstructorExtension', dir: PAGE_CONSTRUCTOR_EXTENSION_DIR}, -]; +export const EXTRA_EXTENSION_DIRS = [PAGE_CONSTRUCTOR_EXTENSION_DIR]; export const EXTENSION_BLACKLIST = [ 'BaseInputRules', 'BaseKeymap', 'BaseStyles', - 'ReactRenderer', + 'ReactRendererExtension', 'Resizable', 'SharedState', 'YfmCut', diff --git a/infra/docs-gen/src/extract-extension-data.mjs b/infra/docs-gen/src/extract-extension-data.mjs index 40d0a149d..49988f98b 100644 --- a/infra/docs-gen/src/extract-extension-data.mjs +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -5,37 +5,16 @@ import {join} from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; -import {sourceHasExtensionExport} from './extension-ast.mjs'; +import {readExtensionExportNames} from './extension-ast.mjs'; import { DOCS_GEN_DIR, EDITOR_EXTENSIONS_DIR, EXTENSION_BLACKLIST, EXTENSION_CATEGORIES, - EXTRA_EXTENSION_REFS, + EXTRA_EXTENSION_DIRS, } from './extension-config.mjs'; -/** Checks that a candidate directory starts with an uppercase letter. */ -function startsWithUppercaseLetter(name) { - const firstChar = name.charAt(0); - - return ( - firstChar !== '' && - firstChar === firstChar.toUpperCase() && - firstChar !== firstChar.toLowerCase() - ); -} - -/** Lists uppercase extension candidate directories under one category. */ -function listExtensionRefs(dir) { - if (!existsSync(dir)) return []; - - return readdirSync(dir, {withFileTypes: true}) - .filter((entry) => entry.isDirectory() && startsWithUppercaseLetter(entry.name)) - .map((entry) => ({name: entry.name, dir: join(dir, entry.name)})) - .sort((left, right) => left.name.localeCompare(right.name)); -} - -/** Reads TypeScript sources from a candidate directory recursively. */ +/** Reads TypeScript sources from a directory recursively. */ function readSourceFiles(dir) { if (!existsSync(dir)) return []; @@ -53,28 +32,26 @@ function readSourceFiles(dir) { return files; } -/** Checks whether any source in a candidate directory exports an extension. */ -function refHasExtensionExport(ref) { - return readSourceFiles(ref.dir).some(sourceHasExtensionExport); +/** Builds configured source roots for extension scanning. */ +function listExtensionSourceDirs(extensionsDir, categories, extraExtensionDirs) { + return categories.map((category) => join(extensionsDir, category)).concat(extraExtensionDirs); } /** Builds the filtered list of public extension names. */ export function listExtensionNames({ extensionsDir = EDITOR_EXTENSIONS_DIR, categories = EXTENSION_CATEGORIES, - extraExtensionRefs = EXTRA_EXTENSION_REFS, + extraExtensionDirs = EXTRA_EXTENSION_DIRS, blacklist = EXTENSION_BLACKLIST, } = {}) { const blacklistSet = new Set(blacklist); - const categoryRefs = categories.flatMap((category) => - listExtensionRefs(join(extensionsDir, category)), - ); - const refs = categoryRefs.concat(extraExtensionRefs); + const names = listExtensionSourceDirs(extensionsDir, categories, extraExtensionDirs) + .flatMap(readSourceFiles) + .flatMap(readExtensionExportNames); - return refs - .filter((ref) => !blacklistSet.has(ref.name)) - .filter(refHasExtensionExport) - .map((ref) => ref.name); + return [...new Set(names)] + .filter((name) => !blacklistSet.has(name)) + .sort((left, right) => left.localeCompare(right)); } /** Converts extension names into JSON records. */ diff --git a/infra/docs-gen/src/extract-extension-data.test.mjs b/infra/docs-gen/src/extract-extension-data.test.mjs index 861ac22ad..d8abe05d5 100644 --- a/infra/docs-gen/src/extract-extension-data.test.mjs +++ b/infra/docs-gen/src/extract-extension-data.test.mjs @@ -32,17 +32,26 @@ afterEach(() => { } }); -test('listExtensionNames keeps AST-backed extension dirs and applies blacklist', () => { +test('listExtensionNames extracts AST-backed extension names and applies blacklist to exports', () => { const extensionsDir = makeExtensionsRoot(); const extraDir = makeExtensionsRoot(); addExtensionFile( extensionsDir, 'base', - 'BaseKeymap', + 'base-keymap', 'export const BaseKeymap: ExtensionAuto = () => {};', ); - addExtensionFile(extensionsDir, 'base', 'Bold', 'export const Bold: ExtensionAuto = () => {};'); + addExtensionFile(extensionsDir, 'base', 'bold', 'export const Bold: ExtensionAuto = () => {};'); + addExtensionFile( + extensionsDir, + 'base', + 'mixed', + [ + 'export const One: ExtensionAuto = () => {};', + 'export const Two: ExtensionAuto = () => {};', + ].join('\n'), + ); addExtensionFile( extensionsDir, 'behavior', @@ -65,9 +74,9 @@ test('listExtensionNames keeps AST-backed extension dirs and applies blacklist', listExtensionNames({ extensionsDir, categories: ['base', 'behavior', 'additional'], - extraExtensionRefs: [{name: 'YfmPageConstructorExtension', dir: extraDir}], + extraExtensionDirs: [extraDir], }), - ['Bold', 'GPT', 'YfmPageConstructorExtension'], + ['Bold', 'One', 'Two', 'YfmPageConstructorExtension', 'gptExtension'], ); });