5 Commits

Author SHA1 Message Date
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
Gregor Lohaus
8ad2545310 fix build error
All checks were successful
Publish npm package / publish (push) Successful in 24s
2026-05-22 10:05:34 +02:00
Gregor Lohaus
4d25d07687 run on pc
Some checks failed
Publish npm package / publish (push) Failing after 3m9s
2026-05-22 09:59:36 +02:00
8 changed files with 639 additions and 69 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: local runs-on: x86
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

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,22 @@ 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.
## 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,44 @@ 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: false,
text: "test"
}
}, { reverseMap: true })
writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\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(2)
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(true)
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "debug.tmp"))).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.5",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

303
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,7 +139,7 @@ function addReverseMapToken(
state.manifest.tokens[token.result] = tokens state.manifest.tokens[token.result] = tokens
} }
function getReverseMapPath(destRoot: string, reverseMap: boolean | 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 +190,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 = {
type: "text"
text: string
}
type IfBranch = {
type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
type IfNode = {
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
}
}
function getDirectiveTokens(content: string): DirectiveToken[] {
return Array.from(content.matchAll(DIRECTIVE_RE), match => ({
type: match[1] as DirectiveToken["type"],
condition: match[2],
index: match.index!,
end: match.index! + match[0].length,
}))
}
function parseNodes(
content: string,
tokens: DirectiveToken[],
tokenIndex: number,
pos: number,
stopTypes: DirectiveToken["type"][],
): {
nodes: TemplateNode[]
pos: number
tokenIndex: number
stop?: DirectiveToken
} {
const nodes: TemplateNode[] = []
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex]!
if (stopTypes.includes(token.type)) {
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
return { nodes, pos: token.index, tokenIndex, stop: token }
}
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 (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) })
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 = "" let result = ""
let pos = 0
const stack: IfFrame[] = []
const re = new RegExp(DIRECTIVE_RE.source, "g") for (const node of nodes) {
let match: RegExpExecArray | null if (node.type === "text") {
result += node.text
function isEmitting(): boolean { continue
return stack.every(f => f.active)
} }
while ((match = re.exec(content)) !== null) { const activeBranch = getActiveBranch(node, context)
const directive = match[1]! const start = outputStart + result.length
const condPath = match[2] const renderedBranch = activeBranch
? renderNodes(activeBranch.nodes, context, conditionalTokens, start)
if (directive === "if") { : ""
if (isEmitting()) result += content.slice(pos, match.index) result += renderedBranch
const truthy = evalCondition(condPath, context) conditionalTokens.push({
stack.push({ matched: truthy, active: truthy, sawElse: false }) result: renderedBranch,
pos = re.lastIndex token: node.source,
} else if (directive === "elseif") { range: { start, end: start + renderedBranch.length },
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") activeRange: activeBranch
const top = stack[stack.length - 1]! ? {
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>") start: activeBranch.contentStart - node.sourceStart,
// Emit text before this directive if current branch was active end: activeBranch.contentEnd - node.sourceStart,
if (isEmitting()) result += content.slice(pos, match.index)
if (top.matched) {
// A previous branch already matched — skip this one
top.active = false
} else {
const truthy = evalCondition(condPath, context)
top.matched = truthy
top.active = truthy
}
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
} }
: { start: node.source.length, end: node.source.length },
})
} }
if (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
}
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 +384,22 @@ function renderContentWithMap(
state?: RenderState, state?: RenderState,
file?: ReverseMapFile, file?: ReverseMapFile,
): string { ): string {
const processed = processIfBlocks(content, context) 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 +470,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 +522,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 +547,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,160 @@ 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 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, current = root): string[] {
const files: string[] = []
for (const entry of readdirSync(current).sort()) {
const path = resolvePath(current, entry)
const stat = statSync(path)
if (stat.isDirectory()) {
files.push(...walkFiles(root, path))
} else if (stat.isFile()) {
files.push(normalizePath(relative(root, path)))
}
}
return files
}
function copyIncludedRenderedFiles(
renderedRoot: string,
templateRoot: string,
mapPath: string,
manifest: ReverseMapManifest,
include: ReverseOptions["include"],
): number {
const matchers = getIncludeMatchers(include)
if (matchers.length === 0) return 0
const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath)))
const mapRelativePath = normalizePath(relative(renderedRoot, mapPath))
const directoryMap = buildDirectoryMap(manifest)
let filesWritten = 0
for (const outputPath of walkFiles(renderedRoot)) {
if (outputPath === mapRelativePath) continue
if (mappedOutputPaths.has(outputPath)) continue
if (!matchesAny(outputPath, matchers)) continue
const renderedPath = resolveInside(renderedRoot, outputPath)
const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap))
mkdirSync(dirname(templatePath), { recursive: true })
copyFileSync(renderedPath, templatePath)
filesWritten += 1
}
return filesWritten
}
export function reverseDir( export function reverseDir(
renderedDir: string, renderedDir: string,
templateDir: string, templateDir: string,
@@ -154,5 +408,17 @@ export function reverseDir(
filesWritten += 1 filesWritten += 1
} }
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
mapPath,
manifest,
options.include,
)
return { filesWritten, warnings } return { filesWritten, warnings }
} }