Skip to content

Commit abe5aee

Browse files
committed
build(docs-gen): add simple extension list extractor
1 parent b7881f5 commit abe5aee

17 files changed

Lines changed: 289 additions & 400 deletions

infra/docs-gen/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"scripts": {
1717
"build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs",
1818
"extract": "node src/extract-extension-data.mjs",
19-
"test": "node --test src/extractor/*.test.mjs"
19+
"test": "node --test src/*.test.mjs"
2020
},
2121
"dependencies": {
2222
"@diplodoc/cli": "5.43.0",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import ts from 'typescript';
2+
3+
const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']);
4+
const EXTENSION_BUILDER_TYPE_NAMES = new Set(['ExtensionBuilder']);
5+
6+
function getTypeReferenceName(typeName) {
7+
if (ts.isIdentifier(typeName)) return typeName.text;
8+
if (ts.isQualifiedName(typeName)) return typeName.right.text;
9+
10+
return null;
11+
}
12+
13+
function isTypeReferenceTo(typeNode, names) {
14+
return (
15+
typeNode &&
16+
ts.isTypeReferenceNode(typeNode) &&
17+
names.has(getTypeReferenceName(typeNode.typeName))
18+
);
19+
}
20+
21+
function unwrapExpression(expression) {
22+
let current = expression;
23+
24+
while (
25+
ts.isParenthesizedExpression(current) ||
26+
ts.isAsExpression(current) ||
27+
ts.isSatisfiesExpression(current) ||
28+
ts.isNonNullExpression(current) ||
29+
ts.isTypeAssertionExpression(current)
30+
) {
31+
current = current.expression;
32+
}
33+
34+
return current;
35+
}
36+
37+
function hasExportModifier(node) {
38+
return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
39+
}
40+
41+
function readVariableDeclarations(sourceFile) {
42+
return sourceFile.statements.flatMap((statement) => {
43+
if (!ts.isVariableStatement(statement)) return [];
44+
45+
return statement.declarationList.declarations
46+
.filter((declaration) => ts.isIdentifier(declaration.name))
47+
.map((declaration) => ({statement, declaration}));
48+
});
49+
}
50+
51+
function isExtensionInitializer(initializer) {
52+
const current = initializer && unwrapExpression(initializer);
53+
54+
return (
55+
current &&
56+
ts.isArrowFunction(current) &&
57+
isTypeReferenceTo(current.parameters[0]?.type, EXTENSION_BUILDER_TYPE_NAMES)
58+
);
59+
}
60+
61+
function isObjectAssignFromKnownExtension(initializer, extensionImplementations) {
62+
const current = initializer && unwrapExpression(initializer);
63+
if (
64+
!current ||
65+
!ts.isCallExpression(current) ||
66+
!ts.isPropertyAccessExpression(current.expression)
67+
) {
68+
return false;
69+
}
70+
71+
const callee = current.expression;
72+
const firstArg = current.arguments[0];
73+
74+
return (
75+
ts.isIdentifier(callee.expression) &&
76+
callee.expression.text === 'Object' &&
77+
callee.name.text === 'assign' &&
78+
firstArg &&
79+
ts.isIdentifier(firstArg) &&
80+
extensionImplementations.has(firstArg.text)
81+
);
82+
}
83+
84+
export function sourceHasExtensionExport(content) {
85+
const sourceFile = ts.createSourceFile('source.tsx', content, ts.ScriptTarget.Latest, true);
86+
const declarations = readVariableDeclarations(sourceFile);
87+
const extensionImplementations = new Set(
88+
declarations
89+
.filter(({declaration}) => isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES))
90+
.map(({declaration}) => declaration.name.text),
91+
);
92+
93+
return declarations.some(({statement, declaration}) => {
94+
if (!hasExportModifier(statement)) return false;
95+
96+
return (
97+
isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES) ||
98+
isExtensionInitializer(declaration.initializer) ||
99+
isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations)
100+
);
101+
});
102+
}

infra/docs-gen/src/extract-extension-data.mjs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,100 @@
11
#!/usr/bin/env node
2+
import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs';
3+
import {dirname, join, resolve} from 'node:path';
4+
import process from 'node:process';
25
import {fileURLToPath} from 'node:url';
36

