10 Commits

Author SHA1 Message Date
Gregor Lohaus
fe3489e217 version bump
All checks were successful
Publish npm package / publish (push) Successful in 21s
2026-05-24 16:03:45 +02:00
Gregor Lohaus
9ed1324e06 dry stuff up 2026-05-24 16:03:21 +02:00
Gregor Lohaus
d8536d83de replace regex based directive scanner 2026-05-24 16:00:48 +02:00
Gregor Lohaus
0fab9c8d38 neq support
All checks were successful
Publish npm package / publish (push) Successful in 1m24s
2026-05-24 15:46:25 +02:00
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
15 changed files with 947 additions and 84 deletions

View File

@@ -60,6 +60,9 @@ render("./output", {
|---|---|
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
| `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check |
| `<@if(and(context.x,eq(context.y,"value")))>` | Conditional block — all child conditions must match |
| `<@if(or(context.x,eq(context.y,"value")))>` | Conditional block — any child condition may match |
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
| `<@else>` | Else branch |
| `<@endif>` | End conditional block |
@@ -72,6 +75,9 @@ render("./output", {
|---|---|
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
| `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality |
| `<@if(and(context.x,eq(context.y,"value")))>dirname` | Conditionally include by combined conditions |
| `<@if(or(context.x,eq(context.y,"value")))>dirname` | Conditionally include by alternate conditions |
| `<@var(context.x)>` | Dynamic directory/file name |
These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true.
@@ -142,7 +148,7 @@ Pass a string to choose a custom JSON path inside the output directory:
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
{
@@ -160,10 +166,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"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": {
"Hello": ["<@var(context.header.title)>"]
}
@@ -191,7 +214,30 @@ 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

21
cli.ts
View File

@@ -5,7 +5,7 @@ function printHelp() {
console.log(`tdir
Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>]
tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
Commands:
reverse Rebuild template files from a rendered directory and reverse map
@@ -13,18 +13,20 @@ Commands:
Options:
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
Relative paths are resolved from <rendered-dir>.
--include Include new rendered files matching a glob. Can be repeated.
--help Show this help message.
`)
}
function parseReverseArgs(args: string[]) {
const positional: string[] = []
const include: 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 }
return { help: true, positional, mapPath, include }
}
if (arg === "--map") {
const value = args[++i]
@@ -32,10 +34,16 @@ function parseReverseArgs(args: string[]) {
mapPath = value
continue
}
if (arg === "--include") {
const value = args[++i]
if (!value) throw new Error("Missing value for --include")
include.push(value)
continue
}
positional.push(arg)
}
return { help: false, positional, mapPath }
return { help: false, positional, mapPath, include }
}
function main(argv: string[]) {
@@ -58,10 +66,13 @@ function main(argv: string[]) {
const [renderedDir, templateDir] = parsed.positional
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"}`)
for (const warning of result.warnings) {
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"))
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({
kind: "content",
result: "My Title",
@@ -134,10 +145,37 @@ test("reverseDir rebuilds templates from rendered output and map", () => {
expect(result.warnings).toEqual([])
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("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", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
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)
})
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",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
@@ -476,3 +568,90 @@ test("if eq in path", () => {
render(tmp,{test:"foo"})
expect(existsSync(join(tmp,"test"))).toBe(false)
})
test("if neq in file", () => {
const createRenderer = initRenderer("./testdata/neq_in_file")
expect(() => createRenderer(z.object({test: z.boolean()}))).toThrow(SchemaMismatchError)
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"foo"})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("not-test")
render(tmp,{test:"test"})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("test")
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("not-test")
})
test("if neq in path", () => {
const createRenderer = initRenderer("./testdata/neq_in_path")
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"foo"})
expect(existsSync(join(tmp,"not-test"))).toBe(true)
render(tmp,{test:"test"})
expect(existsSync(join(tmp,"not-test"))).toBe(false)
})
test("if neq multiline block", () => {
const createRenderer = initRenderer("./testdata/neq_multiline_block")
const render = createRenderer(z.object({
project: z.object({
frontend: z.string()
})
}))
render(tmp,{project:{frontend:"react"}})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("bun dev")
render(tmp,{project:{frontend:"none"}})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("bun dev")
})
test("and or in file", () => {
const createRenderer = initRenderer("./testdata/and_or_in_file")
expect(() => createRenderer(z.object({
enabled: z.boolean(),
fallback: z.boolean(),
kind: z.boolean()
}))).toThrow(SchemaMismatchError)
const render = createRenderer(z.object({
enabled: z.boolean(),
fallback: z.boolean(),
kind: z.string()
}))
render(tmp,{enabled:true, fallback:false, kind:"web"})
let content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("and")
expect(content).toContain("or")
expect(content).toContain("nested")
render(tmp,{enabled:false, fallback:true, kind:"web"})
content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("not-and")
expect(content).toContain("not-or")
expect(content).toContain("nested")
render(tmp,{enabled:false, fallback:true, kind:"docs"})
content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("not-and")
expect(content).toContain("or")
expect(content).toContain("nested")
})
test("and or in path", () => {
const createRenderer = initRenderer("./testdata/and_or_in_path")
const render = createRenderer(z.object({
enabled: z.boolean(),
kind: z.string()
}))
render(tmp,{enabled:true, kind:"web"})
expect(existsSync(join(tmp,"match"))).toBe(true)
render(tmp,{enabled:true, kind:"docs"})
expect(existsSync(join(tmp,"match"))).toBe(true)
render(tmp,{enabled:false, kind:"docs"})
expect(existsSync(join(tmp,"match"))).toBe(false)
})

View File

@@ -2,7 +2,7 @@ import { z } from "zod"
import { parse, type TemplateVariable } from "./parser"
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"
interface Stringable {

View File

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

View File

@@ -1,23 +1,31 @@
import { readdirSync, statSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { TextDecoder } from "node:util"
import { getDirectiveTokens, splitArgs } from "./scanner"
export type TemplateVariable = {
path: string
type: string
}
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
if (!expr) throw new Error("Missing condition expression")
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
vars.push({ path: eqMatch[1]!, type: "string" })
for (const operator of ["and", "or"]) {
const prefix = `${operator}(`
if (expr.startsWith(prefix) && expr.endsWith(")")) {
const args = splitArgs(expr.slice(prefix.length, -1))
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
for (const arg of args) extractCondition(arg, vars)
return
}
}
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
if (stringCompareMatch) {
vars.push({ path: stringCompareMatch[1]!, type: "string" })
return
}
const pathMatch = expr.match(PATH_RE)
@@ -29,8 +37,10 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
}
function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) {
extractCondition(match[1]!, vars)
for (const token of getDirectiveTokens(text)) {
if (token.type === "if" || token.type === "elseif") {
extractCondition(token.condition, vars)
}
}
for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" })
@@ -40,9 +50,9 @@ function extractFromString(text: string, vars: TemplateVariable[]) {
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
const stack: { sawElse: boolean }[] = []
for (const match of content.matchAll(DIRECTIVE_RE)) {
const directive = match[1]!
const condition = match[2]
for (const token of getDirectiveTokens(content)) {
const directive = token.type
const condition = token.condition
if (directive === "if") {
extractCondition(condition!, vars)

317
render.ts
View File

@@ -10,15 +10,15 @@ import {
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { homedir } from "node:os"
import { TextDecoder } from "node:util"
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = {
kind: "path" | "content"
kind: "path" | "content" | "conditional"
result: string
token: string
contextPath?: string
@@ -28,6 +28,12 @@ export type ReverseMapToken = {
start: number
end: number
}
activeRange?: {
start: number
end: number
}
before?: string
after?: string
}
export type ReverseMapFile = {
@@ -36,9 +42,17 @@ export type ReverseMapFile = {
tokens: ReverseMapToken[]
}
export type ReverseMapStoredTemplate = {
kind: "directory" | "file"
templatePath: string
encoding?: "utf8" | "base64"
content?: string
}
export type ReverseMapManifest = {
version: 1
files: ReverseMapFile[]
skipped: ReverseMapStoredTemplate[]
tokens: Record<string, string[]>
}
@@ -54,9 +68,20 @@ type RenderState = {
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
if (!expr) throw new Error("Missing condition expression")
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
for (const operator of ["and", "or"] as const) {
const prefix = `${operator}(`
if (expr.startsWith(prefix) && expr.endsWith(")")) {
const args = splitArgs(expr.slice(prefix.length, -1))
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
return operator === "and"
? args.every(arg => evalCondition(arg, context))
: args.some(arg => evalCondition(arg, context))
}
}
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
if (stringCompareMatch) {
const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3]
return stringCompareMatch[1] === "eq" ? result : !result
}
const pathMatch = expr.match(PATH_RE)
if (pathMatch) {
@@ -110,7 +135,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
}
function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], tokens: {} }
return { version: 1, files: [], skipped: [], tokens: {} }
}
function addReverseMapToken(
@@ -125,6 +150,17 @@ function addReverseMapToken(
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 {
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
return resolveOutputPath(destRoot, reverseMap)
@@ -176,70 +212,174 @@ function isUtf8Text(buffer: Buffer): boolean {
}
}
// Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
type TextNode = {
type: "text"
text: string
}
function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = ""
let pos = 0
const stack: IfFrame[] = []
type IfBranch = {
type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
const re = new RegExp(DIRECTIVE_RE.source, "g")
let match: RegExpExecArray | null
type IfNode = {
type: "if"
sourceStart: number
sourceEnd: number
source: string
branches: IfBranch[]
}
function isEmitting(): boolean {
return stack.every(f => f.active)
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) {
const directive = match[1]!
const condPath = match[2]
function parseNodes(
content: string,
tokens: DirectiveToken[],
tokenIndex: number,
pos: number,
stopTypes: DirectiveToken["type"][],
): {
nodes: TemplateNode[]
pos: number
tokenIndex: number
stop?: DirectiveToken
} {
const nodes: TemplateNode[] = []
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
const truthy = evalCondition(condPath, context)
stack.push({ matched: truthy, active: truthy, sawElse: false })
pos = re.lastIndex
} else if (directive === "elseif") {
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
// Emit text before this directive if current branch was active
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
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 (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
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 = ""
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
}
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 {
return renderContentWithMap(content, context)
}
@@ -250,7 +390,28 @@ function renderContentWithMap(
state?: RenderState,
file?: ReverseMapFile,
): 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 pos = 0
@@ -321,6 +482,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(
srcDir: string,
destDir: string,
@@ -340,7 +534,10 @@ function renderDirInner(
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) continue
if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
}
const destPath = resolveOutputPath(destDir, outputName)
const outputPath = relative(state.destRoot, destPath)
@@ -362,7 +559,7 @@ function renderDirInner(
} else {
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,
mkdirSync,
readFileSync,
readdirSync,
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"
import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export type ReverseOptions = {
mapPath?: string
include?: string | string[]
}
export type ReverseWarning = {
@@ -57,6 +59,51 @@ function readManifest(mapPath: string): ReverseMapManifest {
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 {
if (!token.range) return null
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)}`
}
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(
content: string,
file: ReverseMapFile,
warnings: ReverseWarning[],
): string {
const tokens = file.tokens
const contentTokens = 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) {
for (const token of contentTokens) {
const rangeResult = replaceAtRange(reversed, token)
if (rangeResult !== null) {
reversed = rangeResult
@@ -101,9 +204,194 @@ function reverseContent(
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
}
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(
renderedDir: string,
templateDir: string,
@@ -115,6 +403,14 @@ export function reverseDir(
? resolvePath(renderedRoot, options.mapPath)
: resolvePath(renderedRoot, ".tdir-map.json")
const manifest = readManifest(mapPath)
const directoryMap = buildDirectoryMap(manifest)
const includedOutputPaths = getIncludedRenderedFiles(
renderedRoot,
templateRoot,
mapPath,
manifest,
options.include,
)
const warnings: ReverseWarning[] = []
let filesWritten = 0
@@ -154,5 +450,17 @@ export function reverseDir(
filesWritten += 1
}
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
includedOutputPaths,
directoryMap,
manifest,
)
return { filesWritten, warnings }
}

99
scanner.ts Normal file
View File

@@ -0,0 +1,99 @@
export type DirectiveToken = {
type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
function readCondition(text: string, start: number): { condition: string; end: number } | null {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < text.length; i++) {
const char = text[i]!
if (escaped) {
escaped = false
continue
}
if (char === "\\") {
escaped = true
continue
}
if (char === "\"") {
inString = !inString
continue
}
if (inString) continue
if (char === "(") {
depth += 1
continue
}
if (char === ")") {
depth -= 1
if (depth === 0 && text[i + 1] === ">") {
return { condition: text.slice(start + 1, i), end: i + 2 }
}
}
}
return null
}
export function getDirectiveTokens(text: string): DirectiveToken[] {
const tokens: DirectiveToken[] = []
for (let i = 0; i < text.length; i++) {
if (text[i] !== "<" || text[i + 1] !== "@") continue
const rest = text.slice(i + 2)
const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined
if (!type) continue
const afterName = i + 2 + type.length
if ((type === "if" || type === "elseif") && text[afterName] === "(") {
const parsed = readCondition(text, afterName)
if (!parsed) continue
tokens.push({ type, condition: parsed.condition, index: i, end: parsed.end })
i = parsed.end - 1
} else if ((type === "else" || type === "endif") && text[afterName] === ">") {
tokens.push({ type, index: i, end: afterName + 1 })
i = afterName
}
}
return tokens
}
export function splitArgs(args: string): string[] {
const result: string[] = []
let current = ""
let depth = 0
let inString = false
let escaped = false
for (const char of args) {
if (escaped) {
current += char
escaped = false
continue
}
if (char === "\\") {
current += char
escaped = true
continue
}
if (char === "\"") {
current += char
inString = !inString
continue
}
if (!inString && char === "(") depth += 1
if (!inString && char === ")") depth -= 1
if (!inString && depth === 0 && char === ",") {
result.push(current.trim())
current = ""
continue
}
current += char
}
if (current.trim() !== "") result.push(current.trim())
return result
}

3
testdata/and_or_in_file/test.txt vendored Normal file
View File

@@ -0,0 +1,3 @@
<@if(and(context.enabled,eq(context.kind,"web")))>and<@else>not-and<@endif>
<@if(or(context.enabled,neq(context.kind,"web")))>or<@else>not-or<@endif>
<@if(and(or(context.enabled,context.fallback),neq(context.kind,"native")))>nested<@else>not-nested<@endif>

1
testdata/neq_in_file/test.txt vendored Normal file
View File

@@ -0,0 +1 @@
<@if(neq(context.test,"test"))>not-test<@else>test<@endif>

View File

@@ -0,0 +1 @@
not test

7
testdata/neq_multiline_block/test.txt vendored Normal file
View File

@@ -0,0 +1,7 @@
<@if(neq(context.project.frontend,"none"))>
bundev = {
exec = "bun dev";
cwd = "./apps/web";
after= ["devenv:processes:air@started"];
};
<@endif>

View File

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