Skip to content

Commit 6de3079

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

16 files changed

Lines changed: 288 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",

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

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,201 @@
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 ts from 'typescript';
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+
const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']);
31+
const EXTENSION_BUILDER_TYPE_NAMES = new Set(['ExtensionBuilder']);
32+
33+
function startsWithUppercaseLetter(name) {
34+
const firstChar = name.charAt(0);
35+
36+
return (
37+
firstChar !== '' &&
38+
firstChar === firstChar.toUpperCase() &&
39+
firstChar !== firstChar.toLowerCase()
40+
);
41+
}
42+
43+
function listExtensionRefs(dir) {
44+
if (!existsSync(dir)) return [];
45+
46+
return readdirSync(dir, {withFileTypes: true})
47+
.filter((entry) => entry.isDirectory() && startsWithUppercaseLetter(entry.name))
48+
.map((entry) => ({name: entry.name, dir: join(dir, entry.name)}))
49+
.sort((left, right) => left.name.localeCompare(right.name));
50+
}
51+
52+
function readSourceFiles(dir) {
53+
if (!existsSync(dir)) return [];
54+
55+
const files = [];
56+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
57+
const fullPath = join(dir, entry.name);
58+
59+
if (entry.isDirectory()) {
60+
files.push(...readSourceFiles(fullPath));
61+
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
62+
files.push(readFileSync(fullPath, 'utf-8'));
63+
}
64+
}
65+
66+
return files;
67+
}
68+
69+
function getTypeReferenceName(typeName) {
70+
if (ts.isIdentifier(typeName)) return typeName.text;
71+
if (ts.isQualifiedName(typeName)) return typeName.right.text;
72+
73+
return null;
74+
}
75+
76+
function isTypeReferenceTo(typeNode, names) {
77+
return (
78+
typeNode &&
79+
ts.isTypeReferenceNode(typeNode) &&
80+
names.has(getTypeReferenceName(typeNode.typeName))
81+
);
82+
}
83+
84+
function unwrapExpression(expression) {
85+
let current = expression;
86+
87+
while (
88+
ts.isParenthesizedExpression(current) ||
89+
ts.isAsExpression(current) ||
90+
ts.isSatisfiesExpression(current) ||
91+
ts.isNonNullExpression(current) ||
92+
ts.isTypeAssertionExpression(current)
93+
) {
94+
current = current.expression;
95+
}
96+
97+
return current;
98+
}
99+
100+
function hasExportModifier(node) {
101+
return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
102+
}
103+
104+
function readVariableDeclarations(sourceFile) {
105+
return sourceFile.statements.flatMap((statement) => {
106+
if (!ts.isVariableStatement(statement)) return [];
107+
108+
return statement.declarationList.declarations
109+
.filter((declaration) => ts.isIdentifier(declaration.name))
110+
.map((declaration) => ({statement, declaration}));
111+
});
112+
}
113+
114+
function isExtensionInitializer(initializer) {
115+
const current = initializer && unwrapExpression(initializer);
116+
117+
return (
118+
current &&
119+
ts.isArrowFunction(current) &&
120+
isTypeReferenceTo(current.parameters[0]?.type, EXTENSION_BUILDER_TYPE_NAMES)
121+
);
122+
}
123+
124+
function isObjectAssignFromKnownExtension(initializer, extensionImplementations) {
125+
const current = initializer && unwrapExpression(initializer);
126+
if (
127+
!current ||
128+
!ts.isCallExpression(current) ||
129+
!ts.isPropertyAccessExpression(current.expression)
130+
) {
131+
return false;
132+
}
133+
134+
const callee = current.expression;
135+
const firstArg = current.arguments[0];
136+
137+
return (
138+
ts.isIdentifier(callee.expression) &&
139+
callee.expression.text === 'Object' &&
140+
callee.name.text === 'assign' &&
141+
firstArg &&
142+
ts.isIdentifier(firstArg) &&
143+
extensionImplementations.has(firstArg.text)
144+
);
145+
}
146+
147+
function sourceHasExtensionExport(content) {
148+
const sourceFile = ts.createSourceFile('source.tsx', content, ts.ScriptTarget.Latest, true);
149+
const declarations = readVariableDeclarations(sourceFile);
150+
const extensionImplementations = new Set(
151+
declarations
152+
.filter(({declaration}) => isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES))
153+
.map(({declaration}) => declaration.name.text),
154+
);
155+
156+
return declarations.some(({statement, declaration}) => {
157+
if (!hasExportModifier(statement)) return false;
158+
159+
return (
160+
isTypeReferenceTo(declaration.type, EXTENSION_TYPE_NAMES) ||
161+
isExtensionInitializer(declaration.initializer) ||
162+
isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations)
163+
);
164+
});
165+
}
166+
167+
function refHasExtensionExport(ref) {
168+
return readSourceFiles(ref.dir).some(sourceHasExtensionExport);
169+
}
170+
171+
export function listExtensionNames({
172+
extensionsDir = EDITOR_EXTENSIONS_DIR,
173+
categories = EXTENSION_CATEGORIES,
174+
extraExtensionRefs = EXTRA_EXTENSION_REFS,
175+
blacklist = EXTENSION_BLACKLIST,
176+
} = {}) {
177+
const blacklistSet = new Set(blacklist);
178+
const refs = categories.flatMap((category) => listExtensionRefs(join(extensionsDir, category)));
179+
180+
return [...refs, ...extraExtensionRefs]
181+
.filter((ref) => !blacklistSet.has(ref.name) && refHasExtensionExport(ref))
182+
.map((ref) => ref.name);
183+
}
184+
185+
export function createExtensionRecords(names) {
186+
return names.map((name) => ({name}));
187+
}
188+
189+
export function writeExtensionsJson(outDir = DOCS_GEN_DIR, names = listExtensionNames()) {
190+
mkdirSync(outDir, {recursive: true});
191+
writeFileSync(
192+
join(outDir, 'extensions.json'),
193+
`${JSON.stringify({extensions: createExtensionRecords(names)}, null, 2)}\n`,
194+
);
195+
}
7196

8197
export function main() {
9-
writeExtensionsJson(DOCS_GEN_DIR, extractExtensionData());
198+
writeExtensionsJson();
10199
}
11200

12201
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)