diff --git a/infra/scripts/release/oxfmt-inprocess/api-bindings.ts b/infra/scripts/release/oxfmt-inprocess/api-bindings.ts new file mode 100644 index 0000000..ce95f5b --- /dev/null +++ b/infra/scripts/release/oxfmt-inprocess/api-bindings.ts @@ -0,0 +1,128 @@ +import { ApiExportMissing, WorkerImportUnrecognised } from '#oxfmt-inprocess/errors'; +import { err, ok } from '#oxfmt-inprocess/result'; +import type { Result } from '#oxfmt-inprocess/result'; + +/** How oxfmt's worker entry imports the functions the shim delegates to. */ +const WORKER_IMPORT = /import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/; + +/** + * One imported binding, in either form a bundler may emit: `x as localName` + * (rolldown's minified re-export) or a bare `localName`. + */ +const BINDING = /^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/; + +/** + * Where oxfmt's embedded-formatting functions live, and what each is exported + * as. + * + * oxfmt's worker entry (`cli-worker.js`) is a two-line module that re-exports + * four functions from a content-hashed API module, e.g. + * + * ```js + * import { i as sortTailwindClasses, n as formatEmbeddedDoc, r as formatFile, t as formatEmbeddedCode } from './apis-CvFX8LhR.js'; + * ``` + * + * Both the hash and the single-letter aliases are bundler output and change + * between oxfmt releases, so they are read from the worker entry rather than + * hard-coded. Parsing succeeds only when all four functions are present, which + * is what lets the getters be total. + */ +export class ApiBindings { + readonly #moduleSpecifier: string; + readonly #formatFile: string; + readonly #formatEmbeddedCode: string; + readonly #formatEmbeddedDoc: string; + readonly #sortTailwindClasses: string; + + private constructor(moduleSpecifier: string, formatFile: string, formatEmbeddedCode: string, formatEmbeddedDoc: string, sortTailwindClasses: string) { + this.#moduleSpecifier = moduleSpecifier; + this.#formatFile = formatFile; + this.#formatEmbeddedCode = formatEmbeddedCode; + this.#formatEmbeddedDoc = formatEmbeddedDoc; + this.#sortTailwindClasses = sortTailwindClasses; + } + + /** + * Read the API module and its export aliases out of oxfmt's worker entry. + * + * @param workerSource - The contents of oxfmt's `cli-worker.js`. + * @param workerPath - Where that file came from, for error reporting. + * @returns The parsed bindings, or the reason the worker entry was not recognised. + */ + static parse(workerSource: string, workerPath: string): Result { + const workerImport = WORKER_IMPORT.exec(workerSource); + + if (workerImport === null) { + return err(new WorkerImportUnrecognised(workerPath, 'cannot find the API import')); + } + + const [, bindingList, moduleSpecifier] = workerImport; + + if (bindingList === undefined || moduleSpecifier === undefined) { + return err(new WorkerImportUnrecognised(workerPath, 'the API import carries no bindings')); + } + + const exportsByRole = new Map(); + + for (const binding of bindingList.split(',')) { + const trimmed = binding.trim(); + const parsed = BINDING.exec(trimmed); + const exported = parsed?.[1]; + + if (parsed === null || exported === undefined) { + return err(new WorkerImportUnrecognised(workerPath, `unexpected binding "${trimmed}"`)); + } + + // A bare binding is imported under its own name, so it is its own role. + exportsByRole.set(parsed[2] ?? exported, exported); + } + + const formatFile = exportsByRole.get('formatFile'); + const formatEmbeddedCode = exportsByRole.get('formatEmbeddedCode'); + const formatEmbeddedDoc = exportsByRole.get('formatEmbeddedDoc'); + const sortTailwindClasses = exportsByRole.get('sortTailwindClasses'); + + if (formatFile === undefined) { + return err(new ApiExportMissing('formatFile', workerPath)); + } + + if (formatEmbeddedCode === undefined) { + return err(new ApiExportMissing('formatEmbeddedCode', workerPath)); + } + + if (formatEmbeddedDoc === undefined) { + return err(new ApiExportMissing('formatEmbeddedDoc', workerPath)); + } + + if (sortTailwindClasses === undefined) { + return err(new ApiExportMissing('sortTailwindClasses', workerPath)); + } + + return ok(new ApiBindings(moduleSpecifier, formatFile, formatEmbeddedCode, formatEmbeddedDoc, sortTailwindClasses)); + } + + /** The module specifier the API functions are imported from. */ + get moduleSpecifier(): string { + return this.#moduleSpecifier; + } + + /** The name `formatFile` is exported as. */ + get formatFile(): string { + return this.#formatFile; + } + + /** The name `formatEmbeddedCode` is exported as. */ + get formatEmbeddedCode(): string { + return this.#formatEmbeddedCode; + } + + /** The name `formatEmbeddedDoc` is exported as. */ + get formatEmbeddedDoc(): string { + return this.#formatEmbeddedDoc; + } + + /** The name `sortTailwindClasses` is exported as. */ + get sortTailwindClasses(): string { + return this.#sortTailwindClasses; + } +} diff --git a/infra/scripts/release/oxfmt-inprocess/cli-patcher.ts b/infra/scripts/release/oxfmt-inprocess/cli-patcher.ts new file mode 100644 index 0000000..c79ba2b --- /dev/null +++ b/infra/scripts/release/oxfmt-inprocess/cli-patcher.ts @@ -0,0 +1,141 @@ +import { join } from 'node:path'; +import { ApiBindings } from '#oxfmt-inprocess/api-bindings'; +import { CliAlreadyPatched, CliAnchorMissing, CliPatchIncomplete } from '#oxfmt-inprocess/errors'; +import type { OxfmtPatchError } from '#oxfmt-inprocess/errors'; +import { err, isErr, ok } from '#oxfmt-inprocess/result'; +import type { Result } from '#oxfmt-inprocess/result'; +import { SHIM_MARKER, ShimSource } from '#oxfmt-inprocess/shim-source'; +import type { TextFiles } from '#oxfmt-inprocess/text-files'; + +/** oxfmt's import of the worker-pool library, replaced by the shim's import. */ +const TINYPOOL_IMPORT = 'import Tinypool from "tinypool";'; + +/** + * The structures the rewrite edits. Absent any one of them, oxfmt's CLI has + * changed shape and the rewrite must be re-derived rather than misapplied. + */ +const ANCHORS: ReadonlyArray = [TINYPOOL_IMPORT, '//#region src-js/cli/worker-proxy.ts', 'runtime: "child_process"']; + +/** oxfmt's `worker-proxy` region, from its marker to the first region end. */ +const WORKER_PROXY_REGION = /\/\/#region src-js\/cli\/worker-proxy\.ts[\s\S]*?\/\/#endregion/; + +/** Worker-pool code that must not survive the rewrite. */ +const RESIDUE: ReadonlyArray = ['Tinypool', 'pool.run', 'pool = ']; + +/** What a successful rewrite changed. */ +export type PatchOutcome = { + /** The CLI file that was rewritten. */ + readonly cliPath: string; + + /** The API module the shim now calls directly. */ + readonly apiModuleSpecifier: string; +}; + +/** + * Rewrites oxfmt's CLI to format embedded code in-process. + * + * oxfmt formats embedded code — Vue `