6 Commits

Author SHA1 Message Date
Gregor Lohaus
e16fc8b482 verison bump, new files need to include var tokens
All checks were successful
Publish npm package / publish (push) Successful in 26s
2026-05-24 15:05:22 +02:00
Gregor Lohaus
7d01b2f7c9 version bump
All checks were successful
Publish npm package / publish (push) Successful in 22s
2026-05-24 14:58:04 +02:00
Gregor Lohaus
0412cea241 ignore output in include globs 2026-05-24 14:57:44 +02:00
Gregor Lohaus
0a512cdbc3 reverse accepts new file paths via globs
All checks were successful
Publish npm package / publish (push) Successful in 22s
2026-05-24 14:46:07 +02:00
Gregor Lohaus
2971c87618 version bump
All checks were successful
Publish npm package / publish (push) Successful in 23s
2026-05-24 12:21:57 +02:00
Gregor Lohaus
af0c25e64b full template restoreablity
Some checks failed
Publish npm package / publish (push) Failing after 21s
2026-05-24 12:17:12 +02:00
7 changed files with 720 additions and 67 deletions

View File

@@ -142,7 +142,7 @@ Pass a string to choose a custom JSON path inside the output directory:
render("./output", context, { reverseMap: "meta/reverse-map.json" }) render("./output", context, { reverseMap: "meta/reverse-map.json" })
``` ```
The map contains a flat lookup from rendered strings to template tokens plus per-file occurrences with path/range context: The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals:
```json ```json
{ {
@@ -160,10 +160,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per
"outputPath": "web/index.html", "outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html", "templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 16, "end": 21 } "range": { "start": 16, "end": 21 }
},
{
"kind": "conditional",
"result": "<head><@var(context.header.title)></head>",
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 9, "end": 53 },
"activeRange": { "start": 27, "end": 71 }
} }
] ]
} }
], ],
"skipped": [
{
"kind": "file",
"templatePath": "<@if(context.docs)>docs/readme.md",
"encoding": "utf8",
"content": "# Docs"
}
],
"tokens": { "tokens": {
"Hello": ["<@var(context.header.title)>"] "Hello": ["<@var(context.header.title)>"]
} }
@@ -191,7 +208,30 @@ tdir reverse ./output ./templates --map meta/reverse-map.json
bunx @gregorlohaus/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. New files created in the rendered directory are ignored by default. Include them explicitly with one or more glob patterns:
```sh
tdir reverse ./output ./templates --include "components/**"
tdir reverse ./output ./templates --include "components/**/*.ts" --include "pages/*.html"
```
Programmatically, pass the same globs to `reverseDir`:
```ts
reverseDir("./output", "./templates", {
include: ["components/**/*.ts", "pages/*.html"]
})
```
The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals.
The template output directory may be inside the rendered directory, for example:
```sh
tdir reverse ./ ./reversed --include "components/**"
```
When the template output directory is inside the rendered directory, reverse snapshots included files before writing and excludes the output directory from include glob matching.
## Unmatched directives ## Unmatched directives

21
cli.ts
View File

