From 545fe2f2e41d6bce4577d199931ce9c40a9d1fa7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:06:53 -0500 Subject: [PATCH] chore: initial generator package --- packages/compiler/src/index.ts | 54 +++- packages/generator/eslint.config.mjs | 17 ++ packages/generator/package.json | 60 +++++ packages/generator/src/index.ts | 326 ++++++++++++++++++++++++ packages/generator/src/tempblot.d.ts | 11 + packages/generator/tests/index.spec.ts | 154 +++++++++++ packages/generator/tsconfig.app.json | 4 + packages/generator/tsconfig.config.json | 8 + packages/generator/tsconfig.json | 14 + packages/generator/tsconfig.spec.json | 4 + packages/generator/vite.config.ts | 43 ++++ pnpm-lock.yaml | 28 ++ 12 files changed, 710 insertions(+), 13 deletions(-) create mode 100644 packages/generator/eslint.config.mjs create mode 100644 packages/generator/package.json create mode 100644 packages/generator/src/index.ts create mode 100644 packages/generator/src/tempblot.d.ts create mode 100644 packages/generator/tests/index.spec.ts create mode 100644 packages/generator/tsconfig.app.json create mode 100644 packages/generator/tsconfig.config.json create mode 100644 packages/generator/tsconfig.json create mode 100644 packages/generator/tsconfig.spec.json create mode 100644 packages/generator/vite.config.ts diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 2c0c482..90f88a2 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -22,30 +22,58 @@ export async function compilePath( sourcePath: string, params: TParams, ): Promise { + const outputVarName = "o" + crypto.randomUUID().replace(/-/g, ""); + const compiledModule = await loadPathModule(sourcePath, params, outputVarName); + const compiledOutput = compiledModule[outputVarName]; + + if (typeof compiledOutput !== "string") { + throw new TypeError("Tempblot output must compile to a string"); + } + + return compiledOutput; +} + +/** + * @param sourcePath - the absolute path to the `.blot` source file + * @param params - configuration passed to the `.blot` source file + * @returns the evaluated exports from the source file's `` block + */ +export async function loadSetupPath( + sourcePath: string, + params: TParams, +): Promise> { + return loadPathModule(sourcePath, params); +} + +async function loadPathModule( + sourcePath: string, + params: TParams, + outputVarName?: string, +): Promise> { globalThis.tempblotParams ??= {}; globalThis.tempblotParams[sourcePath] = params; - const outputVarName = "o" + crypto.randomUUID().replace(/-/g, ""); const source = await fs.readFile(sourcePath, "utf8"); const sourceDir = path.dirname(sourcePath); const rootTokens = tokenizeRoot(source); const rootAST = parseRoot(rootTokens); - const transformedOutput = transformOutputTemplate(rootAST.output.contents); const transformedSetup = transformSetup(rootAST.setup.contents, sourcePath); - const concatenatedSetupOutput = ` - ${transformedSetup} - export const ${outputVarName} = \`${transformedOutput}\`; - `; + const concatenatedSetupOutput = outputVarName + ? ` + ${transformedSetup} + export const ${outputVarName} = \`${transformOutputTemplate(rootAST.output.contents)}\`; + ` + : transformedSetup; + // Write a temporary file to disk - const tempPath = path.join(sourceDir, `.tempblot_${outputVarName}.ts`); + const tempPath = path.join( + sourceDir, + `.tempblot_${outputVarName ?? crypto.randomUUID().replace(/-/g, "")}.ts`, + ); + try { await fs.writeFile(tempPath, concatenatedSetupOutput); - const compiledModule = (await import(tempPath)) as Record; - const compiledOutput = compiledModule[outputVarName]; - if (typeof compiledOutput !== "string") { - throw new TypeError("Tempblot output must compile to a string"); - } - return compiledOutput; + return (await import(tempPath)) as Record; } finally { await fs.unlink(tempPath); } diff --git a/packages/generator/eslint.config.mjs b/packages/generator/eslint.config.mjs new file mode 100644 index 0000000..01d1bbe --- /dev/null +++ b/packages/generator/eslint.config.mjs @@ -0,0 +1,17 @@ +import tempblotPreset from "@tempblot/config/eslint-preset.js"; + +export default [ + ...tempblotPreset, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["eslint.config.mjs"], + }, +]; diff --git a/packages/generator/package.json b/packages/generator/package.json new file mode 100644 index 0000000..fabac33 --- /dev/null +++ b/packages/generator/package.json @@ -0,0 +1,60 @@ +{ + "name": "@tempblot/generator", + "version": "0.1.0", + "type": "module", + "description": "File-based scaffolding engine for Tempblot", + "author": "Corbin Crutchley ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/crutchcorn/tempblot.git", + "directory": "packages/generator" + }, + "homepage": "https://github.com/crutchcorn/tempblot", + "bugs": { + "url": "https://github.com/crutchcorn/tempblot/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + }, + "keywords": [ + "generator", + "scaffolding", + "tempblot", + "template", + "templating" + ], + "scripts": { + "build": "vite build", + "test:lib": "vitest", + "test:build": "publint && attw --profile esm-only --pack .", + "test:types": "tsc --noEmit", + "test:eslint": "eslint ." + }, + "dependencies": { + "tempblot": "^0.1.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@tempblot/config": "workspace:*", + "@types/node": "^24.5.2", + "publint": "^0.3.21", + "unplugin-dts": "^1.0.0-beta.6", + "vite": "^8.0.12", + "vitest": "^4.1.6" + }, + "engines": { + "node": ">=22.18.0" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts new file mode 100644 index 0000000..3ec6124 --- /dev/null +++ b/packages/generator/src/index.ts @@ -0,0 +1,326 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compilePath, loadSetupPath } from "tempblot"; + +export type ExistingFileBehavior = "error" | "overwrite" | "skip"; + +export type TemplateParams = Record; + +export interface GenerateOptions { + inputDir: string | URL; + params?: TParams; + outputDir: string | URL; + existingFiles?: ExistingFileBehavior; +} + +export type GeneratedFileAction = "created" | "overwritten" | "skipped"; + +export interface GeneratedFile { + sourcePath: string; + outputPath: string; + action: GeneratedFileAction; +} + +export interface GenerateResult { + files: GeneratedFile[]; +} + +interface GenerateState { + inputDir: string; + outputDir: string; + existingFiles: ExistingFileBehavior; + files: GeneratedFile[]; +} + +const blotExtension = ".blot"; +const pathsFileName = `_paths${blotExtension}`; +const dynamicSegmentPattern = /\[([^\]]+)\]/g; + +export async function generate< + TParams extends TemplateParams = TemplateParams, +>(options: GenerateOptions): Promise { + const state: GenerateState = { + inputDir: normalizeInputPath(options.inputDir), + outputDir: normalizeInputPath(options.outputDir), + existingFiles: options.existingFiles ?? "error", + files: [], + }; + + await processDirectoryContents(state, state.inputDir, [], options.params ?? {}); + + return { files: state.files }; +} + +async function processDirectoryContents( + state: GenerateState, + inputDir: string, + outputSegments: string[], + params: TemplateParams, +): Promise { + const entries = await fs.readdir(inputDir, { withFileTypes: true }); + + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const sourcePath = path.join(inputDir, entry.name); + + if (entry.isDirectory()) { + await processDirectory(state, sourcePath, outputSegments, params); + continue; + } + + if (!entry.isFile() || entry.name === pathsFileName) { + continue; + } + + if (entry.name.endsWith(blotExtension)) { + await processBlotFile(state, sourcePath, outputSegments, params); + } else { + await copyStaticFile(state, sourcePath, [...outputSegments, entry.name]); + } + } +} + +async function processDirectory( + state: GenerateState, + sourcePath: string, + outputSegments: string[], + params: TemplateParams, +): Promise { + const directoryName = path.basename(sourcePath); + + if (!hasDynamicSegment(directoryName)) { + await processDirectoryContents(state, sourcePath, [ + ...outputSegments, + directoryName, + ], params); + return; + } + + const pathsSourcePath = path.join(sourcePath, pathsFileName); + const pathParams = await readPathParams(pathsSourcePath, params, true); + + for (const nextParams of pathParams) { + const mergedParams = mergeParams(params, nextParams); + const renderedDirectoryName = renderDynamicSegments( + directoryName, + mergedParams, + sourcePath, + ); + + await processDirectoryContents( + state, + sourcePath, + [...outputSegments, ...splitOutputPath(renderedDirectoryName, sourcePath)], + mergedParams, + ); + } +} + +async function processBlotFile( + state: GenerateState, + sourcePath: string, + outputSegments: string[], + params: TemplateParams, +): Promise { + const sourceName = path.basename(sourcePath); + const outputName = sourceName.slice(0, -blotExtension.length); + + if (!hasDynamicSegment(outputName)) { + const output = await compilePath(sourcePath, params); + await writeOutputFile(state, sourcePath, [...outputSegments, outputName], output); + return; + } + + const pathParams = await readPathParams(sourcePath, params, false); + + for (const nextParams of pathParams) { + const mergedParams = mergeParams(params, nextParams); + const renderedOutputName = renderDynamicSegments( + outputName, + mergedParams, + sourcePath, + ); + const output = await compilePath(sourcePath, mergedParams); + + await writeOutputFile( + state, + sourcePath, + [...outputSegments, ...splitOutputPath(renderedOutputName, sourcePath)], + output, + ); + } +} + +async function copyStaticFile( + state: GenerateState, + sourcePath: string, + outputSegments: string[], +): Promise { + const contents = await fs.readFile(sourcePath); + await writeOutputFile(state, sourcePath, outputSegments, contents); +} + +async function readPathParams( + sourcePath: string, + params: TemplateParams, + requireGetPaths: boolean, +): Promise { + const setupExports = await loadSetupPath(sourcePath, params); + const getPaths: unknown = setupExports.getPaths; + + if (getPaths === undefined) { + if (requireGetPaths) { + throw new Error(`${sourcePath} must export a getPaths function`); + } + + return [params]; + } + + if (typeof getPaths !== "function") { + throw new TypeError(`${sourcePath} exports getPaths, but it is not a function`); + } + + const pathGetter = getPaths as () => unknown; + const result = await pathGetter(); + + if (!Array.isArray(result)) { + throw new TypeError(`${sourcePath} getPaths must return an array`); + } + + return result.map((entry, index) => { + if (!isTemplateParams(entry)) { + throw new TypeError( + `${sourcePath} getPaths entry at index ${index} must be an object`, + ); + } + + return entry; + }); +} + +async function writeOutputFile( + state: GenerateState, + sourcePath: string, + outputSegments: string[], + contents: string | Uint8Array, +): Promise { + const outputPath = resolveOutputPath(state, outputSegments); + const exists = await pathExists(outputPath); + + if (exists && state.existingFiles === "error") { + throw new Error(`Output file already exists: ${outputPath}`); + } + + if (exists && state.existingFiles === "skip") { + state.files.push({ sourcePath, outputPath, action: "skipped" }); + return; + } + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, contents); + state.files.push({ + sourcePath, + outputPath, + action: exists ? "overwritten" : "created", + }); +} + +function resolveOutputPath( + state: GenerateState, + outputSegments: string[], +): string { + const outputPath = path.resolve(state.outputDir, ...outputSegments); + const relativeOutputPath = path.relative(state.outputDir, outputPath); + + if ( + relativeOutputPath.startsWith("..") || + path.isAbsolute(relativeOutputPath) + ) { + throw new Error(`Generated output path escapes outputDir: ${outputPath}`); + } + + return outputPath; +} + +function renderDynamicSegments( + segment: string, + params: TemplateParams, + sourcePath: string, +): string { + return segment.replace(dynamicSegmentPattern, (_match, paramName: string) => { + const value = params[paramName]; + + if (value === undefined || value === null) { + throw new Error(`${sourcePath} is missing dynamic param: ${paramName}`); + } + + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" + ) { + throw new TypeError( + `${sourcePath} dynamic param ${paramName} must be a string, number, or boolean`, + ); + } + + const text = String(value); + + if (text.length === 0) { + throw new Error(`${sourcePath} dynamic param ${paramName} cannot be empty`); + } + + return text; + }); +} + +function splitOutputPath(outputPath: string, sourcePath: string): string[] { + if (path.isAbsolute(outputPath) || /^[A-Za-z]:[\\/]/.test(outputPath)) { + throw new Error(`${sourcePath} generated an absolute output path`); + } + + const segments = outputPath.split(/[\\/]+/).filter(Boolean); + + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new Error(`${sourcePath} generated an unsafe output path`); + } + + return segments; +} + +function hasDynamicSegment(segment: string): boolean { + return /\[[^\]]+\]/.test(segment); +} + +function mergeParams( + params: TemplateParams, + nextParams: TemplateParams, +): TemplateParams { + return { ...params, ...nextParams }; +} + +function isTemplateParams(value: unknown): value is TemplateParams { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + + throw error; + } +} + +function normalizeInputPath(inputPath: string | URL): string { + return path.resolve( + inputPath instanceof URL ? fileURLToPath(inputPath) : inputPath, + ); +} diff --git a/packages/generator/src/tempblot.d.ts b/packages/generator/src/tempblot.d.ts new file mode 100644 index 0000000..d8637a0 --- /dev/null +++ b/packages/generator/src/tempblot.d.ts @@ -0,0 +1,11 @@ +declare module "tempblot" { + export function compilePath( + sourcePath: string, + params: TParams, + ): Promise; + + export function loadSetupPath( + sourcePath: string, + params: TParams, + ): Promise>; +} diff --git a/packages/generator/tests/index.spec.ts b/packages/generator/tests/index.spec.ts new file mode 100644 index 0000000..edb61a9 --- /dev/null +++ b/packages/generator/tests/index.spec.ts @@ -0,0 +1,154 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, expect, test } from "vitest"; + +import { generate } from "../src/index.ts"; + +const testRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + testRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +test("generates a static blot file", async () => { + const { inputDir, outputDir } = await createTestWorkspace(); + await fs.writeFile( + path.join(inputDir, "test.json.blot"), + ` +const val = 123; + + + +{ "value": <> } + +`, + ); + + const result = await generate({ inputDir, outputDir }); + + await expect(fs.readFile(path.join(outputDir, "test.json"), "utf8")).resolves.toBe( + ` +{ "value": 123 } +`, + ); + expect(result.files).toMatchObject([ + { + outputPath: path.join(outputDir, "test.json"), + action: "created", + }, + ]); +}); + +test("generates dynamic file names from getPaths", async () => { + const { inputDir, outputDir } = await createTestWorkspace(); + await fs.writeFile( + path.join(inputDir, "[path].blot"), + ` +import { useParams } from "tempblot"; + +export function getPaths() { + return [{ path: "one.js", val: 1 }, { path: "two.js", val: 2 }]; +} + +const { val } = useParams<{ path: string; val: number }>(); + + + +console.log(<>); + +`, + ); + + await generate({ inputDir, outputDir }); + + await expect(fs.readFile(path.join(outputDir, "one.js"), "utf8")).resolves.toBe( + ` +console.log(1); +`, + ); + await expect(fs.readFile(path.join(outputDir, "two.js"), "utf8")).resolves.toBe( + ` +console.log(2); +`, + ); +}); + +test("generates dynamic directories from _paths.blot", async () => { + const { inputDir, outputDir } = await createTestWorkspace(); + const dynamicDir = path.join(inputDir, "[name]"); + await fs.mkdir(dynamicDir); + await fs.writeFile( + path.join(dynamicDir, "_paths.blot"), + ` +export function getPaths() { + return [{ name: "one" }, { name: "two" }]; +} + +`, + ); + await fs.writeFile( + path.join(dynamicDir, "index.ts.blot"), + ` +import { useParams } from "tempblot"; + +const { name } = useParams<{ name: string }>(); + + + +export const name = "<>"; + +`, + ); + await fs.writeFile(path.join(dynamicDir, "static.txt"), "copied"); + + await generate({ inputDir, outputDir }); + + await expect(fs.readFile(path.join(outputDir, "one", "index.ts"), "utf8")) + .resolves.toBe(` +export const name = "one"; +`); + await expect(fs.readFile(path.join(outputDir, "two", "index.ts"), "utf8")) + .resolves.toBe(` +export const name = "two"; +`); + await expect(fs.readFile(path.join(outputDir, "one", "static.txt"), "utf8")) + .resolves.toBe("copied"); +}); + +test("skips existing files when requested", async () => { + const { inputDir, outputDir } = await createTestWorkspace(); + await fs.writeFile( + path.join(inputDir, "test.txt.blot"), + `new`, + ); + await fs.mkdir(outputDir); + await fs.writeFile(path.join(outputDir, "test.txt"), "existing"); + + const result = await generate({ + inputDir, + outputDir, + existingFiles: "skip", + }); + + await expect(fs.readFile(path.join(outputDir, "test.txt"), "utf8")) + .resolves.toBe("existing"); + expect(result.files).toMatchObject([{ action: "skipped" }]); +}); + +async function createTestWorkspace(): Promise<{ + inputDir: string; + outputDir: string; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tempblot-generator-")); + const inputDir = path.join(root, "input"); + const outputDir = path.join(root, "output"); + + testRoots.push(root); + await fs.mkdir(inputDir); + + return { inputDir, outputDir }; +} diff --git a/packages/generator/tsconfig.app.json b/packages/generator/tsconfig.app.json new file mode 100644 index 0000000..f8e4107 --- /dev/null +++ b/packages/generator/tsconfig.app.json @@ -0,0 +1,4 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src"] +} diff --git a/packages/generator/tsconfig.config.json b/packages/generator/tsconfig.config.json new file mode 100644 index 0000000..0bfb873 --- /dev/null +++ b/packages/generator/tsconfig.config.json @@ -0,0 +1,8 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/generator/tsconfig.json b/packages/generator/tsconfig.json new file mode 100644 index 0000000..6e2441f --- /dev/null +++ b/packages/generator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.config.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/generator/tsconfig.spec.json b/packages/generator/tsconfig.spec.json new file mode 100644 index 0000000..c6b7a16 --- /dev/null +++ b/packages/generator/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src", "tests"] +} diff --git a/packages/generator/vite.config.ts b/packages/generator/vite.config.ts new file mode 100644 index 0000000..c3f581c --- /dev/null +++ b/packages/generator/vite.config.ts @@ -0,0 +1,43 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import dts from "unplugin-dts/vite"; +import packageJson from "./package.json" with { type: "json" }; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig(({ mode }) => ({ + plugins: [ + dts({ + tsconfigPath: "tsconfig.app.json", + entryRoot: "src", + }), + ], + resolve: + mode === "test" + ? { + alias: { + tempblot: resolve(__dirname, "../compiler/src/index.ts"), + }, + } + : undefined, + build: { + lib: { + name: "TempblotGenerator", + fileName: "index", + entry: resolve(__dirname, "src/index.ts"), + formats: ["es"], + }, + rollupOptions: { + external: [/^node:/, "tempblot"], + }, + }, + test: { + name: packageJson.name, + dir: "./tests", + watch: false, + }, + define: { + "import.meta.vitest": mode !== "production", + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d911de..4770f42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,34 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/generator: + dependencies: + tempblot: + specifier: ^0.1.0 + version: link:../compiler + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.2 + version: 0.18.2 + '@tempblot/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^24.5.2 + version: 24.12.4 + publint: + specifier: ^0.3.21 + version: 0.3.21 + unplugin-dts: + specifier: ^1.0.0-beta.6 + version: 1.0.0(esbuild@0.28.0)(rolldown@1.0.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.12 + version: 8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.12.4)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + packages/language-server: dependencies: '@tempblot/language-service': -- 2.51.2