22 Commits

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
Gregor Lohaus
0a512cdbc3 reverse accepts new file paths via globs
All checks were successful
Publish npm package / publish (push) Successful in 22s
2026-05-24 14:46:07 +02:00
Gregor Lohaus
2971c87618 version bump
All checks were successful
Publish npm package / publish (push) Successful in 23s
2026-05-24 12:21:57 +02:00
Gregor Lohaus
af0c25e64b full template restoreablity
Some checks failed
Publish npm package / publish (push) Failing after 21s
2026-05-24 12:17:12 +02:00
Gregor Lohaus
8ad2545310 fix build error
All checks were successful
Publish npm package / publish (push) Successful in 24s
2026-05-22 10:05:34 +02:00
Gregor Lohaus
4d25d07687 run on pc
Some checks failed
Publish npm package / publish (push) Failing after 3m9s
2026-05-22 09:59:36 +02:00
Gregor Lohaus
b04b719840 version bump
Some checks failed
Publish npm package / publish (push) Has been cancelled
2026-05-22 09:34:54 +02:00
Gregor Lohaus
bceed3711c gitea action 2026-05-22 09:34:14 +02:00
Gregor Lohaus
28a2a771e8 reverse cli command 2026-05-22 09:09:56 +02:00
Gregor Lohaus
bda6e8cc40 map rendered strings to template token source 2026-05-22 09:05:02 +02:00
Gregor Lohaus
3110eefcbd hardening 2026-05-22 08:55:28 +02:00
Gregor Lohaus
ca4a02ab4e version bump 2026-04-15 22:47:28 +02:00
Gregor Lohaus
0fd19254e9 eq directive 2026-04-15 22:37:13 +02:00
Gregor Lohaus
5edb1139b9 bump version 2026-04-08 01:31:23 +02:00
21 changed files with 1886 additions and 349 deletions

View File

@@ -0,0 +1,39 @@
name: Publish npm package
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: x86
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Check tag matches package version
run: |
PACKAGE_VERSION="v$(node -p "require('./package.json').version")"
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
test "$PACKAGE_VERSION" = "$TAG_NAME"
- name: Build
run: bun run build
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

1
.gitignore vendored
View File

@@ -43,3 +43,4 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
.codex

106
CLAUDE.md
View File

@@ -1,106 +0,0 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

142
README.md
View File

@@ -1,6 +1,6 @@
# tdir
Treat a directory as a template. File paths and contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time.
Treat a directory as a template. File paths and text file contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time. Binary files are copied through unchanged.
## Install
@@ -32,7 +32,7 @@ Where `index.html` contains:
Render it:
```ts
import { initRenderer } from "tdir"
import { initRenderer } from "@gregorlohaus/tdir"
import { z } from "zod"
const createRenderer = initRenderer("./templates")
@@ -58,8 +58,12 @@ render("./output", {
| Directive | Description |
|---|---|
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) |
| `<@elseif(context.y)>` | Else-if branch |
| `<@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 |
| `<@var(context.x)>` | Substitute with context value (default type: `string`) |
@@ -69,7 +73,11 @@ render("./output", {
| Directive | Description |
|---|---|
| `<@if(context.x)>dirname` | Conditionally include directory/file |
| `<@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.
@@ -79,7 +87,7 @@ These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` create
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
```ts
import { initRenderer, SchemaMismatchError } from "tdir"
import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
import { z } from "zod"
const createRenderer = initRenderer("./templates")
@@ -90,14 +98,14 @@ createRenderer(z.object({
web: z.string(), // wrong type
header: z.object({ show: z.boolean(), title: z.string() })
}))
// SchemaMismatchError: Shema doesnt match used template variables: web: expected z.boolean() but schema has z.string()
// SchemaMismatchError: Schema doesn't match used template variables: web: expected z.boolean() but schema has z.string()
// Schema is missing fields used in templates -- throws SchemaMismatchError
createRenderer(z.object({
web: z.boolean()
// missing header
}))
// SchemaMismatchError: Shema doesnt match used template variables: header: missing in schema
// SchemaMismatchError: Schema doesn't match used template variables: header: missing in schema
```
## Context validation
@@ -117,12 +125,126 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
// ZodError: expected boolean, received string at "web"
```
## Re-rendering
`render(target, context)` clears `target` before writing, so rendering the same template into the same directory with different contexts always produces a clean result (files/paths excluded by conditionals won't linger from a previous run).
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.
## Reverse maps
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
```ts
render("./output", {
web: true,
header: { show: true, title: "Hello" }
}, { reverseMap: true })
```
Pass a string to choose a custom JSON path inside the output directory:
```ts
render("./output", context, { reverseMap: "meta/reverse-map.json" })
```
The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals:
```json
{
"version": 1,
"files": [
{
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"tokens": [
{
"kind": "content",
"result": "Hello",
"token": "<@var(context.header.title)>",
"contextPath": "header.title",
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 16, "end": 21 }
},
{
"kind": "conditional",
"result": "<head><@var(context.header.title)></head>",
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 9, "end": 53 },
"activeRange": { "start": 27, "end": 71 }
}
]
}
],
"skipped": [
{
"kind": "file",
"templatePath": "<@if(context.docs)>docs/readme.md",
"encoding": "utf8",
"content": "# Docs"
}
],
"tokens": {
"Hello": ["<@var(context.header.title)>"]
}
}
```
## Reverse CLI
Use the reverse map to rebuild template files from an edited rendered directory:
```sh
tdir reverse ./output ./templates
```
Without installing the package first, run the published CLI through Bun:
```sh
bunx @gregorlohaus/tdir reverse ./output ./templates
```
By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory:
```sh
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 at render time:
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
```ts
// If a template file contains <@if(context.x)> with no <@endif>
render("./output", { x: true })
initRenderer("./templates")
// Error: Unmatched <@if> without <@endif>
```

