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 From 73358a363d9d4bc8748cb9925616a7a3b47b699a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:11:32 -0500 Subject: [PATCH 2/9] chore: change inputs to use fixture folders --- .../input/[name]/_paths.blot | 5 + .../input/[name]/index.ts.blot | 9 ++ .../input/[name]/static.txt | 1 + .../fixtures/dynamic-files/input/[path].blot | 13 +++ .../skip-existing/input/test.txt.blot | 1 + .../fixtures/skip-existing/output/test.txt | 1 + .../fixtures/static/input/test.json.blot | 7 ++ packages/generator/tests/index.spec.ts | 99 +++++-------------- 8 files changed, 64 insertions(+), 72 deletions(-) create mode 100644 packages/generator/tests/fixtures/dynamic-directories/input/[name]/_paths.blot create mode 100644 packages/generator/tests/fixtures/dynamic-directories/input/[name]/index.ts.blot create mode 100644 packages/generator/tests/fixtures/dynamic-directories/input/[name]/static.txt create mode 100644 packages/generator/tests/fixtures/dynamic-files/input/[path].blot create mode 100644 packages/generator/tests/fixtures/skip-existing/input/test.txt.blot create mode 100644 packages/generator/tests/fixtures/skip-existing/output/test.txt create mode 100644 packages/generator/tests/fixtures/static/input/test.json.blot diff --git a/packages/generator/tests/fixtures/dynamic-directories/input/[name]/_paths.blot b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/_paths.blot new file mode 100644 index 0000000..5e6a034 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/_paths.blot @@ -0,0 +1,5 @@ + +export function getPaths() { + return [{ name: "one" }, { name: "two" }]; +} + diff --git a/packages/generator/tests/fixtures/dynamic-directories/input/[name]/index.ts.blot b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/index.ts.blot new file mode 100644 index 0000000..9c8928b --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/index.ts.blot @@ -0,0 +1,9 @@ + +import { useParams } from "tempblot"; + +const { name } = useParams<{ name: string }>(); + + + +export const name = "<>"; + diff --git a/packages/generator/tests/fixtures/dynamic-directories/input/[name]/static.txt b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/static.txt new file mode 100644 index 0000000..7c29dd8 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/input/[name]/static.txt @@ -0,0 +1 @@ +copied diff --git a/packages/generator/tests/fixtures/dynamic-files/input/[path].blot b/packages/generator/tests/fixtures/dynamic-files/input/[path].blot new file mode 100644 index 0000000..758ac39 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-files/input/[path].blot @@ -0,0 +1,13 @@ + +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(<>); + diff --git a/packages/generator/tests/fixtures/skip-existing/input/test.txt.blot b/packages/generator/tests/fixtures/skip-existing/input/test.txt.blot new file mode 100644 index 0000000..dd8dd9a --- /dev/null +++ b/packages/generator/tests/fixtures/skip-existing/input/test.txt.blot @@ -0,0 +1 @@ +new diff --git a/packages/generator/tests/fixtures/skip-existing/output/test.txt b/packages/generator/tests/fixtures/skip-existing/output/test.txt new file mode 100644 index 0000000..cbaf024 --- /dev/null +++ b/packages/generator/tests/fixtures/skip-existing/output/test.txt @@ -0,0 +1 @@ +existing diff --git a/packages/generator/tests/fixtures/static/input/test.json.blot b/packages/generator/tests/fixtures/static/input/test.json.blot new file mode 100644 index 0000000..98eef85 --- /dev/null +++ b/packages/generator/tests/fixtures/static/input/test.json.blot @@ -0,0 +1,7 @@ + +const val = 123; + + + +{ "value": <> } + diff --git a/packages/generator/tests/index.spec.ts b/packages/generator/tests/index.spec.ts index edb61a9..b7f2348 100644 --- a/packages/generator/tests/index.spec.ts +++ b/packages/generator/tests/index.spec.ts @@ -1,11 +1,14 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, expect, test } from "vitest"; import { generate } from "../src/index.ts"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures"); const testRoots: string[] = []; afterEach(async () => { @@ -15,18 +18,8 @@ afterEach(async () => { }); 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 inputDir = getFixtureInputDir("static"); + const outputDir = await createOutputDir(); const result = await generate({ inputDir, outputDir }); @@ -44,24 +37,8 @@ const val = 123; }); 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(<>); - -`, - ); + const inputDir = getFixtureInputDir("dynamic-files"); + const outputDir = await createOutputDir(); await generate({ inputDir, outputDir }); @@ -78,32 +55,8 @@ 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"); + const inputDir = getFixtureInputDir("dynamic-directories"); + const outputDir = await createOutputDir(); await generate({ inputDir, outputDir }); @@ -116,17 +69,16 @@ export const name = "one"; export const name = "two"; `); await expect(fs.readFile(path.join(outputDir, "one", "static.txt"), "utf8")) - .resolves.toBe("copied"); + .resolves.toBe("copied\n"); }); 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 inputDir = getFixtureInputDir("skip-existing"); + const outputDir = await createOutputDir(); + + await fs.cp(getFixtureOutputDir("skip-existing"), outputDir, { + recursive: true, + }); const result = await generate({ inputDir, @@ -135,20 +87,23 @@ test("skips existing files when requested", async () => { }); await expect(fs.readFile(path.join(outputDir, "test.txt"), "utf8")) - .resolves.toBe("existing"); + .resolves.toBe("existing\n"); expect(result.files).toMatchObject([{ action: "skipped" }]); }); -async function createTestWorkspace(): Promise<{ - inputDir: string; - outputDir: string; -}> { +async function createOutputDir(): Promise { 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 }; + return outputDir; +} + +function getFixtureInputDir(name: string): string { + return path.join(fixturesDir, name, "input"); +} + +function getFixtureOutputDir(name: string): string { + return path.join(fixturesDir, name, "output"); } -- 2.51.2 From c5070859344df736f134d6a4beee10746bb41366 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:15:30 -0500 Subject: [PATCH 3/9] chore: update outputs to use fixtures too --- packages/generator/eslint.config.mjs | 2 +- .../dynamic-directories/output/one/index.ts | 2 + .../dynamic-directories/output/one/static.txt | 1 + .../dynamic-directories/output/two/index.ts | 2 + .../dynamic-directories/output/two/static.txt | 1 + .../fixtures/dynamic-files/output/one.js | 2 + .../fixtures/dynamic-files/output/two.js | 2 + .../tests/fixtures/static/output/test.json | 2 + packages/generator/tests/index.spec.ts | 76 ++++++++++++------- 9 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 packages/generator/tests/fixtures/dynamic-directories/output/one/index.ts create mode 100644 packages/generator/tests/fixtures/dynamic-directories/output/one/static.txt create mode 100644 packages/generator/tests/fixtures/dynamic-directories/output/two/index.ts create mode 100644 packages/generator/tests/fixtures/dynamic-directories/output/two/static.txt create mode 100644 packages/generator/tests/fixtures/dynamic-files/output/one.js create mode 100644 packages/generator/tests/fixtures/dynamic-files/output/two.js create mode 100644 packages/generator/tests/fixtures/static/output/test.json diff --git a/packages/generator/eslint.config.mjs b/packages/generator/eslint.config.mjs index 01d1bbe..14f3d51 100644 --- a/packages/generator/eslint.config.mjs +++ b/packages/generator/eslint.config.mjs @@ -12,6 +12,6 @@ export default [ }, }, { - ignores: ["eslint.config.mjs"], + ignores: ["eslint.config.mjs", "tests/fixtures/**"], }, ]; diff --git a/packages/generator/tests/fixtures/dynamic-directories/output/one/index.ts b/packages/generator/tests/fixtures/dynamic-directories/output/one/index.ts new file mode 100644 index 0000000..e2cc92b --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/output/one/index.ts @@ -0,0 +1,2 @@ + +export const name = "one"; diff --git a/packages/generator/tests/fixtures/dynamic-directories/output/one/static.txt b/packages/generator/tests/fixtures/dynamic-directories/output/one/static.txt new file mode 100644 index 0000000..7c29dd8 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/output/one/static.txt @@ -0,0 +1 @@ +copied diff --git a/packages/generator/tests/fixtures/dynamic-directories/output/two/index.ts b/packages/generator/tests/fixtures/dynamic-directories/output/two/index.ts new file mode 100644 index 0000000..ea6301f --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/output/two/index.ts @@ -0,0 +1,2 @@ + +export const name = "two"; diff --git a/packages/generator/tests/fixtures/dynamic-directories/output/two/static.txt b/packages/generator/tests/fixtures/dynamic-directories/output/two/static.txt new file mode 100644 index 0000000..7c29dd8 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-directories/output/two/static.txt @@ -0,0 +1 @@ +copied diff --git a/packages/generator/tests/fixtures/dynamic-files/output/one.js b/packages/generator/tests/fixtures/dynamic-files/output/one.js new file mode 100644 index 0000000..c26c006 --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-files/output/one.js @@ -0,0 +1,2 @@ + +console.log(1); diff --git a/packages/generator/tests/fixtures/dynamic-files/output/two.js b/packages/generator/tests/fixtures/dynamic-files/output/two.js new file mode 100644 index 0000000..b2b999c --- /dev/null +++ b/packages/generator/tests/fixtures/dynamic-files/output/two.js @@ -0,0 +1,2 @@ + +console.log(2); diff --git a/packages/generator/tests/fixtures/static/output/test.json b/packages/generator/tests/fixtures/static/output/test.json new file mode 100644 index 0000000..9c3634f --- /dev/null +++ b/packages/generator/tests/fixtures/static/output/test.json @@ -0,0 +1,2 @@ + +{ "value": 123 } diff --git a/packages/generator/tests/index.spec.ts b/packages/generator/tests/index.spec.ts index b7f2348..596ca1e 100644 --- a/packages/generator/tests/index.spec.ts +++ b/packages/generator/tests/index.spec.ts @@ -23,11 +23,7 @@ test("generates a static blot file", async () => { const result = await generate({ inputDir, outputDir }); - await expect(fs.readFile(path.join(outputDir, "test.json"), "utf8")).resolves.toBe( - ` -{ "value": 123 } -`, - ); + await expectOutputToMatchFixture(outputDir, "static"); expect(result.files).toMatchObject([ { outputPath: path.join(outputDir, "test.json"), @@ -42,16 +38,7 @@ test("generates dynamic file names from getPaths", async () => { 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); -`, - ); + await expectOutputToMatchFixture(outputDir, "dynamic-files"); }); test("generates dynamic directories from _paths.blot", async () => { @@ -60,16 +47,7 @@ test("generates dynamic directories from _paths.blot", async () => { 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\n"); + await expectOutputToMatchFixture(outputDir, "dynamic-directories"); }); test("skips existing files when requested", async () => { @@ -86,11 +64,55 @@ test("skips existing files when requested", async () => { existingFiles: "skip", }); - await expect(fs.readFile(path.join(outputDir, "test.txt"), "utf8")) - .resolves.toBe("existing\n"); + await expectOutputToMatchFixture(outputDir, "skip-existing"); expect(result.files).toMatchObject([{ action: "skipped" }]); }); +async function expectOutputToMatchFixture( + outputDir: string, + fixtureName: string, +): Promise { + const fixtureOutputDir = getFixtureOutputDir(fixtureName); + const outputFiles = await readTreeFilePaths(outputDir); + + expect(outputFiles).toEqual(await readTreeFilePaths(fixtureOutputDir)); + + for (const outputFile of outputFiles) { + await expect( + await fs.readFile(path.join(outputDir, outputFile), "utf8"), + ).toMatchFileSnapshot(path.join(fixtureOutputDir, outputFile)); + } +} + +async function readTreeFilePaths(rootDir: string): Promise { + const files: string[] = []; + await readTreeFilePathsInto(rootDir, rootDir, files); + return files; +} + +async function readTreeFilePathsInto( + rootDir: string, + currentDir: string, + files: string[], +): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + await readTreeFilePathsInto(rootDir, entryPath, files); + continue; + } + + if (entry.isFile()) { + files.push(path.relative(rootDir, entryPath)); + } + } +} + async function createOutputDir(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tempblot-generator-")); const outputDir = path.join(root, "output"); -- 2.51.2 From b72c77d3362b70bda4320bfc4c19d79bc9207b28 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:19:25 -0500 Subject: [PATCH 4/9] chore: remove ambient file --- packages/generator/src/tempblot.d.ts | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 packages/generator/src/tempblot.d.ts diff --git a/packages/generator/src/tempblot.d.ts b/packages/generator/src/tempblot.d.ts deleted file mode 100644 index d8637a0..0000000 --- a/packages/generator/src/tempblot.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module "tempblot" { - export function compilePath( - sourcePath: string, - params: TParams, - ): Promise; - - export function loadSetupPath( - sourcePath: string, - params: TParams, - ): Promise>; -} -- 2.51.2 From d0799729fa97202c9463954fb1d889aa60eb9f56 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:27:58 -0500 Subject: [PATCH 5/9] fix: handle no output or setup better --- packages/language-service/package.json | 9 +- .../language-service/src/language-plugin.ts | 93 +++++++------- .../language-service/src/service-plugin.ts | 119 +++++++++--------- .../tests/language-plugin.spec.ts | 79 ++++++++++++ packages/language-service/tsconfig.build.json | 9 ++ packages/language-service/tsconfig.json | 13 +- packages/language-service/tsconfig.spec.json | 8 ++ pnpm-lock.yaml | 3 + 8 files changed, 222 insertions(+), 111 deletions(-) create mode 100644 packages/language-service/tests/language-plugin.spec.ts create mode 100644 packages/language-service/tsconfig.build.json create mode 100644 packages/language-service/tsconfig.spec.json diff --git a/packages/language-service/package.json b/packages/language-service/package.json index 04ada26..e582163 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -36,8 +36,10 @@ "volar" ], "scripts": { - "build": "tsc --build", - "prepack": "tsc --build --clean && tsc --build", + "build": "tsc --build tsconfig.build.json", + "prepack": "tsc --build tsconfig.build.json --clean && tsc --build tsconfig.build.json", + "test:lib": "vitest", + "test:types": "tsc --noEmit --project tsconfig.spec.json", "test:eslint": "eslint ." }, "dependencies": { @@ -50,7 +52,8 @@ "devDependencies": { "@tempblot/config": "workspace:*", "@types/node": "^24.5.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.6" }, "sideEffects": false } diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index 8ca07d1..c8bd09f 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -130,25 +130,45 @@ function* getTempblotEmbeddedCodes( ): Generator { const setups = getRootBlocks(rootDocument, "setup"); const outputs = getRootBlocks(rootDocument, "output"); + const setup = setups[0]; + const output = outputs[0]; - // If we have both setup and output, combine them into a single TypeScript context - // This allows setup variables to be accessible in output interpolations - if (setups.length > 0 && outputs.length > 0) { - const setup = setups[0]; // Take the first setup block - const output = outputs[0]; // Take the first output block + // Combine setup and output interpolations into one TypeScript context so setup + // variables are visible from interpolation expressions when both exist. + if (setup || output) { + const base = `export {}; // Make this file a module\n\n`; + let combinedText = base; + const tsMappings: CodeMapping[] = []; - const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); - const outputText = snapshot.getText(output.startTagEnd, output.endTagStart); + if (setup) { + const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); + const setupGeneratedOffset = combinedText.length; + combinedText += setupText; - // Extract interpolation expressions and their positions from output - const interpolationsData = scanInterpolations(outputText); + tsMappings.push({ + sourceOffsets: [setup.startTagEnd], + generatedOffsets: [setupGeneratedOffset], + lengths: [setupText.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, + }); + } - // Create a combined TypeScript context wrapped in a module - // This ensures each .blot file has its own isolated scope - const base = `export {}; // Make this file a module\n\n`; - let combinedText = `${base}${setupText}\n\n// Output interpolations:\n`; + const outputText = output + ? snapshot.getText(output.startTagEnd, output.endTagStart) + : ""; + + const interpolationsData = output ? scanInterpolations(outputText) : []; + if (interpolationsData.length > 0) { + combinedText += `\n\n// Output interpolations:\n`; + } - const tsInterpolationMappings: CodeMapping[] = []; interpolationsData.forEach((interp) => { const interpLine = `(${interp.expression});\n`; const interpStartOffset = combinedText.length; @@ -156,7 +176,7 @@ function* getTempblotEmbeddedCodes( // Map the interpolation expression to the original source const expressionStart = interpStartOffset + `(`.length; - tsInterpolationMappings.push({ + tsMappings.push({ sourceOffsets: [output.startTagEnd + interp.sourceStart], generatedOffsets: [expressionStart], lengths: [interp.expression.length], @@ -171,34 +191,23 @@ function* getTempblotEmbeddedCodes( }); }); - yield { - id: "combined_context", - languageId: "typescript", - snapshot: { - getText: (start, end) => combinedText.substring(start, end), - getLength: () => combinedText.length, - getChangeRange: () => undefined, - }, - mappings: [ - // Mapping for setup block - { - sourceOffsets: [setup.startTagEnd], - generatedOffsets: [base.length], - lengths: [setupText.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, + if (tsMappings.length > 0) { + yield { + id: "combined_context", + languageId: "typescript", + snapshot: { + getText: (start, end) => combinedText.substring(start, end), + getLength: () => combinedText.length, + getChangeRange: () => undefined, }, - // Mappings for interpolation expressions - ...tsInterpolationMappings, - ], - embeddedCodes: [], - }; + mappings: tsMappings, + embeddedCodes: [], + }; + } + + if (!output) { + return; + } // Create JSON output with interpolations replaced by placeholder values const { transformedText, jsonMappings } = createJsonWithMappings( diff --git a/packages/language-service/src/service-plugin.ts b/packages/language-service/src/service-plugin.ts index f70b8f9..d1fb72f 100644 --- a/packages/language-service/src/service-plugin.ts +++ b/packages/language-service/src/service-plugin.ts @@ -4,12 +4,14 @@ import type { LanguageServicePlugin, LanguageServicePluginInstance, } from "@volar/language-service"; +import type { ParsedRoot } from "@tempblot/parser"; import { URI } from "vscode-uri"; import { TempblotVirtualCode } from "./language-plugin.ts"; type DiagnosticsDocument = Parameters< NonNullable >[0]; +type PositionAt = DiagnosticsDocument["positionAt"]; export function createTempblotServicePlugin(): LanguageServicePlugin { return { @@ -35,69 +37,70 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { if (!(virtualCode instanceof TempblotVirtualCode)) { return; } - const setupNodes = virtualCode.rootDocument.blocks.filter( - (root) => root.tag === "setup", - ); - const outputNodes = virtualCode.rootDocument.blocks.filter( - (root) => root.tag === "output", + return getTempblotRootDiagnostics( + virtualCode.rootDocument, + document.positionAt.bind(document), ); + }, + }; + }, + }; +} - if (setupNodes.length == 1 && outputNodes.length == 1) { - return; - } +export function getTempblotRootDiagnostics( + rootDocument: ParsedRoot, + positionAt: PositionAt, +): Diagnostic[] | undefined { + const setupNodes = rootDocument.blocks.filter((root) => root.tag === "setup"); + const outputNodes = rootDocument.blocks.filter( + (root) => root.tag === "output", + ); - const errors: Diagnostic[] = []; + if ( + setupNodes.length <= 1 && + outputNodes.length <= 1 && + setupNodes.length + outputNodes.length > 0 + ) { + return; + } - if (setupNodes.length === 0) { - errors.push({ - severity: 1, - range: { - start: document.positionAt(0), - end: document.positionAt(1), - }, - source: "tempblot", - message: "Missing setup tag.", - }); - } + const errors: Diagnostic[] = []; - if (outputNodes.length === 0) { - errors.push({ - severity: 1, - range: { - start: document.positionAt(0), - end: document.positionAt(1), - }, - source: "tempblot", - message: "Missing output tag.", - }); - } + if (setupNodes.length === 0 && outputNodes.length === 0) { + errors.push({ + severity: 1, + range: { + start: positionAt(0), + end: positionAt(1), + }, + source: "tempblot", + message: "Missing setup or output tag.", + }); + } - for (let i = 1; i < setupNodes.length; i++) { - errors.push({ - severity: 2, - range: { - start: document.positionAt(setupNodes[i].start), - end: document.positionAt(setupNodes[i].end), - }, - source: "tempblot", - message: "Only one setup tag is allowed.", - }); - } + for (let i = 1; i < setupNodes.length; i++) { + errors.push({ + severity: 2, + range: { + start: positionAt(setupNodes[i].start), + end: positionAt(setupNodes[i].end), + }, + source: "tempblot", + message: "Only one setup tag is allowed.", + }); + } - for (let i = 1; i < outputNodes.length; i++) { - errors.push({ - severity: 2, - range: { - start: document.positionAt(outputNodes[i].start), - end: document.positionAt(outputNodes[i].end), - }, - source: "tempblot", - message: "Only one output tag is allowed.", - }); - } - return errors; - }, - }; - }, - }; + for (let i = 1; i < outputNodes.length; i++) { + errors.push({ + severity: 2, + range: { + start: positionAt(outputNodes[i].start), + end: positionAt(outputNodes[i].end), + }, + source: "tempblot", + message: "Only one output tag is allowed.", + }); + } + + return errors; } diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts new file mode 100644 index 0000000..fe9a8b7 --- /dev/null +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "vitest"; +import { parseTempblotRoot } from "@tempblot/parser"; +import type * as ts from "typescript"; +import { TempblotVirtualCode } from "../src/language-plugin.ts"; +import { getTempblotRootDiagnostics } from "../src/service-plugin.ts"; + +function createVirtualCode(source: string): TempblotVirtualCode { + const snapshot = { + getText: (start, end) => source.substring(start, end), + getLength: () => source.length, + getChangeRange: () => undefined, + } satisfies ts.IScriptSnapshot; + + return new TempblotVirtualCode(snapshot); +} + +const positionAt = (offset: number) => ({ line: 0, character: offset }); + +test("creates TypeScript embedded code for setup-only files", () => { + const source = ` +const value: number = 1; +`; + + const virtualCode = createVirtualCode(source); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toContain("const value: number = 1;"); + expect(virtualCode.embeddedCodes.map((code) => code.id)).not.toContain( + "output_json", + ); +}); + +test("creates output embedded code for output-only files", () => { + const source = ` +{"value": <<1 + 1>>} +`; + + const virtualCode = createVirtualCode(source); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + const outputJson = virtualCode.embeddedCodes.find( + (code) => code.id === "output_json", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toContain("(1 + 1);"); + expect(outputJson?.snapshot.getText(0, outputJson.snapshot.getLength())) + .toContain('{"value": null}'); +}); + +test("does not report missing-block diagnostics for single-section files", () => { + const setupOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + positionAt, + ); + const outputOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + positionAt, + ); + + expect(setupOnlyDiagnostics).toBeUndefined(); + expect(outputOnlyDiagnostics).toBeUndefined(); +}); + +test("reports diagnostics when neither root section exists", () => { + expect( + getTempblotRootDiagnostics(parseTempblotRoot("plain text"), positionAt), + ).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing setup or output tag.", + }, + ]); +}); diff --git a/packages/language-service/tsconfig.build.json b/packages/language-service/tsconfig.build.json new file mode 100644 index 0000000..8da3e7d --- /dev/null +++ b/packages/language-service/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src"], + "references": [{ "path": "../parser/tsconfig.build.json" }], + "compilerOptions": { + "declaration": true, + "outDir": "lib" + } +} diff --git a/packages/language-service/tsconfig.json b/packages/language-service/tsconfig.json index 0072f8d..dcb77f1 100644 --- a/packages/language-service/tsconfig.json +++ b/packages/language-service/tsconfig.json @@ -1,10 +1,7 @@ { - "extends": "@tempblot/config/tsconfig.json", - "include": ["src"], - "references": [{ "path": "../parser/tsconfig.build.json" }], - - "compilerOptions": { - "declaration": true, - "outDir": "lib" - } + "files": [], + "references": [ + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.spec.json" } + ] } diff --git a/packages/language-service/tsconfig.spec.json b/packages/language-service/tsconfig.spec.json new file mode 100644 index 0000000..a7fc895 --- /dev/null +++ b/packages/language-service/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src", "tests"], + "references": [{ "path": "../parser/tsconfig.build.json" }], + "compilerOptions": { + "noEmit": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4770f42..7c7dd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + 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/parser: devDependencies: -- 2.51.2 From 3168aa39ab6fdd8e7c1b8934bf2913f3814fbf67 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:40:27 -0500 Subject: [PATCH 6/9] fix: handle edgecase where no interpolation occurs --- .../language-service/src/language-plugin.ts | 24 +++++++++---------- .../tests/language-plugin.spec.ts | 22 +++++++++++++++++ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index c8bd09f..5ed4856 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -191,19 +191,17 @@ function* getTempblotEmbeddedCodes( }); }); - if (tsMappings.length > 0) { - yield { - id: "combined_context", - languageId: "typescript", - snapshot: { - getText: (start, end) => combinedText.substring(start, end), - getLength: () => combinedText.length, - getChangeRange: () => undefined, - }, - mappings: tsMappings, - embeddedCodes: [], - }; - } + yield { + id: "combined_context", + languageId: "typescript", + snapshot: { + getText: (start, end) => combinedText.substring(start, end), + getLength: () => combinedText.length, + getChangeRange: () => undefined, + }, + mappings: tsMappings, + embeddedCodes: [], + }; if (!output) { return; diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts index fe9a8b7..1730ace 100644 --- a/packages/language-service/tests/language-plugin.spec.ts +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -52,6 +52,28 @@ test("creates output embedded code for output-only files", () => { .toContain('{"value": null}'); }); +test("creates empty TypeScript embedded code for output-only files without interpolations", () => { + const source = ` +{ + "test": "a" +} +`; + + const virtualCode = createVirtualCode(source); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + const outputJson = virtualCode.embeddedCodes.find( + (code) => code.id === "output_json", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toBe("export {}; // Make this file a module\n\n"); + expect(combinedContext?.mappings).toEqual([]); + expect(outputJson?.snapshot.getText(0, outputJson.snapshot.getLength())) + .toContain('"test": "a"'); +}); + test("does not report missing-block diagnostics for single-section files", () => { const setupOnlyDiagnostics = getTempblotRootDiagnostics( parseTempblotRoot(""), -- 2.51.2 From 8e8923272f9539511ffa047bbe785f74de80dce0 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:52:40 -0500 Subject: [PATCH 7/9] feat: handle _paths.blot files in LSP --- .../language-service/src/language-plugin.ts | 173 +++++++++--------- .../language-service/src/service-plugin.ts | 49 ++++- .../tests/language-plugin.spec.ts | 79 +++++++- 3 files changed, 202 insertions(+), 99 deletions(-) diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index 5ed4856..92b43cd 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -27,12 +27,12 @@ export function createTempblotLanguagePlugin( } }, createVirtualCode( - _uri: URI, + uri: URI, languageId: string, snapshot: ts.IScriptSnapshot, ) { if (languageId === "tempblot") { - return new TempblotVirtualCode(snapshot); + return new TempblotVirtualCode(snapshot, isPathBlotUri(uri)); } }, typescript: { @@ -87,6 +87,11 @@ function getCombinedContextCode(root: VirtualCode) { return root.embeddedCodes?.find((code) => code.id === "combined_context"); } +export function isPathBlotUri(uri: URI | string): boolean { + const path = typeof uri === "string" ? uri : uri.path; + return path.endsWith("/_paths.blot") || path.endsWith("\\_paths.blot"); +} + export class TempblotVirtualCode implements VirtualCode { id = "root"; languageId = "tempblot"; @@ -97,9 +102,11 @@ export class TempblotVirtualCode implements VirtualCode { rootDocument: ParsedRoot; snapshot: ts.IScriptSnapshot; + isPathFile: boolean; - constructor(snapshot: ts.IScriptSnapshot) { + constructor(snapshot: ts.IScriptSnapshot, isPathFile = false) { this.snapshot = snapshot; + this.isPathFile = isPathFile; this.mappings = [ { sourceOffsets: [0], @@ -135,98 +142,96 @@ function* getTempblotEmbeddedCodes( // Combine setup and output interpolations into one TypeScript context so setup // variables are visible from interpolation expressions when both exist. - if (setup || output) { - const base = `export {}; // Make this file a module\n\n`; - let combinedText = base; - const tsMappings: CodeMapping[] = []; + const base = `export {}; // Make this file a module\n\n`; + let combinedText = base; + const tsMappings: CodeMapping[] = []; - if (setup) { - const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); - const setupGeneratedOffset = combinedText.length; - combinedText += setupText; + if (setup) { + const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); + const setupGeneratedOffset = combinedText.length; + combinedText += setupText; - tsMappings.push({ - sourceOffsets: [setup.startTagEnd], - generatedOffsets: [setupGeneratedOffset], - lengths: [setupText.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, - }); - } + tsMappings.push({ + sourceOffsets: [setup.startTagEnd], + generatedOffsets: [setupGeneratedOffset], + lengths: [setupText.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, + }); + } - const outputText = output - ? snapshot.getText(output.startTagEnd, output.endTagStart) - : ""; + const outputText = output + ? snapshot.getText(output.startTagEnd, output.endTagStart) + : ""; - const interpolationsData = output ? scanInterpolations(outputText) : []; - if (interpolationsData.length > 0) { - combinedText += `\n\n// Output interpolations:\n`; - } + const interpolationsData = output ? scanInterpolations(outputText) : []; + if (interpolationsData.length > 0) { + combinedText += `\n\n// Output interpolations:\n`; + } - interpolationsData.forEach((interp) => { - const interpLine = `(${interp.expression});\n`; - const interpStartOffset = combinedText.length; - combinedText += interpLine; + interpolationsData.forEach((interp) => { + const interpLine = `(${interp.expression});\n`; + const interpStartOffset = combinedText.length; + combinedText += interpLine; - // Map the interpolation expression to the original source - const expressionStart = interpStartOffset + `(`.length; - tsMappings.push({ - sourceOffsets: [output.startTagEnd + interp.sourceStart], - generatedOffsets: [expressionStart], - lengths: [interp.expression.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, - }); + // Map the interpolation expression to the original source + const expressionStart = interpStartOffset + `(`.length; + tsMappings.push({ + sourceOffsets: [output.startTagEnd + interp.sourceStart], + generatedOffsets: [expressionStart], + lengths: [interp.expression.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, }); + }); - yield { - id: "combined_context", - languageId: "typescript", - snapshot: { - getText: (start, end) => combinedText.substring(start, end), - getLength: () => combinedText.length, - getChangeRange: () => undefined, - }, - mappings: tsMappings, - embeddedCodes: [], - }; + yield { + id: "combined_context", + languageId: "typescript", + snapshot: { + getText: (start, end) => combinedText.substring(start, end), + getLength: () => combinedText.length, + getChangeRange: () => undefined, + }, + mappings: tsMappings, + embeddedCodes: [], + }; - if (!output) { - return; - } + if (!output) { + return; + } - // Create JSON output with interpolations replaced by placeholder values - const { transformedText, jsonMappings } = createJsonWithMappings( - outputText, - interpolationsData, - output.startTagEnd, - ); + // Create JSON output with interpolations replaced by placeholder values + const { transformedText, jsonMappings } = createJsonWithMappings( + outputText, + interpolationsData, + output.startTagEnd, + ); - // TODO: Make generic and not tied to JSON - yield { - id: "output_json", - languageId: "json", - snapshot: { - getText: (start, end) => transformedText.substring(start, end), - getLength: () => transformedText.length, - getChangeRange: () => undefined, - }, - mappings: jsonMappings, - embeddedCodes: [], - }; - } + // TODO: Make generic and not tied to JSON + yield { + id: "output_json", + languageId: "json", + snapshot: { + getText: (start, end) => transformedText.substring(start, end), + getLength: () => transformedText.length, + getChangeRange: () => undefined, + }, + mappings: jsonMappings, + embeddedCodes: [], + }; } function createJsonWithMappings( diff --git a/packages/language-service/src/service-plugin.ts b/packages/language-service/src/service-plugin.ts index d1fb72f..fc3018e 100644 --- a/packages/language-service/src/service-plugin.ts +++ b/packages/language-service/src/service-plugin.ts @@ -39,6 +39,7 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { } return getTempblotRootDiagnostics( virtualCode.rootDocument, + virtualCode.isPathFile, document.positionAt.bind(document), ); }, @@ -49,6 +50,7 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { export function getTempblotRootDiagnostics( rootDocument: ParsedRoot, + isPathFile: boolean, positionAt: PositionAt, ): Diagnostic[] | undefined { const setupNodes = rootDocument.blocks.filter((root) => root.tag === "setup"); @@ -56,17 +58,38 @@ export function getTempblotRootDiagnostics( (root) => root.tag === "output", ); + const hasValidSetupCount = setupNodes.length <= 1; + const hasValidOutputCount = outputNodes.length === 1; + const hasValidPathOutputCount = outputNodes.length === 0; + if ( - setupNodes.length <= 1 && - outputNodes.length <= 1 && - setupNodes.length + outputNodes.length > 0 + isPathFile && + hasValidSetupCount && + setupNodes.length === 1 && + hasValidPathOutputCount ) { return; } + if (!isPathFile && hasValidSetupCount && hasValidOutputCount) { + return; + } + const errors: Diagnostic[] = []; - if (setupNodes.length === 0 && outputNodes.length === 0) { + if (isPathFile && setupNodes.length === 0) { + errors.push({ + severity: 1, + range: { + start: positionAt(0), + end: positionAt(1), + }, + source: "tempblot", + message: "Missing setup tag.", + }); + } + + if (!isPathFile && outputNodes.length === 0) { errors.push({ severity: 1, range: { @@ -74,7 +97,7 @@ export function getTempblotRootDiagnostics( end: positionAt(1), }, source: "tempblot", - message: "Missing setup or output tag.", + message: "Missing output tag.", }); } @@ -90,6 +113,22 @@ export function getTempblotRootDiagnostics( }); } + if (isPathFile) { + for (const outputNode of outputNodes) { + errors.push({ + severity: 1, + range: { + start: positionAt(outputNode.start), + end: positionAt(outputNode.end), + }, + source: "tempblot", + message: "Output tag is not allowed in path files.", + }); + } + + return errors; + } + for (let i = 1; i < outputNodes.length; i++) { errors.push({ severity: 2, diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts index 1730ace..08d7192 100644 --- a/packages/language-service/tests/language-plugin.spec.ts +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -1,27 +1,36 @@ import { expect, test } from "vitest"; import { parseTempblotRoot } from "@tempblot/parser"; import type * as ts from "typescript"; -import { TempblotVirtualCode } from "../src/language-plugin.ts"; +import { isPathBlotUri, TempblotVirtualCode } from "../src/language-plugin.ts"; import { getTempblotRootDiagnostics } from "../src/service-plugin.ts"; -function createVirtualCode(source: string): TempblotVirtualCode { +function createVirtualCode( + source: string, + isPathFile = false, +): TempblotVirtualCode { const snapshot = { getText: (start, end) => source.substring(start, end), getLength: () => source.length, getChangeRange: () => undefined, } satisfies ts.IScriptSnapshot; - return new TempblotVirtualCode(snapshot); + return new TempblotVirtualCode(snapshot, isPathFile); } const positionAt = (offset: number) => ({ line: 0, character: offset }); -test("creates TypeScript embedded code for setup-only files", () => { +test("detects path files from URI and TypeScript plugin file names", () => { + expect(isPathBlotUri("/project/_paths.blot")).toBe(true); + expect(isPathBlotUri("C:\\project\\_paths.blot")).toBe(true); + expect(isPathBlotUri("/project/_path.blot")).toBe(false); +}); + +test("creates TypeScript embedded code for setup-only path files", () => { const source = ` const value: number = 1; `; - const virtualCode = createVirtualCode(source); + const virtualCode = createVirtualCode(source, true); const combinedContext = virtualCode.embeddedCodes.find( (code) => code.id === "combined_context", ); @@ -74,28 +83,78 @@ test("creates empty TypeScript embedded code for output-only files without inter .toContain('"test": "a"'); }); -test("does not report missing-block diagnostics for single-section files", () => { +test("creates empty TypeScript embedded code for files without root sections", () => { + const virtualCode = createVirtualCode("plain text"); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toBe("export {}; // Make this file a module\n\n"); + expect(combinedContext?.mappings).toEqual([]); +}); + +test("requires output for regular files", () => { + expect( + getTempblotRootDiagnostics( + parseTempblotRoot(""), + false, + positionAt, + ), + ).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing output tag.", + }, + ]); +}); + +test("allows output-only regular files", () => { + const outputOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + false, + positionAt, + ); + + expect(outputOnlyDiagnostics).toBeUndefined(); +}); + +test("requires setup and rejects output for path files", () => { const setupOnlyDiagnostics = getTempblotRootDiagnostics( parseTempblotRoot(""), + true, positionAt, ); const outputOnlyDiagnostics = getTempblotRootDiagnostics( parseTempblotRoot(""), + true, positionAt, ); expect(setupOnlyDiagnostics).toBeUndefined(); - expect(outputOnlyDiagnostics).toBeUndefined(); + expect(outputOnlyDiagnostics).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing setup tag.", + }, + { + severity: 1, + source: "tempblot", + message: "Output tag is not allowed in path files.", + }, + ]); }); -test("reports diagnostics when neither root section exists", () => { +test("reports diagnostics when regular files have no output", () => { expect( - getTempblotRootDiagnostics(parseTempblotRoot("plain text"), positionAt), + getTempblotRootDiagnostics(parseTempblotRoot("plain text"), false, positionAt), ).toMatchObject([ { severity: 1, source: "tempblot", - message: "Missing setup or output tag.", + message: "Missing output tag.", }, ]); }); -- 2.51.2 From 0993283888ed52c9c2039b014c13ef823ae0903f Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:57:39 -0500 Subject: [PATCH 8/9] fix: handle non-JSON outputs a bit better --- .../language-service/src/language-plugin.ts | 71 +++++++++++++++---- .../tests/language-plugin.spec.ts | 47 ++++++++++-- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index 92b43cd..abc0fc6 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -213,36 +213,64 @@ function* getTempblotEmbeddedCodes( return; } - // Create JSON output with interpolations replaced by placeholder values - const { transformedText, jsonMappings } = createJsonWithMappings( + const outputLanguageId = getOutputLanguageId(output.attributes.lang); + const { transformedText, mappings } = createOutputWithMappings( outputText, interpolationsData, output.startTagEnd, + outputLanguageId, ); - // TODO: Make generic and not tied to JSON yield { - id: "output_json", - languageId: "json", + id: "output", + languageId: outputLanguageId, snapshot: { getText: (start, end) => transformedText.substring(start, end), getLength: () => transformedText.length, getChangeRange: () => undefined, }, - mappings: jsonMappings, + mappings, embeddedCodes: [], }; } -function createJsonWithMappings( +function getOutputLanguageId(lang: string | undefined): string { + switch (lang) { + case undefined: + case "html": + return "html"; + case "md": + return "markdown"; + case "js": + return "javascript"; + case "jsx": + return "javascriptreact"; + case "ts": + return "typescript"; + case "tsx": + return "typescriptreact"; + case "txt": + return "plaintext"; + case "gql": + return "graphql"; + case "coffee": + return "coffeescript"; + default: + return lang; + } +} + +function createOutputWithMappings( outputText: string, interpolationsData: InterpolationData[], outputStartOffset: number, -): { transformedText: string; jsonMappings: CodeMapping[] } { + languageId: string, +): { transformedText: string; mappings: CodeMapping[] } { const mappings: CodeMapping[] = []; let transformedText = ""; let lastOffset = 0; let generatedOffset = 0; + const interpolationPlaceholder = getInterpolationPlaceholder(languageId); // Process each interpolation for (const interp of interpolationsData) { @@ -266,10 +294,8 @@ function createJsonWithMappings( generatedOffset += beforeText.length; } - // Replace interpolation with null placeholder for JSON validity - const placeholder = "null"; - transformedText += placeholder; - generatedOffset += placeholder.length; + transformedText += interpolationPlaceholder; + generatedOffset += interpolationPlaceholder.length; lastOffset = interp.fullEnd; } @@ -293,5 +319,24 @@ function createJsonWithMappings( transformedText += remainingText; } - return { transformedText, jsonMappings: mappings }; + return { transformedText, mappings }; +} + +function getInterpolationPlaceholder(languageId: string): string { + switch (languageId) { + case "json": + case "jsonc": + case "json5": + return "null"; + case "css": + case "scss": + case "less": + case "sass": + case "stylus": + case "postcss": + case "toml": + return "0"; + default: + return "tempblot"; + } } diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts index 08d7192..08e074a 100644 --- a/packages/language-service/tests/language-plugin.spec.ts +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -38,7 +38,7 @@ const value: number = 1; expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) .toContain("const value: number = 1;"); expect(virtualCode.embeddedCodes.map((code) => code.id)).not.toContain( - "output_json", + "output", ); }); @@ -51,16 +51,49 @@ test("creates output embedded code for output-only files", () => { const combinedContext = virtualCode.embeddedCodes.find( (code) => code.id === "combined_context", ); - const outputJson = virtualCode.embeddedCodes.find( - (code) => code.id === "output_json", + const output = virtualCode.embeddedCodes.find( + (code) => code.id === "output", ); expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) .toContain("(1 + 1);"); - expect(outputJson?.snapshot.getText(0, outputJson.snapshot.getLength())) + expect(output?.languageId).toBe("json"); + expect(output?.snapshot.getText(0, output.snapshot.getLength())) .toContain('{"value": null}'); }); +test("creates output embedded code from non-JSON output languages", () => { + const source = ` +

<></h1> +</output>`; + + const virtualCode = createVirtualCode(source); + const output = virtualCode.embeddedCodes.find((code) => code.id === "output"); + + expect(output?.languageId).toBe("html"); + expect(output?.snapshot.getText(0, output.snapshot.getLength())) + .toContain("<h1>tempblot</h1>"); +}); + +test("normalizes output language aliases", () => { + const markdownCode = createVirtualCode( + `<output lang="md"># <<title>></output>`, + ); + const javascriptCode = createVirtualCode( + `<output lang="js">const value = <<value>>;</output>`, + ); + + const markdownOutput = markdownCode.embeddedCodes.find( + (code) => code.id === "output", + ); + const javascriptOutput = javascriptCode.embeddedCodes.find( + (code) => code.id === "output", + ); + + expect(markdownOutput?.languageId).toBe("markdown"); + expect(javascriptOutput?.languageId).toBe("javascript"); +}); + test("creates empty TypeScript embedded code for output-only files without interpolations", () => { const source = `<output lang="json"> { @@ -72,14 +105,14 @@ test("creates empty TypeScript embedded code for output-only files without inter const combinedContext = virtualCode.embeddedCodes.find( (code) => code.id === "combined_context", ); - const outputJson = virtualCode.embeddedCodes.find( - (code) => code.id === "output_json", + const output = virtualCode.embeddedCodes.find( + (code) => code.id === "output", ); expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) .toBe("export {}; // Make this file a module\n\n"); expect(combinedContext?.mappings).toEqual([]); - expect(outputJson?.snapshot.getText(0, outputJson.snapshot.getLength())) + expect(output?.snapshot.getText(0, output.snapshot.getLength())) .toContain('"test": "a"'); }); -- 2.51.2 From 3ba85713265093e0e875f336fa9b0ec460705f1c Mon Sep 17 00:00:00 2001 From: Corbin Crutchley <git@crutchcorn.dev> Date: Thu, 14 May 2026 08:59:46 -0500 Subject: [PATCH 9/9] chore: fix CI --- knip.json | 2 +- packages/language-service/tsconfig.spec.json | 2 +- packages/language-service/vite.config.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 packages/language-service/vite.config.ts diff --git a/knip.json b/knip.json index fd4b703..21a12ff 100644 --- a/knip.json +++ b/knip.json @@ -7,5 +7,5 @@ } }, "ignoreDependencies": ["sherif", "uuid", "vsce"], - "ignoreFiles": ["sample/index.ts", "packages/config/tsignore.ts"] + "ignoreFiles": ["sample/index.ts", "packages/config/tsignore.ts", "**/fixtures/**"] } diff --git a/packages/language-service/tsconfig.spec.json b/packages/language-service/tsconfig.spec.json index a7fc895..2ef1c80 100644 --- a/packages/language-service/tsconfig.spec.json +++ b/packages/language-service/tsconfig.spec.json @@ -1,6 +1,6 @@ { "extends": "@tempblot/config/tsconfig.json", - "include": ["src", "tests"], + "include": ["src", "tests", "vite.config.ts"], "references": [{ "path": "../parser/tsconfig.build.json" }], "compilerOptions": { "noEmit": true diff --git a/packages/language-service/vite.config.ts b/packages/language-service/vite.config.ts new file mode 100644 index 0000000..f6832d0 --- /dev/null +++ b/packages/language-service/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import packageJson from "./package.json" with { type: "json" }; + +export default defineConfig(({ mode }) => ({ + test: { + name: packageJson.name, + dir: "./tests", + watch: false, + }, + define: { + "import.meta.vitest": mode !== "production", + }, +}));