5 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
13 changed files with 286 additions and 37 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.

View File

@@ -229,19 +229,21 @@ 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)
}) })
@@ -566,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.6", "version": "0.1.9",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

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 } 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)
@@ -29,8 +37,10 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
} }
function extractFromString(text: string, vars: TemplateVariable[]) { function extractFromString(text: string, vars: TemplateVariable[]) {
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") {
extractCondition(token.condition, vars)
}
} }
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" })
@@ -40,9 +50,9 @@ function extractFromString(text: string, vars: TemplateVariable[]) {
function validateIfBlocks(content: string, vars: TemplateVariable[]) { function validateIfBlocks(content: string, vars: TemplateVariable[]) {
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)

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("/")
@@ -340,6 +353,7 @@ function copyIncludedRenderedFiles(
templateRoot: string, templateRoot: string,
includedOutputPaths: string[], includedOutputPaths: string[],
directoryMap: Map<string, string>, directoryMap: Map<string, string>,
manifest: ReverseMapManifest,
): number { ): number {
let filesWritten = 0 let filesWritten = 0
@@ -347,7 +361,12 @@ function copyIncludedRenderedFiles(
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 })
copyFileSync(renderedPath, templatePath) const content = readFileSync(renderedPath)
if (isUtf8Text(content)) {
writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest))
} else {
copyFileSync(renderedPath, templatePath)
}
filesWritten += 1 filesWritten += 1
} }
@@ -440,6 +459,7 @@ export function reverseDir(
templateRoot, templateRoot,
includedOutputPaths, includedOutputPaths,
directoryMap, directoryMap,
manifest,
) )
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"]
} }