@@ -5,7 +5,7 @@ function printHelp() {
console.log(`tdir console.log(`tdir
Usage: Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>] tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
Commands: Commands:
reverse Rebuild template files from a rendered directory and reverse map reverse Rebuild template files from a rendered directory and reverse map
@@ -13,18 +13,20 @@ Commands:
Options: Options:
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json. --map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
Relative paths are resolved from <rendered-dir>. Relative paths are resolved from <rendered-dir>.
--include Include new rendered files matching a glob. Can be repeated.
--help Show this help message. --help Show this help message.
`) `)
} }
function parseReverseArgs(args: string[]) { function parseReverseArgs(args: string[]) {
const positional: string[] = [] const positional: string[] = []
const include: string[] = []
let mapPath: string | undefined let mapPath: string | undefined
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]! const arg = args[i]!
if (arg === "--help" || arg === "-h") { if (arg === "--help" || arg === "-h") {
return { help: true, positional, mapPath } return { help: true, positional, mapPath, include }
} }
if (arg === "--map") { if (arg === "--map") {
const value = args[++i] const value = args[++i]
@@ -32,10 +34,16 @@ function parseReverseArgs(args: string[]) {
mapPath = value mapPath = value
continue continue
} }
if (arg === "--include") {
const value = args[++i]
if (!value) throw new Error("Missing value for --include")
include.push(value)
continue
}
positional.push(arg) positional.push(arg)
} }
return { help: false, positional, mapPath } return { help: false, positional, mapPath, include }
} }
function main(argv: string[]) { function main(argv: string[]) {
@@ -58,10 +66,13 @@ function main(argv: string[]) {
const [renderedDir, templateDir] = parsed.positional const [renderedDir, templateDir] = parsed.positional
if (!renderedDir || !templateDir || parsed.positional.length > 2) { if (!renderedDir || !templateDir || parsed.positional.length > 2) {
throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>]") throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]")
} }
const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath }) const result = reverseDir(renderedDir, templateDir, {
mapPath: parsed.mapPath,
include: parsed.include,
})
console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`) console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`)
for (const warning of result.warnings) { for (const warning of result.warnings) {
console.warn(`Warning: ${warning.outputPath}: ${warning.message}`) console.warn(`Warning: ${warning.outputPath}: ${warning.message}`)

View File

@@ -66,6 +66,17 @@ test("render can write a reverse map", () => {
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html")) const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
expect(file).toBeDefined() expect(file).toBeDefined()
expect(file.tokens).toContainEqual({
kind: "conditional",
result: expect.stringContaining("<@var(context.header.text)>"),
token: expect.stringContaining("<@if(context.header.render)>"),
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
activeRange: expect.any(Object),
before: expect.any(String),
after: expect.any(String),
})
expect(file.tokens).toContainEqual({ expect(file.tokens).toContainEqual({
kind: "content", kind: "content",
result: "My Title", result: "My Title",
@@ -134,10 +145,37 @@ test("reverseDir rebuilds templates from rendered output and map", () => {
expect(result.warnings).toEqual([]) expect(result.warnings).toEqual([])
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8") const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8")
expect(reversed).toContain("<@if(context.header.render)>")
expect(reversed).toContain("<@endif>")
expect(reversed).toContain("<@var(context.header.text)>") expect(reversed).toContain("<@var(context.header.text)>")
expect(reversed).toContain("Edited rendered output") expect(reversed).toContain("Edited rendered output")
}) })
test("reverseDir restores files skipped by path conditionals", () => {
const createRenderer = initRenderer("./testdata/file_if")
const render = createRenderer(z.object({
web: z.boolean(),
file: z.boolean(),
text: z.string()
}))
const renderedOut = join(tmp, "rendered")
const templateOut = join(tmp, "template")
render(renderedOut,{
web: true,
file: false,
text: "test"
}, { reverseMap: true })
expect(existsSync(join(renderedOut, "web", "example.txt"))).toBe(false)
const result = reverseDir(renderedOut, templateOut)
expect(result.filesWritten).toBe(1)
const restoredPath = join(templateOut, "<@if(context.web)>web", "<@if(context.file)>example.txt")
expect(existsSync(restoredPath)).toBe(true)
expect(readFileSync(restoredPath, "utf-8")).toContain("<@var(context.text)>")
})
test("reverseDir supports custom map paths", () => { test("reverseDir supports custom map paths", () => {
const createRenderer = initRenderer("./testdata/var_in_path") const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({ const render = createRenderer(z.object({
@@ -169,6 +207,60 @@ test("reverseDir supports custom map paths", () => {
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "var_in_path_example.html"))).toBe(true) expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "var_in_path_example.html"))).toBe(true)
}) })
test("reverseDir only includes new rendered files matching include globs", () => {
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")
const ignoredOut = join(tmp, "ignored-template")
render(renderedOut,{
web: {
create: true,
dir: "components"
},
header: {
render: true,
text: "test"
}
}, { reverseMap: true })
writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\n")
writeFileSync(join(renderedOut, "components", "title.ts"), "export const title = 'test'\n")
writeFileSync(join(renderedOut, "components", "debug.tmp"), "debug\n")
reverseDir(renderedOut, ignoredOut)
expect(existsSync(join(ignoredOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(false)
const result = reverseDir(renderedOut, templateOut, { include: ["components/**/*.ts"] })
expect(result.filesWritten).toBe(3)
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(true)
expect(readFileSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "title.ts"), "utf-8")).toContain("<@var(context.header.text)>")
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "debug.tmp"))).toBe(false)
})
test("reverseDir can write templates inside rendered output without including its own writes", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
const renderedOut = join(tmp, "rendered")
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
writeFileSync(join(renderedOut, "new.html"), "<main>new</main>\n")
const result = reverseDir(renderedOut, join(renderedOut, "reversed"), { include: ["**/*"] })
expect(result.warnings).toEqual([])
expect(existsSync(join(renderedOut, "reversed", "<@if(context.web)>web", "if_example.html"))).toBe(true)
expect(existsSync(join(renderedOut, "reversed", "new.html"))).toBe(true)
expect(existsSync(join(renderedOut, "reversed", "reversed", "new.html"))).toBe(false)
})
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

@@ -2,7 +2,7 @@ import { z } from "zod"
import { parse, type TemplateVariable } from "./parser" 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, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse" export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
interface Stringable { interface Stringable {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gregorlohaus/tdir", "name": "@gregorlohaus/tdir",
"version": "0.1.3", "version": "0.1.7",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

312
render.ts
View File

@@ -18,7 +18,7 @@ const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/ const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = { export type ReverseMapToken = {
kind: "path" | "content" kind: "path" | "content" | "conditional"
result: string result: string
token: string token: string
contextPath?: string contextPath?: string
@@ -28,6 +28,12 @@ export type ReverseMapToken = {
start: number start: number
end: number end: number
} }
activeRange?: {
start: number
end: number
}
before?: string
after?: string
} }
export type ReverseMapFile = { export type ReverseMapFile = {
@@ -36,9 +42,17 @@ export type ReverseMapFile = {
tokens: ReverseMapToken[] tokens: ReverseMapToken[]
} }
export type ReverseMapStoredTemplate = {
kind: "directory" | "file"
templatePath: string
encoding?: "utf8" | "base64"
content?: string
}
export type ReverseMapManifest = { export type ReverseMapManifest = {
version: 1 version: 1
files: ReverseMapFile[] files: ReverseMapFile[]
skipped: ReverseMapStoredTemplate[]
tokens: Record<string, string[]> tokens: Record<string, string[]>
} }
@@ -110,7 +124,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
} }
function createReverseMapManifest(): ReverseMapManifest { function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], tokens: {} } return { version: 1, files: [], skipped: [], tokens: {} }
} }
function addReverseMapToken( function addReverseMapToken(
@@ -125,6 +139,17 @@ function addReverseMapToken(
state.manifest.tokens[token.result] = tokens state.manifest.tokens[token.result] = tokens
} }
function addFlatToken(
state: RenderState | undefined,
result: string,
token: string,
) {
if (!state?.manifest) return
const tokens = state.manifest.tokens[result] ?? []
if (!tokens.includes(token)) tokens.push(token)
state.manifest.tokens[result] = tokens
}
function getReverseMapPath(destRoot: string, reverseMap: true | string): string { function getReverseMapPath(destRoot: string, reverseMap: true | string): string {
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json") if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
return resolveOutputPath(destRoot, reverseMap) return resolveOutputPath(destRoot, reverseMap)
@@ -176,70 +201,190 @@ function isUtf8Text(buffer: Buffer): boolean {
} }
} }
// Each stack frame tracks: did any branch match yet, is the current branch active type DirectiveToken = {
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean } type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
function processIfBlocks(content: string, context: Record<string, unknown>): string { type TextNode = {
let result = "" type: "text"
let pos = 0 text: string
const stack: IfFrame[] = [] }
const re = new RegExp(DIRECTIVE_RE.source, "g") type IfBranch = {
let match: RegExpExecArray | null type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
function isEmitting(): boolean { type IfNode = {
return stack.every(f => f.active) type: "if"
sourceStart: number
sourceEnd: number
source: string
branches: IfBranch[]
}
type TemplateNode = TextNode | IfNode
type ConditionalRender = {
result: string
token: string
range: {
start: number
end: number
} }
activeRange: {
start: number
end: number
}
}
while ((match = re.exec(content)) !== null) { function getDirectiveTokens(content: string): DirectiveToken[] {
const directive = match[1]! return Array.from(content.matchAll(DIRECTIVE_RE), match => ({
const condPath = match[2] type: match[1] as DirectiveToken["type"],
condition: match[2],
index: match.index!,
end: match.index! + match[0].length,
}))
}
if (directive === "if") { function parseNodes(
if (isEmitting()) result += content.slice(pos, match.index) content: string,
const truthy = evalCondition(condPath, context) tokens: DirectiveToken[],
stack.push({ matched: truthy, active: truthy, sawElse: false }) tokenIndex: number,
pos = re.lastIndex pos: number,
} else if (directive === "elseif") { stopTypes: DirectiveToken["type"][],
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") ): {
const top = stack[stack.length - 1]! nodes: TemplateNode[]
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>") pos: number
// Emit text before this directive if current branch was active tokenIndex: number
if (isEmitting()) result += content.slice(pos, match.index) stop?: DirectiveToken
if (top.matched) { } {
// A previous branch already matched — skip this one const nodes: TemplateNode[] = []
top.active = false
} else { while (tokenIndex < tokens.length) {
const truthy = evalCondition(condPath, context) const token = tokens[tokenIndex]!
top.matched = truthy if (stopTypes.includes(token.type)) {
top.active = truthy if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
} return { nodes, pos: token.index, tokenIndex, stop: token }
pos = re.lastIndex
} else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched
top.matched = true
top.sawElse = true
pos = re.lastIndex
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
if (isEmitting()) result += content.slice(pos, match.index)
stack.pop()
pos = re.lastIndex
} }
if (token.type !== "if") {
throw new Error(`Unexpected <@${token.type}> without <@if>`)
}
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
const parsed = parseIfNode(content, tokens, tokenIndex)
nodes.push(parsed.node)
tokenIndex = parsed.tokenIndex
pos = parsed.pos
} }
if (stack.length > 0) { if (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) })
throw new Error("Unmatched <@if> without <@endif>") return { nodes, pos: content.length, tokenIndex }
}
function parseIfNode(content: string, tokens: DirectiveToken[], tokenIndex: number) {
const firstToken = tokens[tokenIndex]!
const sourceStart = firstToken.index
const branches: IfBranch[] = []
let branchType: IfBranch["type"] = "if"
let branchCondition = firstToken.condition
let branchContentStart = firstToken.end
tokenIndex += 1
while (true) {
const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"])
if (!parsed.stop) throw new Error("Unmatched <@if> without <@endif>")
branches.push({
type: branchType,
condition: branchCondition,
nodes: parsed.nodes,
contentStart: branchContentStart,
contentEnd: parsed.pos,
})
if (parsed.stop.type === "endif") {
const sourceEnd = parsed.stop.end
return {
node: {
type: "if" as const,
sourceStart,
sourceEnd,
source: content.slice(sourceStart, sourceEnd),
branches,
},
pos: sourceEnd,
tokenIndex: parsed.tokenIndex + 1,
}
}
branchType = parsed.stop.type
branchCondition = parsed.stop.condition
branchContentStart = parsed.stop.end
tokenIndex = parsed.tokenIndex + 1
}
}
function getActiveBranch(node: IfNode, context: Record<string, unknown>): IfBranch | undefined {
for (const branch of node.branches) {
if (branch.type === "else" || evalCondition(branch.condition, context)) return branch
}
return undefined
}
function renderNodes(
nodes: TemplateNode[],
context: Record<string, unknown>,
conditionalTokens: ConditionalRender[],
outputStart = 0,
): string {
let result = ""
for (const node of nodes) {
if (node.type === "text") {
result += node.text
continue
}
const activeBranch = getActiveBranch(node, context)
const start = outputStart + result.length
const renderedBranch = activeBranch
? renderNodes(activeBranch.nodes, context, conditionalTokens, start)
: ""
result += renderedBranch
conditionalTokens.push({
result: renderedBranch,
token: node.source,
range: { start, end: start + renderedBranch.length },
activeRange: activeBranch
? {
start: activeBranch.contentStart - node.sourceStart,
end: activeBranch.contentEnd - node.sourceStart,
}
: { start: node.source.length, end: node.source.length },
})
} }
result += content.slice(pos)
return result return result
} }
function processIfBlocksWithMap(content: string, context: Record<string, unknown>) {
const tokens = getDirectiveTokens(content)
const parsed = parseNodes(content, tokens, 0, 0, [])
const conditionalTokens: ConditionalRender[] = []
return {
content: renderNodes(parsed.nodes, context, conditionalTokens),
conditionalTokens,
}
}
function renderContent(content: string, context: Record<string, unknown>): string { function renderContent(content: string, context: Record<string, unknown>): string {
return renderContentWithMap(content, context) return renderContentWithMap(content, context)
} }
@@ -250,7 +395,28 @@ function renderContentWithMap(
state?: RenderState, state?: RenderState,
file?: ReverseMapFile, file?: ReverseMapFile,
): string { ): string {
const processed = processIfBlocks(content, context) for (const match of content.matchAll(VAR_RE)) {
const token = match[0]
const path = match[1]!
addFlatToken(state, String(resolveContext(context, path) ?? ""), token)
}
const processedResult = processIfBlocksWithMap(content, context)
const processed = processedResult.content
for (const token of processedResult.conditionalTokens) {
addReverseMapToken(state, file, {
kind: "conditional",
result: token.result,
token: token.token,
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
range: token.range,
activeRange: token.activeRange,
before: processed.slice(0, token.range.start),
after: processed.slice(token.range.end),
})
}
let result = "" let result = ""
let pos = 0 let pos = 0
@@ -321,6 +487,39 @@ function validateOutputPaths(
} }
} }
function storeSkippedTemplate(srcPath: string, state: RenderState) {
if (!state.manifest) return
const stat = statSync(srcPath)
const templatePath = relative(state.sourceRoot, srcPath)
if (stat.isDirectory()) {
state.manifest.skipped.push({ kind: "directory", templatePath })
for (const entry of readdirSync(srcPath).sort()) {
storeSkippedTemplate(resolvePath(srcPath, entry), state)
}
return
}
if (!stat.isFile()) return
const content = readFileSync(srcPath)
if (isUtf8Text(content)) {
state.manifest.skipped.push({
kind: "file",
templatePath,
encoding: "utf8",
content: content.toString("utf-8"),
})
} else {
state.manifest.skipped.push({
kind: "file",
templatePath,
encoding: "base64",
content: content.toString("base64"),
})
}
}
function renderDirInner( function renderDirInner(
srcDir: string, srcDir: string,
destDir: string, destDir: string,
@@ -340,7 +539,10 @@ function renderDirInner(
tokens: [], tokens: [],
} }
const outputName = getOutputName(entry, context, state, tempFile) const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) continue if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
}
const destPath = resolveOutputPath(destDir, outputName) const destPath = resolveOutputPath(destDir, outputName)
const outputPath = relative(state.destRoot, destPath) const outputPath = relative(state.destRoot, destPath)
@@ -362,7 +564,7 @@ function renderDirInner(
} else { } else {
copyFileSync(srcPath, destPath) copyFileSync(srcPath, destPath)
} }
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile) if (state.manifest) state.manifest.files.push(tempFile)
} }
} }
} }

