9 Commits
v0.1.5 ... main

Author SHA1 Message Date
Gregor Lohaus
452d872954 meta, repo url in package.json
All checks were successful
Publish npm package / publish (push) Successful in 30s
2026-05-26 09:28:05 +02:00
Gregor Lohaus
e900729c8f better errors, and/or support
All checks were successful
Publish npm package / publish (push) Successful in 24s
2026-05-24 16:14:08 +02:00
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
13 changed files with 379 additions and 76 deletions

View File

@@ -60,6 +60,9 @@ 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 |
| `<@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`) | | `<@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 +75,9 @@ 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 |
| `<@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 | | `<@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.
@@ -225,6 +231,14 @@ reverseDir("./output", "./templates", {
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:

View File

@@ -229,22 +229,38 @@ test("reverseDir only includes new rendered files matching include globs", () =>
dir: "components" dir: "components"
}, },
header: { header: {
render: false, render: true,
text: "test" text: "test"
} }
}, { reverseMap: true }) }, { reverseMap: true })
writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\n") 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") writeFileSync(join(renderedOut, "components", "debug.tmp"), "debug\n")
reverseDir(renderedOut, ignoredOut) reverseDir(renderedOut, ignoredOut)
expect(existsSync(join(ignoredOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(false) expect(existsSync(join(ignoredOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(false)
const result = reverseDir(renderedOut, templateOut, { include: ["components/**/*.ts"] }) const result = reverseDir(renderedOut, templateOut, { include: ["components/**/*.ts"] })
expect(result.filesWritten).toBe(2) expect(result.filesWritten).toBe(3)
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)>", "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) 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({
@@ -435,7 +451,7 @@ test("file if",() => {
}) })
test("no endif should throw",() => { test("no endif should throw",() => {
expect(() => initRenderer("./testdata/no_end_if")).toThrow() expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
}) })
test("path vars cannot write outside target",() => { test("path vars cannot write outside target",() => {
@@ -552,3 +568,90 @@ 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)
})
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

@@ -1,6 +1,6 @@
{ {
"name": "@gregorlohaus/tdir", "name": "@gregorlohaus/tdir",
"version": "0.1.5", "version": "0.2.1",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -25,5 +25,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4" "zod": "^4"
},
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
} }
} }

View File

@@ -1,23 +1,31 @@
import { readdirSync, statSync, readFileSync } from "node:fs" import { readdirSync, statSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join, relative } from "node:path"
import { TextDecoder } from "node:util" import { TextDecoder } from "node:util"
import { getDirectiveTokens, splitArgs } from "./scanner"
export type TemplateVariable = { export type TemplateVariable = {
path: string path: string
type: string type: string
} }
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 STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const EQ_RE = /^eq\(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) for (const operator of ["and", "or"]) {
if (eqMatch) { const prefix = `${operator}(`
vars.push({ path: eqMatch[1]!, type: "string" }) 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 return
} }
const pathMatch = expr.match(PATH_RE) const pathMatch = expr.match(PATH_RE)
@@ -28,43 +36,50 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
throw new Error(`Invalid condition expression: ${expr}`) throw new Error(`Invalid condition expression: ${expr}`)
} }
function extractFromString(text: string, vars: TemplateVariable[]) { function extractFromString(text: string, vars: TemplateVariable[], source = "template") {
for (const match of text.matchAll(IF_RE)) { for (const token of getDirectiveTokens(text)) {
extractCondition(match[1]!, vars) if (token.type === "if" || token.type === "elseif") {
try {
extractCondition(token.condition, vars)
} catch (error) {
if (error instanceof Error) throw new Error(`${source}: ${error.message}`)
throw error
}
}
} }
for (const match of text.matchAll(VAR_RE)) { for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" }) vars.push({ path: match[1]!, type: match[2] ?? "string" })
} }
} }
function validateIfBlocks(content: string, vars: TemplateVariable[]) { function validateIfBlocks(content: string, vars: TemplateVariable[], source: string) {
const stack: { sawElse: boolean }[] = [] const stack: { sawElse: boolean }[] = []
for (const match of content.matchAll(DIRECTIVE_RE)) { for (const token of getDirectiveTokens(content)) {
const directive = match[1]! const directive = token.type
const condition = match[2] const condition = token.condition
if (directive === "if") { if (directive === "if") {
extractCondition(condition!, vars) extractCondition(condition!, vars)
stack.push({ sawElse: false }) stack.push({ sawElse: false })
} else if (directive === "elseif") { } else if (directive === "elseif") {
const frame = stack[stack.length - 1] const frame = stack[stack.length - 1]
if (!frame) throw new Error("Unexpected <@elseif> without <@if>") if (!frame) throw new Error(`${source}: Unexpected <@elseif> without <@if>`)
if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>") if (frame.sawElse) throw new Error(`${source}: Unexpected <@elseif> after <@else>`)
extractCondition(condition!, vars) extractCondition(condition!, vars)
} else if (directive === "else") { } else if (directive === "else") {
const frame = stack[stack.length - 1] const frame = stack[stack.length - 1]
if (!frame) throw new Error("Unexpected <@else> without <@if>") if (!frame) throw new Error(`${source}: Unexpected <@else> without <@if>`)
if (frame.sawElse) throw new Error("Unexpected duplicate <@else>") if (frame.sawElse) throw new Error(`${source}: Unexpected duplicate <@else>`)
frame.sawElse = true frame.sawElse = true
} else if (directive === "endif") { } else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>") if (stack.length === 0) throw new Error(`${source}: Unexpected <@endif> without <@if>`)
stack.pop() stack.pop()
} }
} }
if (stack.length > 0) { if (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>") throw new Error(`${source}: Unmatched <@if> without <@endif>`)
} }
} }
@@ -78,21 +93,22 @@ function isUtf8Text(buffer: Buffer): boolean {
} }
} }
function walkDir(dirPath: string, vars: TemplateVariable[]) { function walkDir(dirPath: string, vars: TemplateVariable[], rootPath: string) {
const entries = readdirSync(dirPath).sort() const entries = readdirSync(dirPath).sort()
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dirPath, entry) const fullPath = join(dirPath, entry)
extractFromString(entry, vars) const relativePath = relative(rootPath, fullPath)
extractFromString(entry, vars, relativePath || entry)
const stat = statSync(fullPath) const stat = statSync(fullPath)
if (stat.isDirectory()) { if (stat.isDirectory()) {
walkDir(fullPath, vars) walkDir(fullPath, vars, rootPath)
} else if (stat.isFile()) { } else if (stat.isFile()) {
const content = readFileSync(fullPath) const content = readFileSync(fullPath)
if (isUtf8Text(content)) { if (isUtf8Text(content)) {
const text = content.toString("utf-8") const text = content.toString("utf-8")
extractFromString(text, vars) extractFromString(text, vars, relativePath || fullPath)
validateIfBlocks(text, vars) validateIfBlocks(text, vars, relativePath || fullPath)
} }
} }
} }
@@ -100,7 +116,7 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
export function parse(dirPath: string): TemplateVariable[] { export function parse(dirPath: string): TemplateVariable[] {
const vars: TemplateVariable[] = [] const vars: TemplateVariable[] = []
walkDir(dirPath, vars) walkDir(dirPath, vars, dirPath)
const seen = new Set<string>() const seen = new Set<string>()
return vars.filter(v => { return vars.filter(v => {
const key = `${v.path}:${v.type}` const key = `${v.path}:${v.type}`

View File

@@ -10,11 +10,11 @@ import {
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { homedir } from "node:os" import { homedir } from "node:os"
import { TextDecoder } from "node:util" import { TextDecoder } from "node:util"
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
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 STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/ const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = { export type ReverseMapToken = {
@@ -68,9 +68,20 @@ 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) for (const operator of ["and", "or"] as const) {
if (eqMatch) { const prefix = `${operator}(`
return resolveContext(context, eqMatch[1]!) === eqMatch[2] 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) const pathMatch = expr.match(PATH_RE)
if (pathMatch) { if (pathMatch) {
@@ -139,6 +150,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)
@@ -190,13 +212,6 @@ function isUtf8Text(buffer: Buffer): boolean {
} }
} }
type DirectiveToken = {
type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
type TextNode = { type TextNode = {
type: "text" type: "text"
text: string text: string
@@ -233,15 +248,6 @@ type ConditionalRender = {
} }
} }
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( function parseNodes(
content: string, content: string,
tokens: DirectiveToken[], tokens: DirectiveToken[],
@@ -384,6 +390,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

@@ -245,6 +245,19 @@ 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 { function dirnamePath(path: string): string {
const normalized = normalizePath(path) const normalized = normalizePath(path)
const index = normalized.lastIndexOf("/") const index = normalized.lastIndexOf("/")
@@ -314,50 +327,71 @@ function inferTemplatePath(outputPath: string, directoryMap: Map<string, string>
return joinPath(bestTemplateDir, suffix, basenamePath(normalized)) return joinPath(bestTemplateDir, suffix, basenamePath(normalized))
} }
function walkFiles(root: string, current = root): string[] { function walkFiles(root: string, excludedRoots: string[] = []): string[] {
const files: string[] = [] const files: string[] = []
const pending = [root]
while (pending.length > 0) {
const current = pending.pop()!
for (const entry of readdirSync(current).sort()) { for (const entry of readdirSync(current).sort()) {
const path = resolvePath(current, entry) const path = resolvePath(current, entry)
if (excludedRoots.some(excluded => isInsidePath(excluded, path))) continue
const stat = statSync(path) const stat = statSync(path)
if (stat.isDirectory()) { if (stat.isDirectory()) {
files.push(...walkFiles(root, path)) pending.push(path)
} else if (stat.isFile()) { } else if (stat.isFile()) {
files.push(normalizePath(relative(root, path))) files.push(normalizePath(relative(root, path)))
} }
} }
}
return files return files
} }
function copyIncludedRenderedFiles( function copyIncludedRenderedFiles(
renderedRoot: string, renderedRoot: string,
templateRoot: string, templateRoot: string,
mapPath: string, includedOutputPaths: string[],
directoryMap: Map<string, string>,
manifest: ReverseMapManifest, manifest: ReverseMapManifest,
include: ReverseOptions["include"],
): number { ): 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 let filesWritten = 0
for (const outputPath of walkFiles(renderedRoot)) { for (const outputPath of includedOutputPaths) {
if (outputPath === mapRelativePath) continue
if (mappedOutputPaths.has(outputPath)) continue
if (!matchesAny(outputPath, matchers)) continue
const renderedPath = resolveInside(renderedRoot, outputPath) const renderedPath = resolveInside(renderedRoot, outputPath)
const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap)) const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap))
mkdirSync(dirname(templatePath), { recursive: true }) mkdirSync(dirname(templatePath), { recursive: true })
const content = readFileSync(renderedPath)
if (isUtf8Text(content)) {
writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest))
} else {
copyFileSync(renderedPath, templatePath) copyFileSync(renderedPath, templatePath)
}
filesWritten += 1 filesWritten += 1
} }
return filesWritten 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,
@@ -369,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
@@ -415,9 +457,9 @@ export function reverseDir(
filesWritten += copyIncludedRenderedFiles( filesWritten += copyIncludedRenderedFiles(
renderedRoot, renderedRoot,
templateRoot, templateRoot,
mapPath, includedOutputPaths,
directoryMap,
manifest, manifest,
options.include,
) )
return { filesWritten, warnings } 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, "emitDeclarationOnly": true,
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"] "include": ["index.ts", "parser.ts", "render.ts", "reverse.ts", "scanner.ts"]
} }