4-
import {DOCS_GEN_DIR} from './extractor/config.mjs';
5-
import {extractExtensionData} from './extractor/index.mjs';
6-
import {writeExtensionsJson} from './extractor/output.mjs';
7+
import {sourceHasExtensionExport} from './extension-ast.mjs';
8+
9+
export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
10+
export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen');
11+
export const EDITOR_EXTENSIONS_DIR = join(REPO_ROOT, 'packages/editor/src/extensions');
12+
export const PAGE_CONSTRUCTOR_EXTENSION_DIR = join(
13+
REPO_ROOT,
14+
'packages/page-constructor-extension/src/extension',
15+
);
16+
export const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional'];
17+
export const EXTRA_EXTENSION_REFS = [
18+
{name: 'YfmPageConstructorExtension', dir: PAGE_CONSTRUCTOR_EXTENSION_DIR},
19+
];
20+
export const EXTENSION_BLACKLIST = [
21+
'BaseInputRules',
22+
'BaseKeymap',
23+
'BaseStyles',
24+
'ReactRenderer',
25+
'Resizable',
26+
'SharedState',
27+
'YfmCut',
28+
];
29+
30+
function startsWithUppercaseLetter(name) {
31+
const firstChar = name.charAt(0);
32+
33+
return (
34+
firstChar !== '' &&
35+
firstChar === firstChar.toUpperCase() &&
36+
firstChar !== firstChar.toLowerCase()
37+
);
38+
}
39+
40+
function listExtensionRefs(dir) {
41+
if (!existsSync(dir)) return [];
42+
43+
return readdirSync(dir, {withFileTypes: true})
44+
.filter((entry) => entry.isDirectory() && startsWithUppercaseLetter(entry.name))
45+
.map((entry) => ({name: entry.name, dir: join(dir, entry.name)}))
46+
.sort((left, right) => left.name.localeCompare(right.name));
47+
}
48+
49+
function readSourceFiles(dir) {
50+
if (!existsSync(dir)) return [];
51+
52+
const files = [];
53+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
54+
const fullPath = join(dir, entry.name);
55+
56+
if (entry.isDirectory()) {
57+
files.push(...readSourceFiles(fullPath));
58+
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
59+
files.push(readFileSync(fullPath, 'utf-8'));
60+
}
61+
}
62+
63+
return files;
64+
}
65+
66+
function refHasExtensionExport(ref) {
67+
return readSourceFiles(ref.dir).some(sourceHasExtensionExport);
68+
}
69+
70+
export function listExtensionNames({
71+
extensionsDir = EDITOR_EXTENSIONS_DIR,
72+
categories = EXTENSION_CATEGORIES,
73+
extraExtensionRefs = EXTRA_EXTENSION_REFS,
74+
blacklist = EXTENSION_BLACKLIST,
75+
} = {}) {
76+
const blacklistSet = new Set(blacklist);
77+
const refs = categories.flatMap((category) => listExtensionRefs(join(extensionsDir, category)));
78+
79+
return [...refs, ...extraExtensionRefs]
80+
.filter((ref) => !blacklistSet.has(ref.name) && refHasExtensionExport(ref))
81+
.map((ref) => ref.name);
82+
}
83+
84+
export function createExtensionRecords(names) {
85+
return names.map((name) => ({name}));
86+
}
87+
88+
export function writeExtensionsJson(outDir = DOCS_GEN_DIR, names = listExtensionNames()) {
89+
mkdirSync(outDir, {recursive: true});
90+
writeFileSync(
91+
join(outDir, 'extensions.json'),
92+
`${JSON.stringify({extensions: createExtensionRecords(names)}, null, 2)}\n`,
93+
);
94+
}
795

896
export function main() {
9-
writeExtensionsJson(DOCS_GEN_DIR, extractExtensionData());
97+
writeExtensionsJson();
1098
}
1199

12100
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import assert from 'node:assert/strict';
2+
import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
3+
import {tmpdir} from 'node:os';
4+
import {join} from 'node:path';
5+
import {afterEach, test} from 'node:test';
6+
7+
import {
8+
createExtensionRecords,
9+
listExtensionNames,
10+
writeExtensionsJson,
11+
} from './extract-extension-data.mjs';
12+
13+
const cleanupDirs = [];
14+
15+
function makeExtensionsRoot() {
16+
const root = mkdtempSync(join(tmpdir(), 'docs-gen-extensions-'));
17+
cleanupDirs.push(root);
18+
19+
return root;
20+
}
21+
22+
function addExtensionFile(root, category, name, content) {
23+
const dir = join(root, category, name);
24+
25+
mkdirSync(dir, {recursive: true});
26+
writeFileSync(join(dir, 'index.ts'), content);
27+
}
28+
29+
afterEach(() => {
30+
for (const dir of cleanupDirs.splice(0)) {
31+
rmSync(dir, {recursive: true, force: true});
32+
}
33+
});
34+
35+
test('listExtensionNames keeps AST-backed extension dirs and applies blacklist', () => {
36+
const extensionsDir = makeExtensionsRoot();
37+
const extraDir = makeExtensionsRoot();
38+
39+
addExtensionFile(
40+
extensionsDir,
41+
'base',
42+
'BaseKeymap',
43+
'export const BaseKeymap: ExtensionAuto = () => {};',
44+
);
45+
addExtensionFile(extensionsDir, 'base', 'Bold', 'export const Bold: ExtensionAuto = () => {};');
46+
addExtensionFile(
47+
extensionsDir,
48+
'behavior',
49+
'Resizable',
50+
'export const Resizable: React.FC = () => null;',
51+
);
52+
addExtensionFile(
53+
extensionsDir,
54+
'additional',
55+
'GPT',
56+
'export const gptExtension = (builder: ExtensionBuilder) => builder;',
57+
);
58+
addExtensionFile(extensionsDir, 'additional', 'Widget', 'export const Widget = () => null;');
59+
writeFileSync(
60+
join(extraDir, 'index.ts'),
61+
'export const YfmPageConstructorExtension: ExtensionAuto = () => {};',
62+
);
63+
64+
assert.deepEqual(
65+
listExtensionNames({
66+
extensionsDir,
67+
categories: ['base', 'behavior', 'additional'],
68+
extraExtensionRefs: [{name: 'YfmPageConstructorExtension', dir: extraDir}],
69+
}),
70+
['Bold', 'GPT', 'YfmPageConstructorExtension'],
71+
);
72+
});
73+
74+
test('writeExtensionsJson writes extension name records', () => {
75+
const outDir = makeExtensionsRoot();
76+
77+
writeExtensionsJson(outDir, ['Bold', 'GPT']);
78+
79+
assert.deepEqual(JSON.parse(readFileSync(join(outDir, 'extensions.json'), 'utf-8')), {
80+
extensions: createExtensionRecords(['Bold', 'GPT']),
81+
});
82+
});

infra/docs-gen/src/extractor/README.md

Lines changed: 0 additions & 18 deletions
This file was deleted.

infra/docs-gen/src/extractor/ast/core.mjs

Lines changed: 0 additions & 38 deletions
This file was deleted.

0 commit comments

Comments
 (0)