View File

@@ -3,15 +3,13 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "tdir",
"dependencies": {
"zod": "^4.3.6",
},
"name": "@gregorlohaus/tdir",
"devDependencies": {
"@types/bun": "^1.3.11",
"typescript": "^5",
},
"peerDependencies": {
"typescript": "^5",
"zod": "^4",
},
},
},

88
cli.ts Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
import { reverseDir } from "./reverse"
function printHelp() {
console.log(`tdir
Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
Commands:
reverse Rebuild template files from a rendered directory and reverse map
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, include }
}
if (arg === "--map") {
const value = args[++i]
if (!value) throw new Error("Missing value for --map")
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, include }
}
function main(argv: string[]) {
const [command, ...args] = argv
if (!command || command === "--help" || command === "-h") {
printHelp()
return 0
}
if (command !== "reverse") {
throw new Error(`Unknown command: ${command}`)
}
const parsed = parseReverseArgs(args)
if (parsed.help) {
printHelp()
return 0
}
const [renderedDir, templateDir] = parsed.positional
if (!renderedDir || !templateDir || parsed.positional.length > 2) {
throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]")
}
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}`)
}
return result.warnings.length > 0 ? 2 : 0
}
try {
process.exitCode = main(process.argv.slice(2))
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}

View File

@@ -3,10 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1775585812,
"lastModified": 1776271913,
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
"owner": "cachix",
"repo": "devenv",
"rev": "35b8c42eb10c196dc84611852325c722b6f10750",
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
"type": "github"
},
"original": {
@@ -16,71 +17,16 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1775585728,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "580633fa3fe5fc0379905986543fd7495481913d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1762808025,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1774287239,
"lastModified": 1776097194,
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
"type": "github"
},
"original": {
@@ -93,11 +39,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"lastModified": 1775888245,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"rev": "13043924aaa7375ce482ebe2494338e058282925",
"type": "github"
},
"original": {
@@ -110,11 +56,7 @@
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
"nixpkgs": "nixpkgs"
}
}
},

View File

