reverse cli command
This commit is contained in:
23
README.md
23
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
|
## 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
77
cli.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
1
index.ts
1
index.ts
@@ -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
|
||||||
|
|||||||
@@ -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
158
reverse.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user