|
| 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 | +} |
0 commit comments