@@ -1,8 +1,8 @@
import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, SchemaMismatchError } from ".";
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { basename, join } from "node:path"
import { tmpdir } from "node:os"
const ifExampleSchema = z.object({
@@ -55,6 +55,212 @@ test("renders with web=true, header rendered", () => {
expect(content).toContain("<body>")
})
test("render can write a reverse map", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
const manifest = JSON.parse(readFileSync(join(tmp, ".tdir-map.json"), "utf-8"))
expect(manifest.version).toBe(1)
expect(manifest.tokens["My Title"]).toContain("<@var(context.header.text)>")
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
expect(file).toBeDefined()
expect(file.tokens).toContainEqual({
kind: "conditional",
result: expect.stringContaining("<@var(context.header.text)>"),
token: expect.stringContaining("<@if(context.header.render)>"),
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
activeRange: expect.any(Object),
before: expect.any(String),
after: expect.any(String),
})
expect(file.tokens).toContainEqual({
kind: "content",
result: "My Title",
token: "<@var(context.header.text)>",
contextPath: "header.text",
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
})
})
test("reverse map records path variable tokens", () => {
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()
})
}))
render(tmp,{
web: {
create: true,
dir: "web"
},
header: {
render: false,
text: "test"
}
}, { reverseMap: "meta/reverse-map.json" })
const manifest = JSON.parse(readFileSync(join(tmp, "meta", "reverse-map.json"), "utf-8"))
expect(manifest.tokens["web"]).toContain("<@var(context.web.dir)>")
const pathFile = manifest.files.find((entry: any) => entry.outputPath === "web")
expect(pathFile.tokens).toContainEqual({
kind: "path",
result: "web",
token: "<@var(context.web.dir)>",
contextPath: "web.dir",
outputPath: "web",
templatePath: "<@if(context.web.create)><@var(context.web.dir)>",
})
})
test("reverseDir rebuilds templates from rendered output and map", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
const templateOut = join(tmp, "template")
const renderedOut = join(tmp, "rendered")
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
writeFileSync(join(renderedOut, "web", "if_example.html"), [
"<document>",
" <head> My Title </head>",
" <body>",
" <main>Edited rendered output</main>",
" </body>",
"</document>",
].join("\n"))
const result = reverseDir(renderedOut, templateOut)
expect(result.filesWritten).toBe(1)
expect(result.warnings).toEqual([])
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8")
expect(reversed).toContain("<@if(context.header.render)>")
expect(reversed).toContain("<@endif>")
expect(reversed).toContain("<@var(context.header.text)>")
expect(reversed).toContain("Edited rendered output")
})
test("reverseDir restores files skipped by path conditionals", () => {
const createRenderer = initRenderer("./testdata/file_if")
const render = createRenderer(z.object({
web: z.boolean(),
file: z.boolean(),
text: z.string()
}))
const renderedOut = join(tmp, "rendered")
const templateOut = join(tmp, "template")
render(renderedOut,{
web: true,
file: false,
text: "test"
}, { reverseMap: true })
expect(existsSync(join(renderedOut, "web", "example.txt"))).toBe(false)
const result = reverseDir(renderedOut, templateOut)
expect(result.filesWritten).toBe(1)
const restoredPath = join(templateOut, "<@if(context.web)>web", "<@if(context.file)>example.txt")
expect(existsSync(restoredPath)).toBe(true)
expect(readFileSync(restoredPath, "utf-8")).toContain("<@var(context.text)>")
})
test("reverseDir supports custom map paths", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
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")
render(renderedOut,{
web: {
create: true,
dir: "web"
},
header: {
render: true,
text: "test"
}
}, { reverseMap: "meta/reverse-map.json" })
const result = reverseDir(renderedOut, templateOut, { mapPath: "meta/reverse-map.json" })
expect(result.filesWritten).toBe(1)
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({
@@ -245,13 +451,78 @@ test("file if",() => {
})
test("no endif should throw",() => {
const createRenderer = initRenderer("./testdata/no_end_if")
expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
})
test("path vars cannot write outside target",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const sentinel = join(tmp, "keep.txt")
const outsideName = `${basename(tmp)}-outside`
const outsidePath = join(tmp, "..", outsideName)
writeFileSync(sentinel, "keep")
const render = createRenderer(z.object({
test: z.boolean(),
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
expect( () => render(tmp,{
test: true,
expect(() => render(tmp,{
web: {
create: true,
dir: `../${outsideName}`
},
header: {
render: false,
text: "test"
}
})).toThrow()
expect(existsSync(outsidePath)).toBe(false)
expect(existsSync(sentinel)).toBe(true)
})
test("refuses overlapping source and target directories",() => {
const source = join(tmp, "template")
const target = join(source, "out")
mkdirSync(source, { recursive: true })
writeFileSync(join(source, "test.txt"), "test")
const createRenderer = initRenderer(source)
const render = createRenderer(z.object({}))
expect(() => render(target, {})).toThrow()
})
test("binary files are copied without text rendering",() => {
const source = join(tmp, "template")
const target = join(tmp, "out")
mkdirSync(source, { recursive: true })
const binary = Buffer.from([0, 255, 1, 2, 3])
writeFileSync(join(source, "asset.bin"), binary)
const createRenderer = initRenderer(source)
const render = createRenderer(z.object({}))
render(target, {})
expect(readFileSync(join(target, "asset.bin"))).toEqual(binary)
})
test("conflicting template variable types should throw",() => {
const source = join(tmp, "template")
mkdirSync(source, { recursive: true })
writeFileSync(join(source, "test.txt"), [
"<@if(context.test)>",
"<@endif>",
"<@var(context.test)>"
].join("\n"))
const createRenderer = initRenderer(source)
expect(() => createRenderer(z.object({
test: z.boolean(),
}))).toThrow()
})
test("if elseif else",() => {
@@ -289,3 +560,98 @@ test("if elseif else",() => {
expect(content).toContain("test3")
})
test("if eq in path", () => {
const createRenderer = initRenderer("./testdata/eq_in_path")
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"test"})
expect(existsSync(join(tmp,"test"))).toBe(true)
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)
})

104
index.ts
View File

@@ -1,53 +1,9 @@
import { z } from "zod"
import { parse, type TemplateVariable } from "./parser"
import { renderDir } from "./render"
import { renderDir, type RenderOptions } from "./render"
const zodTypeMap: Record<string, () => z.ZodType> = {
string: () => z.string(),
number: () => z.number(),
boolean: () => z.boolean(),
}
type ShapeNode = {
type?: string
children: Map<string, ShapeNode>
}
function buildTree(variables: TemplateVariable[]): ShapeNode {
const root: ShapeNode = { children: new Map() }
for (const v of variables) {
const segments = v.path.split(".")
let node = root
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!
if (!node.children.has(seg)) {
node.children.set(seg, { children: new Map() })
}
node = node.children.get(seg)!
if (i === segments.length - 1) {
node.type = v.type
}
}
}
return root
}
function nodeToSchema(node: ShapeNode): z.ZodType {
if (node.children.size === 0 && node.type) {
const factory = zodTypeMap[node.type]
if (!factory) throw new Error(`Unsupported type: ${node.type}`)
return factory()
}
const shape: Record<string, z.ZodType> = {}
for (const [key, child] of node.children) {
shape[key] = nodeToSchema(child)
}
return z.object(shape)
}
function inferSchema(variables: TemplateVariable[]): z.ZodType {
return nodeToSchema(buildTree(variables))
}
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
interface Stringable {
toString: () => string
@@ -60,12 +16,37 @@ interface Issue {
export class SchemaMismatchError extends Error {
constructor(issue: Issue) {
super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}` )
super(`Schema doesn't match used template variables: ${issue.path}: ${issue.message}` )
this.name = "SchemaMismatchError"
}
}
function zodTypeName(schema: z.ZodType): string {
if (schema instanceof z.ZodObject) return "object"
if (schema instanceof z.ZodString) return "string"
if (schema instanceof z.ZodBoolean) return "boolean"
if (schema instanceof z.ZodNumber) return "number"
return schema.constructor.name
}
function zodDisplayName(schema: z.ZodType): string {
const type = zodTypeName(schema)
return type.startsWith("Zod") ? type : `z.${type}()`
}
function setExpectedType(expected: Map<string, string>, path: string, type: string) {
const existing = expected.get(path)
if (existing && existing !== type) {
throw new SchemaMismatchError({
path,
message: `conflicting template variable types: expected both z.${existing}() and z.${type}()`,
})
}
expected.set(path, type)
}
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
if (userSchema._zod.def.type !== "object") {
if (!(userSchema instanceof z.ZodObject)) {
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
}
@@ -75,31 +56,29 @@ function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: Templa
const segments = v.path.split(".")
for (let i = 0; i < segments.length - 1; i++) {
const intermediate = segments.slice(0, i + 1).join(".")
if (!expected.has(intermediate)) {
expected.set(intermediate, "object")
}
setExpectedType(expected, intermediate, "object")
}
expected.set(v.path, v.type)
setExpectedType(expected, v.path, v.type)
}
for (const [path, expectedType] of expected) {
const segments = path.split(".")
let current = userSchema
let current: z.ZodType = userSchema
let currentPath = ""
for (const seg of segments) {
currentPath = currentPath ? `${currentPath}.${seg}` : seg
if (current._zod.def.type !== "object") {
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` })
if (!(current instanceof z.ZodObject)) {
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has ${zodDisplayName(current)}` })
}
const shape = (current as z.ZodObject<any>).shape
const shape = current.shape
if (!(seg in shape)) {
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
}
current = shape[seg]
current = shape[seg]!
}
const actual = current._zod.def.type as string
const actual = zodTypeName(current)
if (actual !== expectedType) {
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` })
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has ${zodDisplayName(current)}` })
}
}
}
@@ -109,12 +88,11 @@ export const initRenderer = (dirPath: string) => {
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
validateSchemaMatchesTemplates(userSchema, variables)
return (targetPath: string, context: z.infer<S>) => {
return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
userSchema.parse(context)
renderDir(dirPath, targetPath, context as Record<string, unknown>)
renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
}
}
return createRenderer
}

View File

@@ -1,10 +1,13 @@
{
"name": "@gregorlohaus/tdir",
"version": "0.1.0",
"version": "0.2.1",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"tdir": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
@@ -13,7 +16,7 @@
},
"files": ["dist"],
"scripts": {
"build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
"build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
"test": "bun test"
},
"devDependencies": {
@@ -22,5 +25,9 @@
},
"peerDependencies": {
"zod": "^4"
},
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
}
}

