5 Commits

Author SHA1 Message Date
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
9 changed files with 356 additions and 14 deletions

View File

@@ -60,6 +60,7 @@ render("./output", {
|---|---| |---|---|
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) | | `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check | | `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
| `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check |
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) | | `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
| `<@else>` | Else branch | | `<@else>` | Else branch |
| `<@endif>` | End conditional block | | `<@endif>` | End conditional block |
@@ -72,6 +73,7 @@ render("./output", {
|---|---| |---|---|
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) | | `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality | | `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
| `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality |
| `<@var(context.x)>` | Dynamic directory/file name | | `<@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. 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.
@@ -208,8 +210,31 @@ 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
``` ```
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 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
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized: A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:

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

@@ -207,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({
@@ -514,3 +568,25 @@ test("if eq in path", () => {
render(tmp,{test:"foo"}) render(tmp,{test:"foo"})
expect(existsSync(join(tmp,"test"))).toBe(false) 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)
})

View File

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

View File

@@ -10,14 +10,14 @@ export type TemplateVariable = {
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/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\.(.+)$/ const PATH_RE = /^context\.(.+)$/
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) { function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
if (!expr) throw new Error("Missing condition expression") if (!expr) throw new Error("Missing condition expression")
const eqMatch = expr.match(EQ_RE) const stringCompareMatch = expr.match(STRING_COMPARE_RE)
if (eqMatch) { if (stringCompareMatch) {
vars.push({ path: eqMatch[1]!, type: "string" }) vars.push({ path: stringCompareMatch[1]!, type: "string" })
return return
} }
const pathMatch = expr.match(PATH_RE) const pathMatch = expr.match(PATH_RE)

View File

@@ -14,7 +14,7 @@ import { TextDecoder } from "node:util"
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/ const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/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\.(.+)$/ const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = { export type ReverseMapToken = {
@@ -68,9 +68,10 @@ type RenderState = {
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean { function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
if (!expr) throw new Error("Missing condition expression") if (!expr) throw new Error("Missing condition expression")
const eqMatch = expr.match(EQ_RE) const stringCompareMatch = expr.match(STRING_COMPARE_RE)
if (eqMatch) { if (stringCompareMatch) {
return resolveContext(context, eqMatch[1]!) === eqMatch[2] const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3]
return stringCompareMatch[1] === "eq" ? result : !result
} }
const pathMatch = expr.match(PATH_RE) const pathMatch = expr.match(PATH_RE)
if (pathMatch) { if (pathMatch) {
@@ -139,6 +140,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)
@@ -384,6 +396,12 @@ function renderContentWithMap(
state?: RenderState, state?: RenderState,
file?: ReverseMapFile, file?: ReverseMapFile,
): string { ): string {
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 processedResult = processIfBlocksWithMap(content, context)
const processed = processedResult.content const processed = processedResult.content
for (const token of processedResult.conditionalTokens) { for (const token of processedResult.conditionalTokens) {

View File

@@ -3,6 +3,7 @@ import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
readdirSync,
statSync, statSync,
writeFileSync, writeFileSync,
} from "node:fs" } from "node:fs"
@@ -12,6 +13,7 @@ import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, Reve
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
@@ -198,6 +245,153 @@ function writeSkippedTemplate(
return 1 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,
@@ -209,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
@@ -252,5 +454,13 @@ export function reverseDir(
filesWritten += writeSkippedTemplate(templateRoot, skipped) filesWritten += writeSkippedTemplate(templateRoot, skipped)
} }
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
includedOutputPaths,
directoryMap,
manifest,
)
return { filesWritten, warnings } return { filesWritten, warnings }
} }

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