Skip to content

Commit 758c209

Browse files
committed
build(docs-gen): add extension name extractor
1 parent 068aeca commit 758c209

4 files changed

Lines changed: 310 additions & 2 deletions

File tree

infra/docs-gen/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
}
1515
},
1616
"scripts": {
17-
"build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs"
17+
"build": "node src/generate-docs.mjs && yfm -i ../../tmp/docs-src -o ../../dist/docs",
18+
"extract:names": "node src/extract-extension-names.mjs",
19+
"test": "node --test src/*.test.mjs"
1820
},
1921
"dependencies": {
20-
"@diplodoc/cli": "5.43.0"
22+
"@diplodoc/cli": "5.43.0",
23+
"typescript": "catalog:ts"
2124
}
2225
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
#!/usr/bin/env node
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
readFileSync,
6+
readdirSync,
7+
statSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import {basename, dirname, join, resolve} from 'node:path';
11+
import {fileURLToPath} from 'node:url';
12+
13+
import ts from 'typescript';
14+
15+
export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
16+
export const DOCS_GEN_DIR = join(REPO_ROOT, 'tmp/docs-gen');
17+
18+
const EDITOR_PKG_DIR = join(REPO_ROOT, 'packages/editor');
19+
const PAGE_CONSTRUCTOR_EXTENSION_DIR = join(
20+
REPO_ROOT,
21+
'packages/page-constructor-extension/src/extension',
22+
);
23+
const EXTENSION_CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional'];
24+
const EXTENSION_TYPE_NAMES = new Set(['Extension', 'ExtensionAuto', 'ExtensionWithOptions']);
25+
26+
export const EXTENSION_NAME_BLACKLIST = [
27+
'BaseInputRules',
28+
'BaseKeymap',
29+
'BaseStyles',
30+
'ReactRenderer',
31+
'SharedState',
32+
'YfmCut',
33+
];
34+
35+
const EXTENSION_ENTRY_POINTS = [
36+
{
37+
id: 'editor',
38+
kind: 'category-dirs',
39+
packageDir: EDITOR_PKG_DIR,
40+
extensionsDir: 'src/extensions',
41+
categories: EXTENSION_CATEGORIES,
42+
},
43+
{
44+
id: 'page-constructor-extension',
45+
kind: 'single-extension',
46+
extensionDir: PAGE_CONSTRUCTOR_EXTENSION_DIR,
47+
extensionName: 'YfmPageConstructorExtension',
48+
},
49+
];
50+
51+
function parseSource(content, fileName = 'source.tsx') {
52+
return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
53+
}
54+
55+
function forEachNode(root, callback) {
56+
const visit = (node) => {
57+
callback(node);
58+
ts.forEachChild(node, visit);
59+
};
60+
61+
visit(root);
62+
}
63+
64+
function unwrapExpression(expression) {
65+
let current = expression;
66+
67+
while (
68+
ts.isParenthesizedExpression(current) ||
69+
ts.isAsExpression(current) ||
70+
ts.isSatisfiesExpression(current) ||
71+
ts.isNonNullExpression(current) ||
72+
ts.isTypeAssertionExpression(current)
73+
) {
74+
current = current.expression;
75+
}
76+
77+
return current;
78+
}
79+
80+
function getTypeReferenceName(typeName) {
81+
if (ts.isIdentifier(typeName)) return typeName.text;
82+
if (ts.isQualifiedName(typeName)) return typeName.right.text;
83+
84+
return null;
85+
}
86+
87+
function isExtensionType(typeNode) {
88+
return (
89+
typeNode &&
90+
ts.isTypeReferenceNode(typeNode) &&
91+
EXTENSION_TYPE_NAMES.has(getTypeReferenceName(typeNode.typeName))
92+
);
93+
}
94+
95+
function hasExportModifier(node) {
96+
return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
97+
}
98+
99+
function isObjectAssignFromKnownExtension(initializer, extensionImplementations) {
100+
if (!initializer) return false;
101+
102+
const current = unwrapExpression(initializer);
103+
if (!ts.isCallExpression(current) || !ts.isPropertyAccessExpression(current.expression)) {
104+
return false;
105+
}
106+
107+
const callee = current.expression;
108+
if (
109+
!ts.isIdentifier(callee.expression) ||
110+
callee.expression.text !== 'Object' ||
111+
callee.name.text !== 'assign'
112+
) {
113+
return false;
114+
}
115+
116+
const firstArg = current.arguments[0];
117+
return (
118+
Boolean(firstArg) &&
119+
ts.isIdentifier(firstArg) &&
120+
extensionImplementations.has(firstArg.text)
121+
);
122+
}
123+
124+
function readVariableDeclarations(sourceFile) {
125+
const declarations = [];
126+
127+
forEachNode(sourceFile, (node) => {
128+
if (!ts.isVariableStatement(node)) return;
129+
130+
for (const declaration of node.declarationList.declarations) {
131+
if (ts.isIdentifier(declaration.name)) {
132+
declarations.push({statement: node, declaration});
133+
}
134+
}
135+
});
136+
137+
return declarations;
138+
}
139+
140+
function unique(values) {
141+
return [...new Set(values.filter(Boolean))];
142+
}
143+
144+
export function extractExtensionNamesFromSource(content, fileName) {
145+
const sourceFile = parseSource(content, fileName);
146+
const declarations = readVariableDeclarations(sourceFile);
147+
const extensionImplementations = new Set(
148+
declarations
149+
.filter(({declaration}) => isExtensionType(declaration.type))
150+
.map(({declaration}) => declaration.name.text),
151+
);
152+
const names = [];
153+
154+
for (const {statement, declaration} of declarations) {
155+
if (!hasExportModifier(statement)) continue;
156+
157+
if (
158+
isExtensionType(declaration.type) ||
159+
isObjectAssignFromKnownExtension(declaration.initializer, extensionImplementations)
160+
) {
161+
names.push(declaration.name.text);
162+
}
163+
}
164+
165+
return unique(names);
166+
}
167+
168+
export function filterExtensionNames(names, blacklist = EXTENSION_NAME_BLACKLIST) {
169+
const blockedNames = new Set(blacklist);
170+
171+
return names.filter((name) => !blockedNames.has(name));
172+
}
173+
174+
function startsWithUppercaseLetter(name) {
175+
const firstChar = name.charAt(0);
176+
177+
return (
178+
firstChar !== '' &&
179+
firstChar === firstChar.toUpperCase() &&
180+
firstChar !== firstChar.toLowerCase()
181+
);
182+
}
183+
184+
function listExtensionDirs(dir) {
185+
if (!existsSync(dir)) return [];
186+
187+
return readdirSync(dir)
188+
.filter((name) => {
189+
const fullPath = join(dir, name);
190+
return statSync(fullPath).isDirectory() && startsWithUppercaseLetter(name);
191+
})
192+
.map((name) => join(dir, name))
193+
.sort();
194+
}
195+
196+
function readSourceFiles(dir) {
197+
if (!existsSync(dir)) return [];
198+
199+
const files = [];
200+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
201+
const fullPath = join(dir, entry.name);
202+
203+
if (entry.isDirectory()) {
204+
files.push(...readSourceFiles(fullPath));
205+
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
206+
files.push({path: fullPath, content: readFileSync(fullPath, 'utf-8')});
207+
}
208+
}
209+
210+
return files.sort((left, right) => left.path.localeCompare(right.path));
211+
}
212+
213+
function extractExpectedExtensionName(extensionDir, expectedName = basename(extensionDir)) {
214+
const names = unique(
215+
readSourceFiles(extensionDir).flatMap((file) =>
216+
extractExtensionNamesFromSource(file.content, file.path),
217+
),
218+
);
219+
220+
return names.includes(expectedName) ? expectedName : null;
221+
}
222+
223+
function collectCategoryExtensionNames(entryPoint) {
224+
const names = [];
225+
const extensionsRoot = join(entryPoint.packageDir, entryPoint.extensionsDir);
226+
227+
for (const category of entryPoint.categories) {
228+
const categoryDir = join(extensionsRoot, category);
229+
230+
for (const extensionDir of listExtensionDirs(categoryDir)) {
231+
names.push(extractExpectedExtensionName(extensionDir));
232+
}
233+
}
234+
235+
return names;
236+
}
237+
238+
function collectSingleExtensionName(entryPoint) {
239+
return [extractExpectedExtensionName(entryPoint.extensionDir, entryPoint.extensionName)];
240+
}
241+
242+
export function collectExtensionNames(entryPoints = EXTENSION_ENTRY_POINTS) {
243+
const names = entryPoints.flatMap((entryPoint) => {
244+
if (entryPoint.kind === 'category-dirs') return collectCategoryExtensionNames(entryPoint);
245+
if (entryPoint.kind === 'single-extension') return collectSingleExtensionName(entryPoint);
246+
247+
return [];
248+
});
249+
250+
return filterExtensionNames(unique(names));
251+
}
252+
253+
function writeExtensionNames(outDir, names) {
254+
mkdirSync(outDir, {recursive: true});
255+
writeFileSync(
256+
join(outDir, 'extensions.json'),
257+
`${JSON.stringify({extensions: names}, null, 2)}\n`,
258+
);
259+
}
260+
261+
export function main() {
262+
writeExtensionNames(DOCS_GEN_DIR, collectExtensionNames());
263+
}
264+
265+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
266+
main();
267+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import assert from 'node:assert/strict';
2+
import {test} from 'node:test';
3+
4+
import {
5+
EXTENSION_NAME_BLACKLIST,
6+
extractExtensionNamesFromSource,
7+
filterExtensionNames,
8+
} from './extract-extension-names.mjs';
9+
10+
test('extractExtensionNamesFromSource reads exported extension const names from TypeScript AST', () => {
11+
const content = [
12+
"import type {ExtensionAuto} from '../../../core';",
13+
'const InternalExtension: ExtensionAuto = (builder) => {',
14+
' builder.addAction("internal", () => null);',
15+
'};',
16+
'export const PublicExtension = Object.assign(InternalExtension, {});',
17+
'export const DirectExtension: ExtensionAuto = (builder) => {',
18+
' builder.addAction("direct", () => null);',
19+
'};',
20+
'export const NotAnExtension = "value";',
21+
].join('\n');
22+
23+
assert.deepEqual(extractExtensionNamesFromSource(content), [
24+
'PublicExtension',
25+
'DirectExtension',
26+
]);
27+
});
28+
29+
test('filterExtensionNames removes blacklisted extension names', () => {
30+
assert.deepEqual(filterExtensionNames(['Bold', 'BaseKeymap', 'YfmCut', 'Italic']), [
31+
'Bold',
32+
'Italic',
33+
]);
34+
assert.equal(EXTENSION_NAME_BLACKLIST.includes('YfmCut'), true);
35+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)