12 Commits

Author SHA1 Message Date
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
Gregor Lohaus
020ac91ab9 correct readme, add lisence 2026-04-08 01:30:52 +02:00
Gregor Lohaus
b9f675d1b9 scoped package 2026-04-08 01:16:32 +02:00
16 changed files with 989 additions and 303 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
.pre-commit-config.yaml .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`.

View File

@@ -1,11 +1,11 @@
# tdir # 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 ## Install
```sh ```sh
bun install tdir zod bun add @gregorlohaus/tdir zod
``` ```
## Quick start ## Quick start
@@ -32,7 +32,7 @@ Where `index.html` contains:
Render it: Render it:
```ts ```ts
import { initRenderer } from "tdir" import { initRenderer } from "@gregorlohaus/tdir"
import { z } from "zod" import { z } from "zod"
const createRenderer = initRenderer("./templates") const createRenderer = initRenderer("./templates")
@@ -58,8 +58,9 @@ render("./output", {
| Directive | Description | | Directive | Description |
|---|---| |---|---|
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) | | `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
| `<@elseif(context.y)>` | Else-if branch | | `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
| `<@else>` | Else branch | | `<@else>` | Else branch |
| `<@endif>` | End conditional block | | `<@endif>` | End conditional block |
| `<@var(context.x)>` | Substitute with context value (default type: `string`) | | `<@var(context.x)>` | Substitute with context value (default type: `string`) |
@@ -69,7 +70,8 @@ render("./output", {
| Directive | Description | | 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 |
| `<@var(context.x)>` | Dynamic directory/file name | | `<@var(context.x)>` | Dynamic directory/file name |
These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true. These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true.
@@ -79,7 +81,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`: `createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
```ts ```ts
import { initRenderer, SchemaMismatchError } from "tdir" import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
import { z } from "zod" import { z } from "zod"
const createRenderer = initRenderer("./templates") const createRenderer = initRenderer("./templates")
@@ -90,14 +92,14 @@ createRenderer(z.object({
web: z.string(), // wrong type web: z.string(), // wrong type
header: z.object({ show: z.boolean(), title: z.string() }) 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 // Schema is missing fields used in templates -- throws SchemaMismatchError
createRenderer(z.object({ createRenderer(z.object({
web: z.boolean() web: z.boolean()
// missing header // 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 ## Context validation
@@ -117,12 +119,86 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
// ZodError: expected boolean, received string at "web" // 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 plus per-file occurrences with path/range context:
```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 }
}
]
}
],
"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
```
The command writes files at their original template paths and restores recorded `<@var(...)>` tokens in file contents and file paths. It does not infer conditional blocks that were removed during rendering; keep the original template structure when those blocks need to be preserved.
## Unmatched directives ## 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 ```ts
// If a template file contains <@if(context.x)> with no <@endif> // If a template file contains <@if(context.x)> with no <@endif>
render("./output", { x: true }) initRenderer("./templates")
// Error: Unmatched <@if> without <@endif> // Error: Unmatched <@if> without <@endif>
``` ```

View File

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

77
cli.ts Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import { reverseDir } from "./reverse"
function printHelp() {
console.log(`tdir
Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>]
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>.
--help Show this help message.
`)
}
function parseReverseArgs(args: string[]) {
const positional: 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 }
}
if (arg === "--map") {
const value = args[++i]
if (!value) throw new Error("Missing value for --map")
mapPath = value
continue
}
positional.push(arg)
}
return { help: false, positional, mapPath }
}
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>]")
}
const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath })
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": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1775585812, "lastModified": 1776271913,
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "35b8c42eb10c196dc84611852325c722b6f10750", "rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -16,71 +17,16 @@
"type": "github" "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": { "nixpkgs": {
"inputs": { "inputs": {
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1774287239, "lastModified": 1776097194,
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c", "rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -93,11 +39,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1769922788, "lastModified": 1775888245,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=", "narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e", "rev": "13043924aaa7375ce482ebe2494338e058282925",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -110,11 +56,7 @@
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks", "nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
} }
} }
}, },

