Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions infra/docs-gen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
}
},
"scripts": {
"build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs"
"build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs",
"extract": "node src/extract-extension-data.mjs",
"test": "node --test src/extractor/*.test.mjs"
},
"dependencies": {
"@diplodoc/cli": "5.43.0"
"@diplodoc/cli": "5.43.0",
"typescript": "catalog:ts"
}
}
14 changes: 14 additions & 0 deletions infra/docs-gen/src/extract-extension-data.mjs
Original file line number Diff line number Diff line change
@@ -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]) {

Check failure on line 12 in infra/docs-gen/src/extract-extension-data.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

'process' is not defined

Check failure on line 12 in infra/docs-gen/src/extract-extension-data.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

'process' is not defined
main();
}
18 changes: 18 additions & 0 deletions infra/docs-gen/src/extractor/README.md
Original file line number Diff line number Diff line change
@@ -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`.
38 changes: 38 additions & 0 deletions infra/docs-gen/src/extractor/ast/core.mjs
Original file line number Diff line number Diff line change
@@ -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))];
}
85 changes: 85 additions & 0 deletions infra/docs-gen/src/extractor/ast/extension-name.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
13 changes: 13 additions & 0 deletions infra/docs-gen/src/extractor/blacklist.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
30 changes: 30 additions & 0 deletions infra/docs-gen/src/extractor/config.mjs
Original file line number Diff line number Diff line change
@@ -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',
},
];
72 changes: 72 additions & 0 deletions infra/docs-gen/src/extractor/entry-points.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
55 changes: 55 additions & 0 deletions infra/docs-gen/src/extractor/extractor.test.mjs
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 7 in infra/docs-gen/src/extractor/extractor.test.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

Member 'EXTENSION_FIELD_CONFIG' of the import declaration should be sorted alphabetically

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'});
});
22 changes: 22 additions & 0 deletions infra/docs-gen/src/extractor/field-config.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions infra/docs-gen/src/extractor/fields/name.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {extractExtensionNamesFromSource} from '../ast/extension-name.mjs';
import {unique} from '../ast/core.mjs';

Check failure on line 2 in infra/docs-gen/src/extractor/fields/name.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

`../ast/core.mjs` import should occur before import of `../ast/extension-name.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;
}
Loading
Loading