12 Commits
Author SHA1 Message Date
gregor 101769a4b2 runonlocal
Publish npm package / publish (push) Successful in 26s
2026-10-08 09:10:19 +02:00
gregor ed5a666ad3 0.3.0
Publish npm package / publish (push) Canceled after 0s
2026-10-07 15:30:39 +02:00
Gregor Lohaus 452d872954 meta, repo url in package.json
Publish npm package / publish (push) Successful in 30s
2026-05-26 09:28:05 +02:00
Gregor Lohaus e900729c8f better errors, and/or support
Publish npm package / publish (push) Successful in 24s
2026-05-24 16:14:08 +02:00
Gregor Lohaus fe3489e217 version bump
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
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
Publish npm package / publish (push) Successful in 26s
2026-05-24 15:05:22 +02:00
Gregor Lohaus 7d01b2f7c9 version bump
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
Publish npm package / publish (push) Successful in 22s
2026-05-24 14:46:07 +02:00
21 changed files with 738 additions and 74 deletions
+10 -3
View File
@@ -7,7 +7,7 @@ on:
jobs:
publish:
runs-on: x86
runs-on: local
steps:
- uses: actions/checkout@v4
@@ -21,6 +21,10 @@ jobs:
node-version: 24
registry-url: "https://registry.npmjs.org"
# Stage-only tokens require `npm stage publish` (npm >= 11.15.0).
- name: Update npm
run: npm install -g npm@^11.15.0
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -30,10 +34,13 @@ jobs:
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
test "$PACKAGE_VERSION" = "$TAG_NAME"
- name: Test
run: bun test
- name: Build
run: bun run build
- name: Publish
run: npm publish --access public
- name: Stage publish
run: npm stage publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+54
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.
@@ -125,6 +131,31 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
Pass `{ overwrite: false }` to keep existing work safe, e.g. in a scaffolding CLI. tdir then throws `TargetExistsError` instead of clearing a target that exists and is not empty (or is a file); a missing or empty target is rendered as usual:
```ts
import { TargetExistsError } from "@gregorlohaus/tdir"
try {
render("./my-app", context, { overwrite: false })
} catch (error) {
if (error instanceof TargetExistsError) console.error("./my-app already exists")
else throw error
}
```
## Renaming output files
Some files can't be stored in a template under their real name, e.g. npm never publishes `.gitignore` or `.npmrc` files. Store them under another name and map it back with `rename`, which applies to rendered file and directory names:
```ts
render("./output", context, { rename: { _gitignore: ".gitignore", _npmrc: ".npmrc" } })
// templates/_gitignore -> output/.gitignore
// templates/<@if(context.web)>web/_gitignore -> output/web/.gitignore
```
Reverse maps record the renamed output path, so `tdir reverse` writes edits back to `_gitignore`.
## Reverse maps
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
@@ -208,8 +239,31 @@ 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 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
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
+16 -5
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}`)
+9 -9
View File
@@ -3,11 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1776271913,
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
"lastModified": 1790701375,
"narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=",
"owner": "cachix",
"repo": "devenv",
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
"rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1776097194,
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
"lastModified": 1789506231,
"narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
"rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
"type": "github"
},
"original": {
@@ -39,11 +39,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1775888245,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"lastModified": 1789370336,
"narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13043924aaa7375ce482ebe2494338e058282925",
"rev": "c7def046b9a883d46974757852106483d741586f",
"type": "github"
},
"original": {
+212 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
import { initRenderer, reverseDir, SchemaMismatchError, TargetExistsError } from "./index";
import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { basename, join } from "node:path"
@@ -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)
})
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({
@@ -397,7 +451,7 @@ test("file if",() => {
})
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",() => {
@@ -514,3 +568,159 @@ 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)
})
test("rename maps output names and records them in the reverse map", () => {
const templateDir = join(tmp, "template")
mkdirSync(join(templateDir, "<@if(context.web)>web"), { recursive: true })
writeFileSync(join(templateDir, "_gitignore"), "node_modules\n")
writeFileSync(join(templateDir, "<@if(context.web)>web", "_gitignore"), "dist\n")
const render = initRenderer(templateDir)(z.object({ web: z.boolean() }))
const out = join(tmp, "out")
render(out, { web: true }, { reverseMap: true, rename: { _gitignore: ".gitignore" } })
expect(readFileSync(join(out, ".gitignore"), "utf-8")).toBe("node_modules\n")
expect(readFileSync(join(out, "web", ".gitignore"), "utf-8")).toBe("dist\n")
expect(existsSync(join(out, "_gitignore"))).toBe(false)
const manifest = JSON.parse(readFileSync(join(out, ".tdir-map.json"), "utf-8"))
expect(manifest.files).toContainEqual(expect.objectContaining({ outputPath: ".gitignore", templatePath: "_gitignore" }))
expect(manifest.files).toContainEqual(expect.objectContaining({
outputPath: join("web", ".gitignore"),
templatePath: join("<@if(context.web)>web", "_gitignore"),
}))
const reversedDir = join(tmp, "reversed")
reverseDir(out, reversedDir)
expect(readFileSync(join(reversedDir, "_gitignore"), "utf-8")).toBe("node_modules\n")
})
test("rename applies to directories", () => {
const templateDir = join(tmp, "template")
mkdirSync(join(templateDir, "_github"), { recursive: true })
writeFileSync(join(templateDir, "_github", "ci.yml"), "on: push\n")
const render = initRenderer(templateDir)(z.object({}))
const out = join(tmp, "out")
render(out, {}, { rename: { _github: ".github" } })
expect(readFileSync(join(out, ".github", "ci.yml"), "utf-8")).toBe("on: push\n")
})
test("overwrite false refuses a non-empty target and leaves it untouched", () => {
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
const out = join(tmp, "out")
mkdirSync(out)
writeFileSync(join(out, "keep.txt"), "important")
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
expect(readFileSync(join(out, "keep.txt"), "utf-8")).toBe("important")
})
test("overwrite false renders into a missing or empty target", () => {
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
const missing = join(tmp, "missing")
const empty = join(tmp, "empty")
mkdirSync(empty)
render(missing, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
render(empty, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
expect(existsSync(join(missing, "web", "if_example.html"))).toBe(true)
expect(existsSync(join(empty, "web", "if_example.html"))).toBe(true)
})
test("overwrite false refuses when the target is a file", () => {
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
const out = join(tmp, "out")
writeFileSync(out, "not a directory")
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
expect(readFileSync(out, "utf-8")).toBe("not a directory")
})
+1
View File
@@ -2,6 +2,7 @@ import { z } from "zod"
import { parse, type TemplateVariable } from "./parser"
import { renderDir, type RenderOptions } from "./render"
export { TargetExistsError } from "./render"
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
+6 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gregorlohaus/tdir",
"version": "0.1.4",
"version": "0.3.0",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"tdir": "./dist/cli.js"
"tdir": "dist/cli.js"
},
"exports": {
".": {
@@ -25,5 +25,9 @@
},
"peerDependencies": {
"zod": "^4"
},
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
}
}
+42 -26
View File
@@ -1,23 +1,31 @@
import { readdirSync, statSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { join, relative } 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)
@@ -28,43 +36,50 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
throw new Error(`Invalid condition expression: ${expr}`)
}
function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) {
extractCondition(match[1]!, vars)
function extractFromString(text: string, vars: TemplateVariable[], source = "template") {
for (const token of getDirectiveTokens(text)) {
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)) {
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 }[] = []
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)
stack.push({ sawElse: false })
} else if (directive === "elseif") {
const frame = stack[stack.length - 1]
if (!frame) throw new Error("Unexpected <@elseif> without <@if>")
if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
if (!frame) throw new Error(`${source}: Unexpected <@elseif> without <@if>`)
if (frame.sawElse) throw new Error(`${source}: Unexpected <@elseif> after <@else>`)
extractCondition(condition!, vars)
} else if (directive === "else") {
const frame = stack[stack.length - 1]
if (!frame) throw new Error("Unexpected <@else> without <@if>")
if (frame.sawElse) throw new Error("Unexpected duplicate <@else>")
if (!frame) throw new Error(`${source}: Unexpected <@else> without <@if>`)
if (frame.sawElse) throw new Error(`${source}: Unexpected duplicate <@else>`)
frame.sawElse = true
} 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()
}
}
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()
for (const entry of entries) {
const fullPath = join(dirPath, entry)
extractFromString(entry, vars)
const relativePath = relative(rootPath, fullPath)
extractFromString(entry, vars, relativePath || entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
walkDir(fullPath, vars)
walkDir(fullPath, vars, rootPath)
} else if (stat.isFile()) {
const content = readFileSync(fullPath)
if (isUtf8Text(content)) {
const text = content.toString("utf-8")
extractFromString(text, vars)
validateIfBlocks(text, vars)
extractFromString(text, vars, relativePath || fullPath)
validateIfBlocks(text, vars, relativePath || fullPath)
}
}
}
@@ -100,7 +116,7 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
export function parse(dirPath: string): TemplateVariable[] {
const vars: TemplateVariable[] = []
walkDir(dirPath, vars)
walkDir(dirPath, vars, dirPath)
const seen = new Set<string>()
return vars.filter(v => {
const key = `${v.path}:${v.type}`
+65 -26
View File
@@ -10,11 +10,11 @@ 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 = {
@@ -58,19 +58,42 @@ export type ReverseMapManifest = {
export type RenderOptions = {
reverseMap?: boolean | string
/** When false, throw TargetExistsError instead of clearing a non-empty target. Defaults to true. */
overwrite?: boolean
/** Renames rendered file and directory names, e.g. `{ _gitignore: ".gitignore" }`. */
rename?: Record<string, string>
}
export class TargetExistsError extends Error {
constructor(target: string) {
super(`Refusing to overwrite non-empty target directory: ${target}`)
this.name = "TargetExistsError"
}
}
type RenderState = {
sourceRoot: string
destRoot: string
manifest?: ReverseMapManifest
rename: Record<string, string>
}
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) {
@@ -139,6 +162,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)
@@ -147,6 +181,7 @@ function getReverseMapPath(destRoot: string, reverseMap: true | string): string
function getOutputName(
entry: string,
context: Record<string, unknown>,
rename: Record<string, string>,
state?: RenderState,
file?: ReverseMapFile,
): string | null {
@@ -166,7 +201,7 @@ function getOutputName(
}
}
return outputName.replace(VAR_RE, (_match, path: string) => {
const rendered = outputName.replace(VAR_RE, (_match, path: string) => {
const result = String(resolveContext(context, path) ?? "")
addReverseMapToken(state, file, {
kind: "path",
@@ -178,6 +213,15 @@ function getOutputName(
})
return result
})
return Object.hasOwn(rename, rendered) ? rename[rendered]! : rendered
}
function isNonEmptyTarget(destRoot: string): boolean {
try {
return !statSync(destRoot).isDirectory() || readdirSync(destRoot).length > 0
} catch {
return false
}
}
function isUtf8Text(buffer: Buffer): boolean {
@@ -190,13 +234,6 @@ function isUtf8Text(buffer: Buffer): boolean {
}
}
type DirectiveToken = {
type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
type TextNode = {
type: "text"
text: string
@@ -233,15 +270,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(
content: string,
tokens: DirectiveToken[],
@@ -384,6 +412,12 @@ function renderContentWithMap(
state?: RenderState,
file?: ReverseMapFile,
): 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 processed = processedResult.content
for (const token of processedResult.conditionalTokens) {
@@ -438,10 +472,14 @@ function renderDir(
sourceRoot,
destRoot,
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
rename: options.rename ?? {},
}
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
assertSafeRenderTarget(srcDir, destDir)
validateOutputPaths(srcDir, destRoot, context)
validateOutputPaths(srcDir, destRoot, context, state.rename)
if (options.overwrite === false && isNonEmptyTarget(destRoot)) {
throw new TargetExistsError(destDir)
}
rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destRoot, context, state)
if (state.manifest && mapPath) {
@@ -454,18 +492,19 @@ function validateOutputPaths(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
rename: Record<string, string>,
) {
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
const outputName = getOutputName(entry, context)
const outputName = getOutputName(entry, context, rename)
if (outputName === null) continue
const destPath = resolveOutputPath(destDir, outputName)
if (stat.isDirectory()) {
validateOutputPaths(srcPath, destPath, context)
validateOutputPaths(srcPath, destPath, context, rename)
}
}
}
@@ -521,7 +560,7 @@ function renderDirInner(
templatePath,
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
const outputName = getOutputName(entry, context, state.rename, state, tempFile)
if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
+210
View File
@@ -3,6 +3,7 @@ import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs"
@@ -12,6 +13,7 @@ import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, Reve
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
@@ -198,6 +245,153 @@ function writeSkippedTemplate(
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,
@@ -209,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
@@ -252,5 +454,13 @@ export function reverseDir(
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
includedOutputPaths,
directoryMap,
manifest,
)
return { filesWritten, warnings }
}
+99
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
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
View File
@@ -0,0 +1 @@
<@if(neq(context.test,"test"))>not-test<@else>test<@endif>
@@ -0,0 +1 @@
not test
+7
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>
+1 -1
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"]
}