View File

@@ -3,15 +3,17 @@ import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
readdirSync,
statSync, statSync,
writeFileSync, writeFileSync,
} from "node:fs" } from "node:fs"
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { TextDecoder } from "node:util" import { TextDecoder } from "node:util"
import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export type ReverseOptions = { export type ReverseOptions = {
mapPath?: string mapPath?: string
include?: string | string[]
} }
export type ReverseWarning = { export type ReverseWarning = {
@@ -57,6 +59,51 @@ function readManifest(mapPath: string): ReverseMapManifest {
return manifest return manifest
} }
function normalizePath(path: string): string {
return path.split("\\").join("/")
}
function escapeRegExp(text: string): string {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
}
function globToRegExp(glob: string): RegExp {
let source = "^"
const pattern = normalizePath(glob)
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]!
const next = pattern[i + 1]
if (char === "*" && next === "*") {
if (pattern[i + 2] === "/") {
source += "(?:.*/)?"
i += 2
} else {
source += ".*"
i += 1
}
} else if (char === "*") {
source += "[^/]*"
} else if (char === "?") {
source += "[^/]"
} else {
source += escapeRegExp(char)
}
}
return new RegExp(`${source}$`)
}
function getIncludeMatchers(include: ReverseOptions["include"]): RegExp[] {
if (!include) return []
return (Array.isArray(include) ? include : [include]).map(globToRegExp)
}
function matchesAny(path: string, matchers: RegExp[]): boolean {
const normalized = normalizePath(path)
return matchers.some(matcher => matcher.test(normalized))
}
function replaceAtRange(content: string, token: ReverseMapToken): string | null { function replaceAtRange(content: string, token: ReverseMapToken): string | null {
if (!token.range) return null if (!token.range) return null
const { start, end } = token.range const { start, end } = token.range
@@ -71,17 +118,73 @@ function replaceFirst(content: string, token: ReverseMapToken): string | null {
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}` return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`
} }
function applyActiveBranch(token: ReverseMapToken, branchContent: string): string {
if (!token.activeRange) return token.token
return [
token.token.slice(0, token.activeRange.start),
branchContent,
token.token.slice(token.activeRange.end),
].join("")
}
function findPrefixEnd(content: string, before: string): number {
if (before === "") return 0
let candidate = before
while (candidate.length >= 8) {
const index = content.indexOf(candidate)
if (index !== -1) return index + candidate.length
candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)))
}
return -1
}
function findSuffixStart(content: string, after: string, from: number): number {
if (after === "") return content.length
let candidate = after
while (candidate.length >= 8) {
const index = content.indexOf(candidate, from)
if (index !== -1) return index
candidate = candidate.slice(0, Math.floor(candidate.length / 2))
}
return -1
}
function replaceConditional(content: string, token: ReverseMapToken): string | null {
const exactIndex = content.indexOf(token.result)
if (exactIndex !== -1) {
return [
content.slice(0, exactIndex),
token.token,
content.slice(exactIndex + token.result.length),
].join("")
}
if (token.before === undefined || token.after === undefined) return null
const branchStart = findPrefixEnd(content, token.before)
if (branchStart === -1) return null
const afterIndex = findSuffixStart(content, token.after, branchStart)
if (afterIndex === -1) return null
return [
content.slice(0, branchStart),
applyActiveBranch(token, content.slice(branchStart, afterIndex)),
content.slice(afterIndex),
].join("")
}
function reverseContent( function reverseContent(
content: string, content: string,
file: ReverseMapFile, file: ReverseMapFile,
warnings: ReverseWarning[], warnings: ReverseWarning[],
): string { ): string {
const tokens = file.tokens const contentTokens = file.tokens
.filter(token => token.kind === "content") .filter(token => token.kind === "content")
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
let reversed = content let reversed = content
for (const token of tokens) { for (const token of contentTokens) {
const rangeResult = replaceAtRange(reversed, token) const rangeResult = replaceAtRange(reversed, token)
if (rangeResult !== null) { if (rangeResult !== null) {
reversed = rangeResult reversed = rangeResult
@@ -101,9 +204,194 @@ function reverseContent(
message: "Rendered value was not found; token was not restored", message: "Rendered value was not found; token was not restored",
}) })
} }
const conditionalTokens = file.tokens
.filter(token => token.kind === "conditional")
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
for (const token of conditionalTokens) {
const result = replaceConditional(reversed, token)
if (result !== null) {
reversed = result
continue
}
warnings.push({
outputPath: file.outputPath,
token: token.token,
result: token.result,
message: "Rendered conditional block was not found; block was not restored",
})
}
return reversed return reversed
} }
function writeSkippedTemplate(
templateRoot: string,
skipped: ReverseMapStoredTemplate,
): number {
const templatePath = resolveInside(templateRoot, skipped.templatePath)
if (skipped.kind === "directory") {
mkdirSync(templatePath, { recursive: true })
return 0
}
mkdirSync(dirname(templatePath), { recursive: true })
const content = skipped.encoding === "base64"
? Buffer.from(skipped.content ?? "", "base64")
: skipped.content ?? ""
writeFileSync(templatePath, content)
return 1
}
function replaceFlatTokens(content: string, manifest: ReverseMapManifest): string {
const entries = Object.entries(manifest.tokens)
.filter(([, tokens]) => tokens.length > 0)
.sort(([a], [b]) => b.length - a.length)
let result = content
for (const [rendered, tokens] of entries) {
if (rendered === "") continue
result = result.split(rendered).join(tokens[0]!)
}
return result
}
function dirnamePath(path: string): string {
const normalized = normalizePath(path)
const index = normalized.lastIndexOf("/")
return index === -1 ? "" : normalized.slice(0, index)
}
function basenamePath(path: string): string {
const normalized = normalizePath(path)
const index = normalized.lastIndexOf("/")
return index === -1 ? normalized : normalized.slice(index + 1)
}
function joinPath(...parts: string[]): string {
return parts
.filter(part => part !== "")
.join("/")
}
function buildDirectoryMap(manifest: ReverseMapManifest): Map<string, string> {
const mappings = new Map<string, string>([["", ""]])
function addMapping(outputDir: string, templateDir: string) {
const outputParts = normalizePath(outputDir).split("/").filter(Boolean)
const templateParts = normalizePath(templateDir).split("/").filter(Boolean)
for (let i = 1; i <= outputParts.length; i++) {
if (i <= templateParts.length) {
mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/"))
}
}
mappings.set(normalizePath(outputDir), normalizePath(templateDir))
}
for (const file of manifest.files) {
const output = normalizePath(file.outputPath)
const template = normalizePath(file.templatePath)
addMapping(dirnamePath(output), dirnamePath(template))
if (file.tokens.some(token => token.kind === "path")) addMapping(output, template)
}
for (const skipped of manifest.skipped ?? []) {
if (skipped.kind === "directory") continue
addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath))
}
return mappings
}
function inferTemplatePath(outputPath: string, directoryMap: Map<string, string>): string {
const normalized = normalizePath(outputPath)
const outputDir = dirnamePath(normalized)
let bestOutputDir = ""
let bestTemplateDir = ""
for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) {
if (
mappedOutputDir.length >= bestOutputDir.length
&& (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`))
) {
bestOutputDir = mappedOutputDir
bestTemplateDir = mappedTemplateDir
}
}
const suffix = bestOutputDir === ""
? outputDir
: outputDir.slice(bestOutputDir.length).replace(/^\//, "")
return joinPath(bestTemplateDir, suffix, basenamePath(normalized))
}
function walkFiles(root: string, excludedRoots: string[] = []): string[] {
const files: string[] = []
const pending = [root]
while (pending.length > 0) {
const current = pending.pop()!
for (const entry of readdirSync(current).sort()) {
const path = resolvePath(current, entry)
if (excludedRoots.some(excluded => isInsidePath(excluded, path))) continue
const stat = statSync(path)
if (stat.isDirectory()) {
pending.push(path)
} else if (stat.isFile()) {
files.push(normalizePath(relative(root, path)))
}
}
}
return files
}
function copyIncludedRenderedFiles(
renderedRoot: string,
templateRoot: string,
includedOutputPaths: string[],
directoryMap: Map<string, string>,
manifest: ReverseMapManifest,
): number {
let filesWritten = 0
for (const outputPath of includedOutputPaths) {
const renderedPath = resolveInside(renderedRoot, outputPath)
const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap))
mkdirSync(dirname(templatePath), { recursive: true })
const content = readFileSync(renderedPath)
if (isUtf8Text(content)) {
writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest))
} else {
copyFileSync(renderedPath, templatePath)
}
filesWritten += 1
}
return filesWritten
}
function getIncludedRenderedFiles(
renderedRoot: string,
templateRoot: string,
mapPath: string,
manifest: ReverseMapManifest,
include: ReverseOptions["include"],
): string[] {
const matchers = getIncludeMatchers(include)
if (matchers.length === 0) return []
const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath)))
const mapRelativePath = normalizePath(relative(renderedRoot, mapPath))
return walkFiles(renderedRoot, [templateRoot]).filter(outputPath => {
return outputPath !== mapRelativePath
&& !mappedOutputPaths.has(outputPath)
&& matchesAny(outputPath, matchers)
})
}
export function reverseDir( export function reverseDir(
renderedDir: string, renderedDir: string,
templateDir: string, templateDir: string,
@@ -115,6 +403,14 @@ export function reverseDir(
? resolvePath(renderedRoot, options.mapPath) ? resolvePath(renderedRoot, options.mapPath)
: resolvePath(renderedRoot, ".tdir-map.json") : resolvePath(renderedRoot, ".tdir-map.json")
const manifest = readManifest(mapPath) const manifest = readManifest(mapPath)
const directoryMap = buildDirectoryMap(manifest)
const includedOutputPaths = getIncludedRenderedFiles(
renderedRoot,
templateRoot,
mapPath,
manifest,
options.include,
)
const warnings: ReverseWarning[] = [] const warnings: ReverseWarning[] = []
let filesWritten = 0 let filesWritten = 0
@@ -154,5 +450,17 @@ export function reverseDir(
filesWritten += 1 filesWritten += 1
} }
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
includedOutputPaths,
directoryMap,
manifest,
)
return { filesWritten, warnings } return { filesWritten, warnings }
} }