View File

@@ -1,7 +1,7 @@
{ pkgs, lib, config, inputs, ... }: { pkgs, lib, config, inputs, ... }:
{ {
packages = [ pkgs.bun ]; packages = [ pkgs.bun pkgs.nodejs_24];
languages.typescript.enable = true; languages.typescript.enable = true;
} }

View File

@@ -1,8 +1,8 @@
import { expect, test, beforeEach, afterEach } from 'bun:test' import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, SchemaMismatchError } from "."; import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
import { z } from "zod" import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs" import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { join } from "node:path" import { basename, join } from "node:path"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
const ifExampleSchema = z.object({ const ifExampleSchema = z.object({
@@ -55,6 +55,120 @@ test("renders with web=true, header rendered", () => {
expect(content).toContain("<body>") 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: "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("<@var(context.header.text)>")
expect(reversed).toContain("Edited rendered output")
})
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("wrong schema throws error",() => { test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example") const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({ expect(() => createRenderer(z.object({
@@ -245,13 +359,78 @@ test("file if",() => {
}) })
test("no endif should throw",() => { test("no endif should throw",() => {
const createRenderer = initRenderer("./testdata/no_end_if") expect(() => initRenderer("./testdata/no_end_if")).toThrow()
})
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({ 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() })).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",() => { test("if elseif else",() => {
@@ -289,3 +468,11 @@ test("if elseif else",() => {
expect(content).toContain("test3") 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)
})

104
index.ts
View File

@@ -1,53 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { parse, type TemplateVariable } from "./parser" import { parse, type TemplateVariable } from "./parser"
import { renderDir } from "./render" import { renderDir, type RenderOptions } from "./render"
const zodTypeMap: Record<string, () => z.ZodType> = { export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
string: () => z.string(), export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
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))
}
interface Stringable { interface Stringable {
toString: () => string toString: () => string
@@ -60,12 +16,37 @@ interface Issue {
export class SchemaMismatchError extends Error { export class SchemaMismatchError extends Error {
constructor(issue: Issue) { 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[]) { 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()" }) 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(".") const segments = v.path.split(".")
for (let i = 0; i < segments.length - 1; i++) { for (let i = 0; i < segments.length - 1; i++) {
const intermediate = segments.slice(0, i + 1).join(".") const intermediate = segments.slice(0, i + 1).join(".")
if (!expected.has(intermediate)) { setExpectedType(expected, intermediate, "object")
expected.set(intermediate, "object")
}
} }
expected.set(v.path, v.type) setExpectedType(expected, v.path, v.type)
} }
for (const [path, expectedType] of expected) { for (const [path, expectedType] of expected) {
const segments = path.split(".") const segments = path.split(".")
let current = userSchema let current: z.ZodType = userSchema
let currentPath = "" let currentPath = ""
for (const seg of segments) { for (const seg of segments) {
currentPath = currentPath ? `${currentPath}.${seg}` : seg currentPath = currentPath ? `${currentPath}.${seg}` : seg
if (current._zod.def.type !== "object") { if (!(current instanceof z.ZodObject)) {
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` }) 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)) { if (!(seg in shape)) {
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` }) 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) { 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) => { const createRenderer = <S extends z.ZodType>(userSchema: S) => {
validateSchemaMatchesTemplates(userSchema, variables) validateSchemaMatchesTemplates(userSchema, variables)
return (targetPath: string, context: z.infer<S>) => { return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
userSchema.parse(context) userSchema.parse(context)
renderDir(dirPath, targetPath, context as Record<string, unknown>) renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
} }
} }
return createRenderer return createRenderer
} }

View File

@@ -1,14 +1,29 @@
{ {
"name": "tdir", "name": "@gregorlohaus/tdir",
"module": "index.ts", "version": "0.1.3",
"license": "MIT",
"type": "module", "type": "module",
"devDependencies": { "main": "./dist/index.js",
"@types/bun": "^1.3.11" "types": "./dist/index.d.ts",
"bin": {
"tdir": "./dist/cli.js"
}, },
"peerDependencies": { "exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.11",
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "peerDependencies": {
"zod": "^4.3.6" "zod": "^4"
} }
} }

View File

@@ -1,23 +1,83 @@
import { readdirSync, statSync, readFileSync } from "node:fs" import { readdirSync, statSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { TextDecoder } from "node:util"
export type TemplateVariable = { export type TemplateVariable = {
path: string path: string
type: string type: string
} }
const IF_RE = /<@if\(context\.(.+?)\)>/g const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(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" })
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[]) { function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) { for (const match of text.matchAll(IF_RE)) {
vars.push({ path: match[1]!, type: "boolean" }) extractCondition(match[1]!, vars)
} }
for (const match of text.matchAll(VAR_RE)) { for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" }) vars.push({ path: match[1]!, type: match[2] ?? "string" })
} }
} }
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
const stack: { sawElse: boolean }[] = []
for (const match of content.matchAll(DIRECTIVE_RE)) {
const directive = match[1]!
const condition = match[2]
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>")
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>")
frame.sawElse = true
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
stack.pop()
}
}
if (stack.length > 0) {
throw new Error("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[]) { function walkDir(dirPath: string, vars: TemplateVariable[]) {
const entries = readdirSync(dirPath).sort() const entries = readdirSync(dirPath).sort()
for (const entry of entries) { for (const entry of entries) {
@@ -28,8 +88,12 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
if (stat.isDirectory()) { if (stat.isDirectory()) {
walkDir(fullPath, vars) walkDir(fullPath, vars)
} else if (stat.isFile()) { } else if (stat.isFile()) {
const content = readFileSync(fullPath, "utf-8") const content = readFileSync(fullPath)
extractFromString(content, vars) if (isUtf8Text(content)) {
const text = content.toString("utf-8")
extractFromString(text, vars)
validateIfBlocks(text, vars)
}
} }
} }
} }

307
render.ts
View File

@@ -1,11 +1,71 @@
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs" import {
import { join } from "node:path" 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"
const IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/ const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function resolve(context: Record<string, unknown>, path: string): unknown { export type ReverseMapToken = {
kind: "path" | "content"
result: string
token: string
contextPath?: string
outputPath: string
templatePath: string
range?: {
start: number
end: number
}
}
export type ReverseMapFile = {
outputPath: string
templatePath: string
tokens: ReverseMapToken[]
}
export type ReverseMapManifest = {
version: 1
files: ReverseMapFile[]
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")
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
}
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(".") const segments = path.split(".")
let current: unknown = context let current: unknown = context
for (const seg of segments) { for (const seg of segments) {
@@ -15,8 +75,109 @@ function resolve(context: Record<string, unknown>, path: string): unknown {
return current return current
} }
function isInsidePath(parent: string, child: string): boolean {
const rel = relative(parent, child)
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
}
function assertSafeRenderTarget(srcDir: string, destDir: string) {
const sourceRoot = resolvePath(srcDir)
const destRoot = resolvePath(destDir)
if (destRoot === resolvePath(destRoot, "..")) {
throw new Error("Refusing to render into filesystem root")
}
if (destRoot === resolvePath(process.cwd())) {
throw new Error("Refusing to render into the current working directory")
}
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: [], 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 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 ?? "",
})
}
}
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
}
}
// Each stack frame tracks: did any branch match yet, is the current branch active // Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean } type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
function processIfBlocks(content: string, context: Record<string, unknown>): string { function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = "" let result = ""
@@ -36,19 +197,20 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
if (directive === "if") { if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index) if (isEmitting()) result += content.slice(pos, match.index)
const truthy = !!resolve(context, condPath!) const truthy = evalCondition(condPath, context)
stack.push({ matched: truthy, active: truthy }) stack.push({ matched: truthy, active: truthy, sawElse: false })
pos = re.lastIndex pos = re.lastIndex
} else if (directive === "elseif") { } else if (directive === "elseif") {
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
const top = stack[stack.length - 1]! const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
// Emit text before this directive if current branch was active // Emit text before this directive if current branch was active
if (isEmitting()) result += content.slice(pos, match.index) if (isEmitting()) result += content.slice(pos, match.index)
if (top.matched) { if (top.matched) {
// A previous branch already matched — skip this one // A previous branch already matched — skip this one
top.active = false top.active = false
} else { } else {
const truthy = !!resolve(context, condPath!) const truthy = evalCondition(condPath, context)
top.matched = truthy top.matched = truthy
top.active = truthy top.active = truthy
} }
@@ -56,9 +218,11 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
} else if (directive === "else") { } else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>") if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]! const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
if (isEmitting()) result += content.slice(pos, match.index) if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched top.active = !top.matched
top.matched = true top.matched = true
top.sawElse = true
pos = re.lastIndex pos = re.lastIndex
} else if (directive === "endif") { } else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>") if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
@@ -77,45 +241,128 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
} }
function renderContent(content: string, context: Record<string, unknown>): string { function renderContent(content: string, context: Record<string, unknown>): string {
return renderContentWithMap(content, context)
}
function renderContentWithMap(
content: string,
context: Record<string, unknown>,
state?: RenderState,
file?: ReverseMapFile,
): string {
const processed = processIfBlocks(content, context) const processed = processIfBlocks(content, context)
return processed.replace(VAR_RE, (_match, path: string) => { let result = ""
return String(resolve(context, path) ?? "") 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( function renderDir(
srcDir: string, srcDir: string,
destDir: string, destDir: string,
context: Record<string, unknown>, 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 renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
state: RenderState,
) { ) {
mkdirSync(destDir, { recursive: true }) mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort() const entries = readdirSync(srcDir).sort()
for (const entry of entries) { for (const entry of entries) {
const srcPath = join(srcDir, entry) const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath) const stat = statSync(srcPath)
const templatePath = relative(state.sourceRoot, srcPath)
const tempFile: ReverseMapFile = {
outputPath: "",
templatePath,
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) continue
// Check if path segment has an @if directive const destPath = resolveOutputPath(destDir, outputName)
const ifMatch = entry.match(IF_PATH_RE) const outputPath = relative(state.destRoot, destPath)
let outputName = entry tempFile.outputPath = outputPath
if (ifMatch) { for (const token of tempFile.tokens) {
const conditionPath = ifMatch[1]! token.outputPath = outputPath
if (!resolve(context, conditionPath)) continue token.templatePath = templatePath
outputName = ifMatch[2]!
} }
// Process @var in the output name
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
return String(resolve(context, path) ?? "")
})
const destPath = join(destDir, outputName)
if (stat.isDirectory()) { if (stat.isDirectory()) {
renderDir(srcPath, destPath, context) if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
renderDirInner(srcPath, destPath, context, state)
} else { } else {
mkdirSync(destDir, { recursive: true }) mkdirSync(dirname(destPath), { recursive: true })
const content = readFileSync(srcPath, "utf-8") const content = readFileSync(srcPath)
writeFileSync(destPath, renderContent(content, context)) if (isUtf8Text(content)) {
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
writeFileSync(destPath, rendered)
} else {
copyFileSync(srcPath, destPath)
}
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
} }
} }
} }

158
reverse.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs"
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { TextDecoder } from "node:util"
import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
export type ReverseOptions = {
mapPath?: 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 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 reverseContent(
content: string,
file: ReverseMapFile,
warnings: ReverseWarning[],
): string {
const tokens = 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 tokens) {
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",
})
}
return reversed
}
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 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
}
return { filesWritten, warnings }
}

View File

10
tsconfig.build.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist"
},
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"]
}