From 28a2a771e8939f8636326625ba76eadcbc1c289c Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 22 May 2026 09:09:56 +0200 Subject: [PATCH] reverse cli command --- README.md | 23 +++++++ cli.ts | 77 +++++++++++++++++++++ index.test.ts | 58 +++++++++++++++- index.ts | 1 + package.json | 5 +- reverse.ts | 158 ++++++++++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 2 +- 7 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 cli.ts create mode 100644 reverse.ts diff --git a/README.md b/README.md index a87d91d..9c2d04e 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,29 @@ The map contains a flat lookup from rendered strings to template tokens plus per } ``` +## Reverse CLI + +Use the reverse map to rebuild template files from an edited rendered directory: + +```sh +tdir reverse ./output ./templates +``` + +Without installing the package first, run the published CLI through Bun: + +```sh +bunx @gregorlohaus/tdir reverse ./output ./templates +``` + +By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory: + +```sh +tdir reverse ./output ./templates --map meta/reverse-map.json +bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json +``` + +The command writes files at their original template paths and restores recorded `<@var(...)>` tokens in file contents and file paths. It does not infer conditional blocks that were removed during rendering; keep the original template structure when those blocks need to be preserved. + ## Unmatched directives A `<@if>` without a matching `<@endif>` throws when the renderer is initialized: diff --git a/cli.ts b/cli.ts new file mode 100644 index 0000000..aa04767 --- /dev/null +++ b/cli.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { reverseDir } from "./reverse" + +function printHelp() { + console.log(`tdir + +Usage: + tdir reverse [--map ] + +Commands: + reverse Rebuild template files from a rendered directory and reverse map + +Options: + --map Reverse map path. Defaults to /.tdir-map.json. + Relative paths are resolved from . + --help Show this help message. +`) +} + +function parseReverseArgs(args: string[]) { + const positional: string[] = [] + let mapPath: string | undefined + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === "--help" || arg === "-h") { + return { help: true, positional, mapPath } + } + if (arg === "--map") { + const value = args[++i] + if (!value) throw new Error("Missing value for --map") + mapPath = value + continue + } + positional.push(arg) + } + + return { help: false, positional, mapPath } +} + +function main(argv: string[]) { + const [command, ...args] = argv + + if (!command || command === "--help" || command === "-h") { + printHelp() + return 0 + } + + if (command !== "reverse") { + throw new Error(`Unknown command: ${command}`) + } + + const parsed = parseReverseArgs(args) + if (parsed.help) { + printHelp() + return 0 + } + + const [renderedDir, templateDir] = parsed.positional + if (!renderedDir || !templateDir || parsed.positional.length > 2) { + throw new Error("Usage: tdir reverse [--map ]") + } + + const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath }) + console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`) + for (const warning of result.warnings) { + console.warn(`Warning: ${warning.outputPath}: ${warning.message}`) + } + return result.warnings.length > 0 ? 2 : 0 +} + +try { + process.exitCode = main(process.argv.slice(2)) +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +} diff --git a/index.test.ts b/index.test.ts index 34ae9b1..bf319ac 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, beforeEach, afterEach } from 'bun:test' -import { initRenderer, SchemaMismatchError } from "./index"; +import { initRenderer, reverseDir, SchemaMismatchError } from "./index"; import { z } from "zod" import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs" import { basename, join } from "node:path" @@ -113,6 +113,62 @@ test("reverse map records path variable tokens", () => { }) }) +test("reverseDir rebuilds templates from rendered output and map", () => { + const createRenderer = initRenderer("./testdata/if_example") + const render = createRenderer(ifExampleSchema) + const templateOut = join(tmp, "template") + const renderedOut = join(tmp, "rendered") + + render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true }) + writeFileSync(join(renderedOut, "web", "if_example.html"), [ + "", + " My Title ", + " ", + "
Edited rendered output
", + " ", + "
", + ].join("\n")) + + const result = reverseDir(renderedOut, templateOut) + expect(result.filesWritten).toBe(1) + expect(result.warnings).toEqual([]) + + const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8") + expect(reversed).toContain("<@var(context.header.text)>") + expect(reversed).toContain("Edited rendered output") +}) + +test("reverseDir supports custom map paths", () => { + const createRenderer = initRenderer("./testdata/var_in_path") + const render = createRenderer(z.object({ + web: z.object({ + create: z.boolean(), + dir: z.string() + }), + header: z.object({ + render: z.boolean(), + text: z.string() + }) + })) + const renderedOut = join(tmp, "rendered") + const templateOut = join(tmp, "template") + + render(renderedOut,{ + web: { + create: true, + dir: "web" + }, + header: { + render: true, + text: "test" + } + }, { reverseMap: "meta/reverse-map.json" }) + + const result = reverseDir(renderedOut, templateOut, { mapPath: "meta/reverse-map.json" }) + expect(result.filesWritten).toBe(1) + expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "var_in_path_example.html"))).toBe(true) +}) + test("wrong schema throws error",() => { const createRenderer = initRenderer("./testdata/if_example") expect(() => createRenderer(z.object({ diff --git a/index.ts b/index.ts index fe11462..033c854 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import { parse, type TemplateVariable } from "./parser" import { renderDir, type RenderOptions } from "./render" export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" +export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse" interface Stringable { toString: () => string diff --git a/package.json b/package.json index f1bda41..46fa58f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "tdir": "./dist/cli.js" + }, "exports": { ".": { "import": "./dist/index.js", @@ -13,7 +16,7 @@ }, "files": ["dist"], "scripts": { - "build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json", + "build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json", "test": "bun test" }, "devDependencies": { diff --git a/reverse.ts b/reverse.ts new file mode 100644 index 0000000..ab26a2a --- /dev/null +++ b/reverse.ts @@ -0,0 +1,158 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs" +import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" +import { TextDecoder } from "node:util" +import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" + +export type ReverseOptions = { + mapPath?: string +} + +export type ReverseWarning = { + outputPath: string + token: string + result: string + message: string +} + +export type ReverseResult = { + filesWritten: number + warnings: ReverseWarning[] +} + +function isInsidePath(parent: string, child: string): boolean { + const rel = relative(parent, child) + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)) +} + +function resolveInside(root: string, path: string): string { + const resolved = resolvePath(root, path) + if (!isInsidePath(root, resolved)) { + throw new Error(`Refusing to write outside target directory: ${path}`) + } + return resolved +} + +function isUtf8Text(buffer: Buffer): boolean { + if (buffer.indexOf(0) !== -1) return false + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer) + return true + } catch { + return false + } +} + +function readManifest(mapPath: string): ReverseMapManifest { + const manifest = JSON.parse(readFileSync(mapPath, "utf-8")) as ReverseMapManifest + if (manifest.version !== 1 || !Array.isArray(manifest.files)) { + throw new Error(`Unsupported reverse map: ${mapPath}`) + } + return manifest +} + +function replaceAtRange(content: string, token: ReverseMapToken): string | null { + if (!token.range) return null + const { start, end } = token.range + if (start < 0 || end < start || end > content.length) return null + if (content.slice(start, end) !== token.result) return null + return `${content.slice(0, start)}${token.token}${content.slice(end)}` +} + +function replaceFirst(content: string, token: ReverseMapToken): string | null { + const index = content.indexOf(token.result) + if (index === -1) return null + return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}` +} + +function reverseContent( + content: string, + file: ReverseMapFile, + warnings: ReverseWarning[], +): string { + const tokens = file.tokens + .filter(token => token.kind === "content") + .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) + + let reversed = content + for (const token of tokens) { + const rangeResult = replaceAtRange(reversed, token) + if (rangeResult !== null) { + reversed = rangeResult + continue + } + + const fallbackResult = replaceFirst(reversed, token) + if (fallbackResult !== null) { + reversed = fallbackResult + continue + } + + warnings.push({ + outputPath: file.outputPath, + token: token.token, + result: token.result, + message: "Rendered value was not found; token was not restored", + }) + } + return reversed +} + +export function reverseDir( + renderedDir: string, + templateDir: string, + options: ReverseOptions = {}, +): ReverseResult { + const renderedRoot = resolvePath(renderedDir) + const templateRoot = resolvePath(templateDir) + const mapPath = options.mapPath + ? resolvePath(renderedRoot, options.mapPath) + : resolvePath(renderedRoot, ".tdir-map.json") + const manifest = readManifest(mapPath) + const warnings: ReverseWarning[] = [] + let filesWritten = 0 + + for (const file of manifest.files) { + const renderedPath = resolveInside(renderedRoot, file.outputPath) + const templatePath = resolveInside(templateRoot, file.templatePath) + + if (!existsSync(renderedPath)) { + warnings.push({ + outputPath: file.outputPath, + token: "", + result: "", + message: "Rendered path does not exist; skipped", + }) + continue + } + + const stat = statSync(renderedPath) + if (stat.isDirectory()) { + mkdirSync(templatePath, { recursive: true }) + continue + } + if (!stat.isFile()) continue + + mkdirSync(dirname(templatePath), { recursive: true }) + const content = readFileSync(renderedPath) + if (!isUtf8Text(content)) { + copyFileSync(renderedPath, templatePath) + filesWritten += 1 + continue + } + + writeFileSync( + templatePath, + reverseContent(content.toString("utf-8"), file, warnings), + ) + filesWritten += 1 + } + + return { filesWritten, warnings } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index b5ad206..e820b8c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,5 +6,5 @@ "emitDeclarationOnly": true, "outDir": "./dist" }, - "include": ["index.ts", "parser.ts", "render.ts"] + "include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"] }