102
parser.ts
View File

@@ -1,42 +1,122 @@
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\(context\.(.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) {
vars.push({ path: match[1]!, type: "boolean" })
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
if (!expr) throw new Error("Missing condition expression")
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)
if (pathMatch) {
vars.push({ path: pathMatch[1]!, type: "boolean" })
return
}
throw new Error(`Invalid condition expression: ${expr}`)
}
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 walkDir(dirPath: string, vars: TemplateVariable[]) {
function validateIfBlocks(content: string, vars: TemplateVariable[], source: string) {
const stack: { sawElse: boolean }[] = []
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(`${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(`${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(`${source}: Unexpected <@endif> without <@if>`)
stack.pop()
}
}
if (stack.length > 0) {
throw new Error(`${source}: Unmatched <@if> without <@endif>`)
}
}
function isUtf8Text(buffer: Buffer): boolean {
if (buffer.indexOf(0) !== -1) return false
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
return true
} catch {
return false
}
}
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, "utf-8")
extractFromString(content, vars)
const content = readFileSync(fullPath)
if (isUtf8Text(content)) {
const text = content.toString("utf-8")
extractFromString(text, vars, relativePath || fullPath)
validateIfBlocks(text, vars, relativePath || fullPath)
}
}
}
}
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}`

594
render.ts
View File

@@ -1,11 +1,96 @@
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs"
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\(context\.(.+?)\)>(.*)$/
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function resolve(context: Record<string, unknown>, path: string): unknown {
export type ReverseMapToken = {
kind: "path" | "content" | "conditional"
result: string
token: string
contextPath?: string
outputPath: string
templatePath: string
range?: {
start: number
end: number
}
activeRange?: {
start: number
end: number
}
before?: string
after?: string
}
export type ReverseMapFile = {
outputPath: string
templatePath: string
tokens: ReverseMapToken[]
}
export type ReverseMapStoredTemplate = {
kind: "directory" | "file"
templatePath: string
encoding?: "utf8" | "base64"
content?: string
}
export type ReverseMapManifest = {
version: 1
files: ReverseMapFile[]
skipped: ReverseMapStoredTemplate[]
tokens: Record<string, string[]>
}
export type RenderOptions = {
reverseMap?: boolean | string
}
type RenderState = {
sourceRoot: string
destRoot: string
manifest?: ReverseMapManifest
}
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
if (!expr) throw new Error("Missing condition expression")
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) {
return !!resolveContext(context, pathMatch[1]!)
}
throw new Error(`Invalid condition expression: ${expr}`)
}
function resolveContext(context: Record<string, unknown>, path: string): unknown {
const segments = path.split(".")
let current: unknown = context
for (const seg of segments) {
@@ -15,107 +100,466 @@ function resolve(context: Record<string, unknown>, path: string): unknown {
return current
}
// Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean }
function isInsidePath(parent: string, child: string): boolean {
const rel = relative(parent, child)
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
}
function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = ""
let pos = 0
const stack: IfFrame[] = []
function assertSafeRenderTarget(srcDir: string, destDir: string) {
const sourceRoot = resolvePath(srcDir)
const destRoot = resolvePath(destDir)
const re = new RegExp(DIRECTIVE_RE.source, "g")
let match: RegExpExecArray | null
function isEmitting(): boolean {
return stack.every(f => f.active)
if (destRoot === resolvePath(destRoot, "..")) {
throw new Error("Refusing to render into filesystem root")
}
while ((match = re.exec(content)) !== null) {
const directive = match[1]!
const condPath = match[2]
if (destRoot === resolvePath(process.cwd())) {
throw new Error("Refusing to render into the current working directory")
}
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
const truthy = !!resolve(context, condPath!)
stack.push({ matched: truthy, active: truthy })
pos = re.lastIndex
} else if (directive === "elseif") {
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
const top = stack[stack.length - 1]!
// Emit text before this directive if current branch was active
if (isEmitting()) result += content.slice(pos, match.index)
if (top.matched) {
// A previous branch already matched — skip this one
top.active = false
} else {
const truthy = !!resolve(context, condPath!)
top.matched = truthy
top.active = truthy
}
pos = re.lastIndex
} else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]!
if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched
top.matched = true
pos = re.lastIndex
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
if (isEmitting()) result += content.slice(pos, match.index)
stack.pop()
pos = re.lastIndex
if (destRoot === resolvePath(homedir())) {
throw new Error("Refusing to render into the home directory")
}
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
throw new Error("Refusing to render when source and target directories overlap")
}
}
function resolveOutputPath(destRoot: string, outputName: string): string {
const outputPath = resolvePath(destRoot, outputName)
if (!isInsidePath(destRoot, outputPath)) {
throw new Error(`Refusing to write outside target directory: ${outputName}`)
}
return outputPath
}
function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], skipped: [], tokens: {} }
}
function addReverseMapToken(
state: RenderState | undefined,
file: ReverseMapFile | undefined,
token: ReverseMapToken,
) {
if (!state?.manifest || !file) return
file.tokens.push(token)
const tokens = state.manifest.tokens[token.result] ?? []
if (!tokens.includes(token.token)) tokens.push(token.token)
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)
}
function getOutputName(
entry: string,
context: Record<string, unknown>,
state?: RenderState,
file?: ReverseMapFile,
): string | null {
const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry
if (ifMatch) {
if (!evalCondition(ifMatch[1], context)) return null
outputName = ifMatch[2]!
if (ifMatch[0] !== outputName) {
addReverseMapToken(state, file, {
kind: "path",
result: outputName,
token: ifMatch[0],
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
})
}
}
if (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
return outputName.replace(VAR_RE, (_match, path: string) => {
const result = String(resolveContext(context, path) ?? "")
addReverseMapToken(state, file, {
kind: "path",
result,
token: _match,
contextPath: path,
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
})
return result
})
}
function isUtf8Text(buffer: Buffer): boolean {
if (buffer.indexOf(0) !== -1) return false
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
return true
} catch {
return false
}
}
type TextNode = {
type: "text"
text: string
}
type IfBranch = {
type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
type IfNode = {
type: "if"
sourceStart: number
sourceEnd: number
source: string
branches: IfBranch[]
}
type TemplateNode = TextNode | IfNode
type ConditionalRender = {
result: string
token: string
range: {
start: number
end: number
}
activeRange: {
start: number
end: number
}
}
function parseNodes(
content: string,
tokens: DirectiveToken[],
tokenIndex: number,
pos: number,
stopTypes: DirectiveToken["type"][],
): {
nodes: TemplateNode[]
pos: number
tokenIndex: number
stop?: DirectiveToken
} {
const nodes: TemplateNode[] = []
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex]!
if (stopTypes.includes(token.type)) {
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
return { nodes, pos: token.index, tokenIndex, stop: token }
}
if (token.type !== "if") {
throw new Error(`Unexpected <@${token.type}> without <@if>`)
}
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
const parsed = parseIfNode(content, tokens, tokenIndex)
nodes.push(parsed.node)
tokenIndex = parsed.tokenIndex
pos = parsed.pos
}
if (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) })
return { nodes, pos: content.length, tokenIndex }
}
function parseIfNode(content: string, tokens: DirectiveToken[], tokenIndex: number) {
const firstToken = tokens[tokenIndex]!
const sourceStart = firstToken.index
const branches: IfBranch[] = []
let branchType: IfBranch["type"] = "if"
let branchCondition = firstToken.condition
let branchContentStart = firstToken.end
tokenIndex += 1
while (true) {
const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"])
if (!parsed.stop) throw new Error("Unmatched <@if> without <@endif>")
branches.push({
type: branchType,
condition: branchCondition,
nodes: parsed.nodes,
contentStart: branchContentStart,
contentEnd: parsed.pos,
})
if (parsed.stop.type === "endif") {
const sourceEnd = parsed.stop.end
return {
node: {
type: "if" as const,
sourceStart,
sourceEnd,
source: content.slice(sourceStart, sourceEnd),
branches,
},
pos: sourceEnd,
tokenIndex: parsed.tokenIndex + 1,
}
}
branchType = parsed.stop.type
branchCondition = parsed.stop.condition
branchContentStart = parsed.stop.end
tokenIndex = parsed.tokenIndex + 1
}
}
function getActiveBranch(node: IfNode, context: Record<string, unknown>): IfBranch | undefined {
for (const branch of node.branches) {
if (branch.type === "else" || evalCondition(branch.condition, context)) return branch
}
return undefined
}
function renderNodes(
nodes: TemplateNode[],
context: Record<string, unknown>,
conditionalTokens: ConditionalRender[],
outputStart = 0,
): string {
let result = ""
for (const node of nodes) {
if (node.type === "text") {
result += node.text
continue
}
const activeBranch = getActiveBranch(node, context)
const start = outputStart + result.length
const renderedBranch = activeBranch
? renderNodes(activeBranch.nodes, context, conditionalTokens, start)
: ""
result += renderedBranch
conditionalTokens.push({
result: renderedBranch,
token: node.source,
range: { start, end: start + renderedBranch.length },
activeRange: activeBranch
? {
start: activeBranch.contentStart - node.sourceStart,
end: activeBranch.contentEnd - node.sourceStart,
}
: { start: node.source.length, end: node.source.length },
})
}
result += content.slice(pos)
return result
}
function processIfBlocksWithMap(content: string, context: Record<string, unknown>) {
const tokens = getDirectiveTokens(content)
const parsed = parseNodes(content, tokens, 0, 0, [])
const conditionalTokens: ConditionalRender[] = []
return {
content: renderNodes(parsed.nodes, context, conditionalTokens),
conditionalTokens,
}
}
function renderContent(content: string, context: Record<string, unknown>): string {
const processed = processIfBlocks(content, context)
return processed.replace(VAR_RE, (_match, path: string) => {
return String(resolve(context, path) ?? "")
})
return renderContentWithMap(content, context)
}
function renderContentWithMap(
content: string,
context: Record<string, unknown>,
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) {
addReverseMapToken(state, file, {
kind: "conditional",
result: token.result,
token: token.token,
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
range: token.range,
activeRange: token.activeRange,
before: processed.slice(0, token.range.start),
after: processed.slice(token.range.end),
})
}
let result = ""
let pos = 0
for (const match of processed.matchAll(VAR_RE)) {
const token = match[0]
const path = match[1]!
const rendered = String(resolveContext(context, path) ?? "")
result += processed.slice(pos, match.index)
const start = result.length
result += rendered
addReverseMapToken(state, file, {
kind: "content",
result: rendered,
token,
contextPath: path,
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
range: { start, end: start + rendered.length },
})
pos = match.index! + token.length
}
result += processed.slice(pos)
return result
}
function renderDir(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
options: RenderOptions = {},
) {
const destRoot = resolvePath(destDir)
const sourceRoot = resolvePath(srcDir)
const state: RenderState = {
sourceRoot,
destRoot,
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
}
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
assertSafeRenderTarget(srcDir, destDir)
validateOutputPaths(srcDir, destRoot, context)
rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destRoot, context, state)
if (state.manifest && mapPath) {
mkdirSync(dirname(mapPath), { recursive: true })
writeFileSync(mapPath, `${JSON.stringify(state.manifest, null, 2)}\n`)
}
}
function validateOutputPaths(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
const outputName = getOutputName(entry, context)
if (outputName === null) continue
const destPath = resolveOutputPath(destDir, outputName)
if (stat.isDirectory()) {
validateOutputPaths(srcPath, destPath, context)
}
}
}
function storeSkippedTemplate(srcPath: string, state: RenderState) {
if (!state.manifest) return
const stat = statSync(srcPath)
const templatePath = relative(state.sourceRoot, srcPath)
if (stat.isDirectory()) {
state.manifest.skipped.push({ kind: "directory", templatePath })
for (const entry of readdirSync(srcPath).sort()) {
storeSkippedTemplate(resolvePath(srcPath, entry), state)
}
return
}
if (!stat.isFile()) return
const content = readFileSync(srcPath)
if (isUtf8Text(content)) {
state.manifest.skipped.push({
kind: "file",
templatePath,
encoding: "utf8",
content: content.toString("utf-8"),
})
} else {
state.manifest.skipped.push({
kind: "file",
templatePath,
encoding: "base64",
content: content.toString("base64"),
})
}
}
function renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
state: RenderState,
) {
mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = join(srcDir, entry)
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
// Check if path segment has an @if directive
const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry
if (ifMatch) {
const conditionPath = ifMatch[1]!
if (!resolve(context, conditionPath)) continue
outputName = ifMatch[2]!
const templatePath = relative(state.sourceRoot, srcPath)
const tempFile: ReverseMapFile = {
outputPath: "",
templatePath,
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
}
// Process @var in the output name
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
return String(resolve(context, path) ?? "")
})
const destPath = resolveOutputPath(destDir, outputName)
const outputPath = relative(state.destRoot, destPath)
tempFile.outputPath = outputPath
for (const token of tempFile.tokens) {
token.outputPath = outputPath
token.templatePath = templatePath
}
const destPath = join(destDir, outputName)
if (stat.isDirectory()) {
renderDir(srcPath, destPath, context)
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
renderDirInner(srcPath, destPath, context, state)
} else {
mkdirSync(destDir, { recursive: true })
const content = readFileSync(srcPath, "utf-8")
writeFileSync(destPath, renderContent(content, context))
mkdirSync(dirname(destPath), { recursive: true })
const content = readFileSync(srcPath)
if (isUtf8Text(content)) {
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
writeFileSync(destPath, rendered)
} else {
copyFileSync(srcPath, destPath)
}
if (state.manifest) state.manifest.files.push(tempFile)
}
}
}

466
reverse.ts Normal file
View File

@@ -0,0 +1,466 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs"
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { TextDecoder } from "node:util"
import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export type ReverseOptions = {
mapPath?: string
include?: string | string[]
}
export type ReverseWarning = {
outputPath: string
token: string
result: string
message: string
}
export type ReverseResult = {
filesWritten: number
warnings: ReverseWarning[]
}
function isInsidePath(parent: string, child: string): boolean {
const rel = relative(parent, child)
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
}
function resolveInside(root: string, path: string): string {
const resolved = resolvePath(root, path)
if (!isInsidePath(root, resolved)) {
throw new Error(`Refusing to write outside target directory: ${path}`)
}
return resolved
}
function isUtf8Text(buffer: Buffer): boolean {
if (buffer.indexOf(0) !== -1) return false
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
return true
} catch {
return false
}
}
function readManifest(mapPath: string): ReverseMapManifest {
const manifest = JSON.parse(readFileSync(mapPath, "utf-8")) as ReverseMapManifest
if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
throw new Error(`Unsupported reverse map: ${mapPath}`)
}
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
if (start < 0 || end < start || end > content.length) return null
if (content.slice(start, end) !== token.result) return null
return `${content.slice(0, start)}${token.token}${content.slice(end)}`
}
function replaceFirst(content: string, token: ReverseMapToken): string | null {
const index = content.indexOf(token.result)
if (index === -1) return null
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`
}
function applyActiveBranch(token: ReverseMapToken, branchContent: string): string {
if (!token.activeRange) return token.token
return [
token.token.slice(0, token.activeRange.start),
branchContent,
token.token.slice(token.activeRange.end),
].join("")
}
function findPrefixEnd(content: string, before: string): number {
if (before === "") return 0
let candidate = before
while (candidate.length >= 8) {
const index = content.indexOf(candidate)
if (index !== -1) return index + candidate.length
candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)))
}
return -1
}
function findSuffixStart(content: string, after: string, from: number): number {
if (after === "") return content.length
let candidate = after
while (candidate.length >= 8) {
const index = content.indexOf(candidate, from)
if (index !== -1) return index
candidate = candidate.slice(0, Math.floor(candidate.length / 2))
}
return -1
}
function replaceConditional(content: string, token: ReverseMapToken): string | null {
const exactIndex = content.indexOf(token.result)
if (exactIndex !== -1) {
return [
content.slice(0, exactIndex),
token.token,
content.slice(exactIndex + token.result.length),
].join("")
}
if (token.before === undefined || token.after === undefined) return null
const branchStart = findPrefixEnd(content, token.before)
if (branchStart === -1) return null
const afterIndex = findSuffixStart(content, token.after, branchStart)
if (afterIndex === -1) return null
return [
content.slice(0, branchStart),
applyActiveBranch(token, content.slice(branchStart, afterIndex)),
content.slice(afterIndex),
].join("")
}
function reverseContent(
content: string,
file: ReverseMapFile,
warnings: ReverseWarning[],
): string {
const contentTokens = file.tokens
.filter(token => token.kind === "content")
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
let reversed = content
for (const token of contentTokens) {
const rangeResult = replaceAtRange(reversed, token)
if (rangeResult !== null) {
reversed = rangeResult
continue
}
const fallbackResult = replaceFirst(reversed, token)
if (fallbackResult !== null) {
reversed = fallbackResult
continue
}
warnings.push({
outputPath: file.outputPath,
token: token.token,
result: token.result,
message: "Rendered value was not found; token was not restored",
})
}
const conditionalTokens = file.tokens
.filter(token => token.kind === "conditional")
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
for (const token of conditionalTokens) {
const result = replaceConditional(reversed, token)
if (result !== null) {
reversed = result
continue
}
warnings.push({
outputPath: file.outputPath,
token: token.token,
result: token.result,
message: "Rendered conditional block was not found; block was not restored",
})
}
return reversed
}
function writeSkippedTemplate(
templateRoot: string,
skipped: ReverseMapStoredTemplate,
): number {
const templatePath = resolveInside(templateRoot, skipped.templatePath)
if (skipped.kind === "directory") {
mkdirSync(templatePath, { recursive: true })
return 0
}
mkdirSync(dirname(templatePath), { recursive: true })
const content = skipped.encoding === "base64"
? Buffer.from(skipped.content ?? "", "base64")
: skipped.content ?? ""
writeFileSync(templatePath, content)
return 1
}
function replaceFlatTokens(content: string, manifest: ReverseMapManifest): string {
const entries = Object.entries(manifest.tokens)
.filter(([, tokens]) => tokens.length > 0)
.sort(([a], [b]) => b.length - a.length)
let result = content
for (const [rendered, tokens] of entries) {
if (rendered === "") continue
result = result.split(rendered).join(tokens[0]!)
}
return result
}
function dirnamePath(path: string): string {
const normalized = normalizePath(path)
const index = normalized.lastIndexOf("/")
return index === -1 ? "" : normalized.slice(0, index)
}
function basenamePath(path: string): string {
const normalized = normalizePath(path)
const index = normalized.lastIndexOf("/")
return index === -1 ? normalized : normalized.slice(index + 1)
}
function joinPath(...parts: string[]): string {
return parts
.filter(part => part !== "")
.join("/")
}
function buildDirectoryMap(manifest: ReverseMapManifest): Map<string, string> {
const mappings = new Map<string, string>([["", ""]])
function addMapping(outputDir: string, templateDir: string) {
const outputParts = normalizePath(outputDir).split("/").filter(Boolean)
const templateParts = normalizePath(templateDir).split("/").filter(Boolean)
for (let i = 1; i <= outputParts.length; i++) {
if (i <= templateParts.length) {
mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/"))
}
}
mappings.set(normalizePath(outputDir), normalizePath(templateDir))
}
for (const file of manifest.files) {
const output = normalizePath(file.outputPath)
const template = normalizePath(file.templatePath)
addMapping(dirnamePath(output), dirnamePath(template))
if (file.tokens.some(token => token.kind === "path")) addMapping(output, template)
}
for (const skipped of manifest.skipped ?? []) {
if (skipped.kind === "directory") continue
addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath))
}
return mappings
}
function inferTemplatePath(outputPath: string, directoryMap: Map<string, string>): string {
const normalized = normalizePath(outputPath)
const outputDir = dirnamePath(normalized)
let bestOutputDir = ""
let bestTemplateDir = ""
for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) {
if (
mappedOutputDir.length >= bestOutputDir.length
&& (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`))
) {
bestOutputDir = mappedOutputDir
bestTemplateDir = mappedTemplateDir
}
}
const suffix = bestOutputDir === ""
? outputDir
: outputDir.slice(bestOutputDir.length).replace(/^\//, "")
return joinPath(bestTemplateDir, suffix, basenamePath(normalized))
}
function walkFiles(root: string, excludedRoots: string[] = []): string[] {
const files: string[] = []
const pending = [root]
while (pending.length > 0) {
const current = pending.pop()!
for (const entry of readdirSync(current).sort()) {
const path = resolvePath(current, entry)
if (excludedRoots.some(excluded => isInsidePath(excluded, path))) continue
const stat = statSync(path)
if (stat.isDirectory()) {
pending.push(path)
} else if (stat.isFile()) {
files.push(normalizePath(relative(root, path)))
}
}
}
return files
}
function copyIncludedRenderedFiles(
renderedRoot: string,
templateRoot: string,
includedOutputPaths: string[],
directoryMap: Map<string, string>,
manifest: ReverseMapManifest,
): number {
let filesWritten = 0
for (const outputPath of includedOutputPaths) {
const renderedPath = resolveInside(renderedRoot, outputPath)
const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap))
mkdirSync(dirname(templatePath), { recursive: true })
const content = readFileSync(renderedPath)
if (isUtf8Text(content)) {
writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest))
} else {
copyFileSync(renderedPath, templatePath)
}
filesWritten += 1
}
return filesWritten
}
function getIncludedRenderedFiles(
renderedRoot: string,
templateRoot: string,
mapPath: string,
manifest: ReverseMapManifest,
include: ReverseOptions["include"],
): string[] {
const matchers = getIncludeMatchers(include)
if (matchers.length === 0) return []
const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath)))
const mapRelativePath = normalizePath(relative(renderedRoot, mapPath))
return walkFiles(renderedRoot, [templateRoot]).filter(outputPath => {
return outputPath !== mapRelativePath
&& !mappedOutputPaths.has(outputPath)
&& matchesAny(outputPath, matchers)
})
}
export function reverseDir(
renderedDir: string,
templateDir: string,
options: ReverseOptions = {},
): ReverseResult {
const renderedRoot = resolvePath(renderedDir)
const templateRoot = resolvePath(templateDir)
const mapPath = options.mapPath
? 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
for (const file of manifest.files) {
const renderedPath = resolveInside(renderedRoot, file.outputPath)
const templatePath = resolveInside(templateRoot, file.templatePath)
if (!existsSync(renderedPath)) {
warnings.push({
outputPath: file.outputPath,
token: "",
result: "",
message: "Rendered path does not exist; skipped",
})
continue
}
const stat = statSync(renderedPath)
if (stat.isDirectory()) {
mkdirSync(templatePath, { recursive: true })
continue
}
if (!stat.isFile()) continue
mkdirSync(dirname(templatePath), { recursive: true })
const content = readFileSync(renderedPath)
if (!isUtf8Text(content)) {
copyFileSync(renderedPath, templatePath)
filesWritten += 1
continue
}
writeFileSync(
templatePath,
reverseContent(content.toString("utf-8"), file, warnings),
)
filesWritten += 1
}
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
includedOutputPaths,
directoryMap,
manifest,
)
return { filesWritten, warnings }
}

99
scanner.ts Normal file
View File

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

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

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

View File

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

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

View File

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

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

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

View File

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