reverse cli command

This commit is contained in:
Gregor Lohaus
2026-05-22 09:09:56 +02:00
parent bda6e8cc40
commit 28a2a771e8
7 changed files with 321 additions and 3 deletions

View File

@@ -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 ## Unmatched directives
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized: A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:

77
cli.ts Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import { reverseDir } from "./reverse"
function printHelp() {
console.log(`tdir
Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>]
Commands:
reverse Rebuild template files from a rendered directory and reverse map
Options:
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
Relative paths are resolved from <rendered-dir>.
--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 <rendered-dir> <template-dir> [--map <path>]")
}
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
}

View File

@@ -1,5 +1,5 @@
import { expect, test, beforeEach, afterEach } from 'bun:test' import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, SchemaMismatchError } from "./index"; import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
import { z } from "zod" import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs" import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { basename, join } from "node:path" 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"), [
"<document>",
" <head> My Title </head>",
" <body>",
" <main>Edited rendered output</main>",
" </body>",
"</document>",
].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",() => { test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example") const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({ expect(() => createRenderer(z.object({

View File

@@ -3,6 +3,7 @@ import { parse, type TemplateVariable } from "./parser"
import { renderDir, type RenderOptions } from "./render" import { renderDir, type RenderOptions } from "./render"
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
interface Stringable { interface Stringable {
toString: () => string toString: () => string

View File

@@ -5,6 +5,9 @@
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"bin": {
"tdir": "./dist/cli.js"
},
"exports": { "exports": {
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
@@ -13,7 +16,7 @@
}, },
"files": ["dist"], "files": ["dist"],
"scripts": { "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" "test": "bun test"
}, },
"devDependencies": { "devDependencies": {

158
reverse.ts Normal file
View File

@@ -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 }
}

View File

@@ -6,5 +6,5 @@
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["index.ts", "parser.ts", "render.ts"] "include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"]
} }