diff --git a/infra/docs-gen/package.json b/infra/docs-gen/package.json index de6aa64c6..e56195386 100644 --- a/infra/docs-gen/package.json +++ b/infra/docs-gen/package.json @@ -14,9 +14,11 @@ } }, "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", + "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/extension-ast.mjs b/infra/docs-gen/src/extension-ast.mjs new file mode 100644 index 000000000..1bcf0ad78 --- /dev/null +++ b/infra/docs-gen/src/extension-ast.mjs @@ -0,0 +1,47 @@ +/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ +import ts from 'typescript'; + +import {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; + + return null; +} + +/** Checks that a type annotation references one of the configured names. */ +function isTypeReferenceTo(typeNode, names) { + return ( + typeNode && + ts.isTypeReferenceNode(typeNode) && + names.has(getTypeReferenceName(typeNode.typeName)) + ); +} + +/** Detects direct `export` modifiers on a top-level declaration statement. */ +function hasExportModifier(node) { + return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +/** Reads extension export names from a TypeScript source file. */ +export function extractExtensionNamesFromSource(content, fileName = 'source.ts') { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const names = []; + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue; + + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES) + ) { + names.push(declaration.name.text); + } + } + } + + return names; +} diff --git a/infra/docs-gen/src/extension-config.mjs b/infra/docs-gen/src/extension-config.mjs new file mode 100644 index 000000000..29f4e1aff --- /dev/null +++ b/infra/docs-gen/src/extension-config.mjs @@ -0,0 +1,95 @@ +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export const EXTENSION_WHITELIST = [ + {name: 'BaseInputRules', entry: 'packages/editor/src/extensions/base/BaseInputRules/index.ts'}, + {name: 'BaseKeymap', entry: 'packages/editor/src/extensions/base/BaseKeymap/index.ts'}, + {name: 'BaseSchema', entry: 'packages/editor/src/extensions/base/BaseSchema/index.ts'}, + {name: 'BaseStyles', entry: 'packages/editor/src/extensions/base/BaseStyles/index.ts'}, + {name: 'Autocomplete', entry: 'packages/editor/src/extensions/behavior/Autocomplete/index.ts'}, + { + name: 'ClicksOnEdges', + entry: 'packages/editor/src/extensions/behavior/ClicksOnEdges/ClicksOnEdges.ts', + }, + {name: 'Clipboard', entry: 'packages/editor/src/extensions/behavior/Clipboard/index.ts'}, + {name: 'CommandMenu', entry: 'packages/editor/src/extensions/behavior/CommandMenu/index.ts'}, + {name: 'Cursor', entry: 'packages/editor/src/extensions/behavior/Cursor/index.ts'}, + { + name: 'EditorModeKeymap', + entry: 'packages/editor/src/extensions/behavior/EditorModeKeymap/index.ts', + }, + {name: 'FilePaste', entry: 'packages/editor/src/extensions/behavior/FilePaste/index.ts'}, + {name: 'History', entry: 'packages/editor/src/extensions/behavior/History/index.ts'}, + {name: 'Placeholder', entry: 'packages/editor/src/extensions/behavior/Placeholder/index.ts'}, + { + name: 'ReactRendererExtension', + entry: 'packages/editor/src/extensions/behavior/ReactRenderer/index.ts', + }, + {name: 'Search', entry: 'packages/editor/src/extensions/behavior/Search/Search.ts'}, + {name: 'Selection', entry: 'packages/editor/src/extensions/behavior/Selection/index.ts'}, + { + name: 'SelectionContext', + entry: 'packages/editor/src/extensions/behavior/SelectionContext/index.ts', + }, + { + name: 'SharedState', + entry: 'packages/editor/src/extensions/behavior/SharedState/SharedState.ts', + }, + { + name: 'WidgetDecoration', + entry: 'packages/editor/src/extensions/behavior/WidgetDecoration/index.ts', + }, + {name: 'Bold', entry: 'packages/editor/src/extensions/markdown/Bold/index.ts'}, + {name: 'Blockquote', entry: 'packages/editor/src/extensions/markdown/Blockquote/index.ts'}, + {name: 'Breaks', entry: 'packages/editor/src/extensions/markdown/Breaks/index.ts'}, + {name: 'Code', entry: 'packages/editor/src/extensions/markdown/Code/index.ts'}, + {name: 'CodeBlock', entry: 'packages/editor/src/extensions/markdown/CodeBlock/index.ts'}, + {name: 'Deflist', entry: 'packages/editor/src/extensions/markdown/Deflist/index.ts'}, + {name: 'Heading', entry: 'packages/editor/src/extensions/markdown/Heading/index.ts'}, + { + name: 'HorizontalRule', + entry: 'packages/editor/src/extensions/markdown/HorizontalRule/index.ts', + }, + {name: 'Html', entry: 'packages/editor/src/extensions/markdown/Html/index.ts'}, + {name: 'Image', entry: 'packages/editor/src/extensions/markdown/Image/index.ts'}, + {name: 'Italic', entry: 'packages/editor/src/extensions/markdown/Italic/index.ts'}, + {name: 'Link', entry: 'packages/editor/src/extensions/markdown/Link/index.ts'}, + {name: 'Lists', entry: 'packages/editor/src/extensions/markdown/Lists/index.ts'}, + {name: 'Mark', entry: 'packages/editor/src/extensions/markdown/Mark/index.ts'}, + {name: 'Strike', entry: 'packages/editor/src/extensions/markdown/Strike/index.ts'}, + {name: 'Subscript', entry: 'packages/editor/src/extensions/markdown/Subscript/index.ts'}, + {name: 'Superscript', entry: 'packages/editor/src/extensions/markdown/Superscript/index.ts'}, + {name: 'Table', entry: 'packages/editor/src/extensions/markdown/Table/index.ts'}, + {name: 'Underline', entry: 'packages/editor/src/extensions/markdown/Underline/index.ts'}, + {name: 'Checkbox', entry: 'packages/editor/src/extensions/yfm/Checkbox/index.ts'}, + {name: 'Color', entry: 'packages/editor/src/extensions/yfm/Color/index.ts'}, + {name: 'ImgSize', entry: 'packages/editor/src/extensions/yfm/ImgSize/index.ts'}, + {name: 'Monospace', entry: 'packages/editor/src/extensions/yfm/Monospace/index.ts'}, + {name: 'Video', entry: 'packages/editor/src/extensions/yfm/Video/index.ts'}, + {name: 'YfmConfigs', entry: 'packages/editor/src/extensions/yfm/YfmConfigs/index.ts'}, + {name: 'YfmCut', entry: 'packages/editor/src/extensions/yfm/YfmCut/index.ts'}, + {name: 'YfmFile', entry: 'packages/editor/src/extensions/yfm/YfmFile/index.ts'}, + {name: 'YfmHeading', entry: 'packages/editor/src/extensions/yfm/YfmHeading/index.ts'}, + {name: 'YfmNote', entry: 'packages/editor/src/extensions/yfm/YfmNote/index.ts'}, + {name: 'YfmTable', entry: 'packages/editor/src/extensions/yfm/YfmTable/index.ts'}, + {name: 'YfmTabs', entry: 'packages/editor/src/extensions/yfm/YfmTabs/index.ts'}, + { + name: 'FoldingHeading', + entry: 'packages/editor/src/extensions/additional/FoldingHeading/FoldingHeading.ts', + }, + {name: 'Math', entry: 'packages/editor/src/extensions/additional/Math/index.ts'}, + {name: 'Mermaid', entry: 'packages/editor/src/extensions/additional/Mermaid/index.ts'}, + {name: 'QuoteLink', entry: 'packages/editor/src/extensions/additional/QuoteLink/index.ts'}, + { + name: 'YfmHtmlBlock', + entry: 'packages/editor/src/extensions/additional/YfmHtmlBlock/index.ts', + }, + { + name: 'YfmPageConstructorExtension', + entry: 'packages/page-constructor-extension/src/extension/index.ts', + }, +]; + +export const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']); 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..9de968b2e --- /dev/null +++ b/infra/docs-gen/src/extract-extension-data.mjs @@ -0,0 +1,25 @@ +/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +import {extractExtensionNamesFromSource} from './extension-ast.mjs'; +import {EXTENSION_WHITELIST, REPO_ROOT} from './extension-config.mjs'; + +function readWhitelistedExtensionName(repoRoot, {name, entry}) { + const filePath = join(repoRoot, entry); + const names = extractExtensionNamesFromSource(readFileSync(filePath, 'utf-8'), filePath); + + if (!names.includes(name)) { + throw new Error(`Expected "${entry}" to export extension "${name}"`); + } + + return name; +} + +/** Reads extension names from the explicit documentation whitelist. */ +export function extractExtensionNames({ + repoRoot = REPO_ROOT, + whitelist = EXTENSION_WHITELIST, +} = {}) { + return whitelist.map((extension) => readWhitelistedExtensionName(repoRoot, extension)); +} 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..f42d15e55 --- /dev/null +++ b/infra/docs-gen/src/extract-extension-data.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {afterEach, test} from 'node:test'; + +import {extractExtensionNamesFromSource} from './extension-ast.mjs'; +import {extractExtensionNames} from './extract-extension-data.mjs'; + +const cleanupDirs = []; + +function makeRepoRoot() { + const root = mkdtempSync(join(tmpdir(), 'docs-gen-repo-')); + cleanupDirs.push(root); + + return root; +} + +function addFile(root, filePath, content) { + const fullPath = join(root, filePath); + + mkdirSync(dirname(fullPath), {recursive: true}); + writeFileSync(fullPath, content); +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, {recursive: true, force: true}); + } +}); + +test('extractExtensionNamesFromSource reads exported extension names from AST', () => { + assert.deepEqual( + extractExtensionNamesFromSource( + [ + 'export const Bold: ExtensionAuto = () => {};', + 'export const BoldSpecs = () => {};', + 'export type BoldOptions = {};', + "export {boldMarkName} from './BoldSpecs';", + ].join('\n'), + ), + ['Bold'], + ); +}); + +test('extractExtensionNames reads only whitelisted extension entry points', () => { + const repoRoot = makeRepoRoot(); + + addFile( + repoRoot, + 'packages/editor/src/extensions/markdown/Bold/index.ts', + [ + 'export const Bold: ExtensionAuto = () => {};', + 'export const BoldSpecs: ExtensionAuto = () => {};', + ].join('\n'), + ); + addFile( + repoRoot, + 'packages/editor/src/extensions/markdown/Heading/index.ts', + 'export const Heading: ExtensionWithOptions = () => {};', + ); + addFile( + repoRoot, + 'packages/editor/src/extensions/markdown/Italic/index.ts', + 'export const Italic: ExtensionAuto = () => {};', + ); + + assert.deepEqual( + extractExtensionNames({ + repoRoot, + whitelist: [ + {name: 'Bold', entry: 'packages/editor/src/extensions/markdown/Bold/index.ts'}, + { + name: 'Heading', + entry: 'packages/editor/src/extensions/markdown/Heading/index.ts', + }, + ], + }), + ['Bold', 'Heading'], + ); +}); + +test('extractExtensionNames fails when a whitelisted entry does not export the expected name', () => { + const repoRoot = makeRepoRoot(); + + addFile( + repoRoot, + 'packages/editor/src/extensions/markdown/Bold/index.ts', + 'export const NotBold: ExtensionAuto = () => {};', + ); + + assert.throws( + () => + extractExtensionNames({ + repoRoot, + whitelist: [ + {name: 'Bold', entry: 'packages/editor/src/extensions/markdown/Bold/index.ts'}, + ], + }), + /Expected "packages\/editor\/src\/extensions\/markdown\/Bold\/index.ts" to export extension "Bold"/, + ); +}); 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: