#!/usr/bin/env -S deno run --allow-read --allow-env --allow-write import { fromFileUrl } from "@std/path"; import ts from "typescript"; import { cliMain, type GeneratedAsset } from "./generated-asset.ts"; /** * Generates `packages/static/assets/types/commonfabric.d.ts` from the public * pattern API (`packages/api/index.ts`). * * That file is the type module the pattern compiler serves for `commonfabric`, * and it is also what a reader opens to see the surface a pattern is written * against. Generating it puts a "do not edit by hand" line at the top of what * a reader opens, and it lets the api module take declarations from the * packages that own them: the compiler serves each module as a single * self-contained text and resolves no bare specifier, so a declaration the api * module re-exports has to arrive here as text. * * Two statement forms carry that. `export * from "@commonfabric/…"` is * replaced by the named module's own text, and an `import type` from a * specifier being inlined is dropped, its names now local. Any other statement * naming a `@commonfabric/` specifier throws rather than emitting a file whose * specifier the sandbox cannot resolve. * * Run with `--check` to fail when the checked-in file is out of date; run with * no arguments to rewrite it. */ const SCRIPT_REL_PATH = "packages/static/scripts/generate-commonfabric-types.ts"; const SOURCE_REL_PATH = "packages/api/index.ts"; const GEN_TASK = "deno task gen-commonfabric-types"; const CHECK_TASK = "deno task check-commonfabric-types"; /** Prefix of the specifiers this module inlines. */ const WORKSPACE_SCOPE = "@commonfabric/"; const sourcePath = fromFileUrl(new URL("../../api/index.ts", import.meta.url)); const targetPath = fromFileUrl( new URL("../assets/types/commonfabric.d.ts", import.meta.url), ); const HEADER = [ `// Generated by ${SCRIPT_REL_PATH}.`, "// Do not edit by hand.", "//", "// The type module the pattern compiler serves for `commonfabric`, built", `// from ${SOURCE_REL_PATH} with each workspace module it re-exports inlined.`, "//", "// To regenerate after changing the pattern API, run (from packages/static):", `// ${GEN_TASK}`, "// To verify it is up to date, run:", `// ${CHECK_TASK}`, ].join("\n"); /** One replacement to apply to the source text. */ interface Edit { readonly start: number; readonly end: number; readonly text: string; } /** * Returns the module specifier a statement names, or `undefined` for a * statement that names none. Only a string-literal specifier counts, which is * the only form either an import or an export declaration can carry. */ export function moduleSpecifierOf( statement: ts.Statement, ): string | undefined { const specifier = ts.isImportDeclaration(statement) ? statement.moduleSpecifier : ts.isExportDeclaration(statement) ? statement.moduleSpecifier : undefined; return (specifier !== undefined && ts.isStringLiteral(specifier)) ? specifier.text : undefined; } /** Whether a statement is `export * from "…"`, with no named export clause. */ function isStarReExport(statement: ts.Statement): boolean { return ts.isExportDeclaration(statement) && statement.exportClause === undefined; } /** Whether a statement is `import type … from "…"`. */ function isTypeOnlyImport(statement: ts.Statement): boolean { return ts.isImportDeclaration(statement) && statement.importClause?.isTypeOnly === true; } /** * Returns the offset just past the line a dropped statement ends on, together * with the blank line that separated it from the next one. Deleting to here * leaves the surrounding text as though the statement had never been written. */ function endOfDroppedStatement(text: string, end: number): number { let index = end; if (text[index] === "\n") index += 1; if (text[index] === "\n") index += 1; return index; } /** * Reads the module a workspace specifier names, and returns its text with * trailing newlines trimmed so that the spliced statement's own line ending * terminates it. * * An inlined module must be self-contained. One that names a specifier of its * own would put that specifier in the generated file, where the pattern * compiler resolves nothing, so it is refused here instead. */ export async function readInlinedModule(specifier: string): Promise { const path = fromFileUrl(import.meta.resolve(specifier)); const text = await Deno.readTextFile(path); const source = ts.createSourceFile( path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); for (const statement of source.statements) { const named = moduleSpecifierOf(statement); if (named !== undefined) { throw new Error( `Cannot inline "${specifier}": ${path} imports "${named}", and the` + " pattern compiler resolves no specifier in a generated module.", ); } } return text.replace(/\n+$/, ""); } /** * Replaces each `export * from` a workspace specifier with that module's text, * and drops each `import type` from a specifier so inlined. */ export async function inlineWorkspaceModules( text: string, fileName: string, ): Promise { const source = ts.createSourceFile( fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const inlined = new Set(); for (const statement of source.statements) { const specifier = moduleSpecifierOf(statement); if ( specifier?.startsWith(WORKSPACE_SCOPE) && isStarReExport(statement) ) { inlined.add(specifier); } } const edits: Edit[] = []; for (const statement of source.statements) { const specifier = moduleSpecifierOf(statement); if (specifier === undefined || !specifier.startsWith(WORKSPACE_SCOPE)) { continue; } const start = statement.getStart(source); if (isStarReExport(statement)) { edits.push({ start, end: statement.getEnd(), text: await readInlinedModule(specifier), }); } else if (isTypeOnlyImport(statement) && inlined.has(specifier)) { edits.push({ start, end: endOfDroppedStatement(text, statement.getEnd()), text: "", }); } else { throw new Error( `${fileName} cannot be generated: the statement naming "${specifier}"` + " is neither an `export * from` to inline nor an `import type` from" + ` a module being inlined. Statement: ${statement.getText(source)}`, ); } } let result = text; for (const edit of [...edits].sort((a, b) => b.start - a.start)) { result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); } return result; } /** Builds the type module's text from the pattern API module. */ export async function generateCommonfabricTypes( source: string = sourcePath, ): Promise { const text = await Deno.readTextFile(source); return `${HEADER}\n\n${await inlineWorkspaceModules(text, source)}`; } /** This script's asset. */ export const asset: GeneratedAsset = { target: targetPath, genTask: GEN_TASK, generate: generateCommonfabricTypes, }; await cliMain(asset, Deno.args, import.meta.main);