@gregorlohaus/tdir 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tdir
2
2
 
3
- 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.
3
+ 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.
4
4
 
5
5
  ## Install
6
6
 
@@ -32,7 +32,7 @@ Where `index.html` contains:
32
32
  Render it:
33
33
 
34
34
  ```ts
35
- import { initRenderer } from "tdir"
35
+ import { initRenderer } from "@gregorlohaus/tdir"
36
36
  import { z } from "zod"
37
37
 
38
38
  const createRenderer = initRenderer("./templates")
@@ -58,8 +58,9 @@ render("./output", {
58
58
 
59
59
  | Directive | Description |
60
60
  |---|---|
61
- | `<@if(context.x)>` | Conditional block (must end with `<@endif>`) |
62
- | `<@elseif(context.y)>` | Else-if branch |
61
+ | `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
62
+ | `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
63
+ | `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
63
64
  | `<@else>` | Else branch |
64
65
  | `<@endif>` | End conditional block |
65
66
  | `<@var(context.x)>` | Substitute with context value (default type: `string`) |
@@ -69,7 +70,8 @@ render("./output", {
69
70
 
70
71
  | Directive | Description |
71
72
  |---|---|
72
- | `<@if(context.x)>dirname` | Conditionally include directory/file |
73
+ | `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
74
+ | `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
73
75
  | `<@var(context.x)>` | Dynamic directory/file name |
74
76
 
75
77
  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
79
81
  `createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
80
82
 
81
83
  ```ts
82
- import { initRenderer, SchemaMismatchError } from "tdir"
84
+ import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
83
85
  import { z } from "zod"
84
86
 
85
87
  const createRenderer = initRenderer("./templates")
@@ -90,14 +92,14 @@ createRenderer(z.object({
90
92
  web: z.string(), // wrong type
91
93
  header: z.object({ show: z.boolean(), title: z.string() })
92
94
  }))
93
- // SchemaMismatchError: Shema doesnt match used template variables: web: expected z.boolean() but schema has z.string()
95
+ // SchemaMismatchError: Schema doesn't match used template variables: web: expected z.boolean() but schema has z.string()
94
96
 
95
97
  // Schema is missing fields used in templates -- throws SchemaMismatchError
96
98
  createRenderer(z.object({
97
99
  web: z.boolean()
98
100
  // missing header
99
101
  }))
100
- // SchemaMismatchError: Shema doesnt match used template variables: header: missing in schema
102
+ // SchemaMismatchError: Schema doesn't match used template variables: header: missing in schema
101
103
  ```
102
104
 
103
105
  ## Context validation
@@ -117,12 +119,86 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
117
119
  // ZodError: expected boolean, received string at "web"
118
120
  ```
119
121
 
122
+ ## Re-rendering
123
+
124
+ `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).
125
+
126
+ 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.
127
+
128
+ ## Reverse maps
129
+
130
+ Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
131
+
132
+ ```ts
133
+ render("./output", {
134
+ web: true,
135
+ header: { show: true, title: "Hello" }
136
+ }, { reverseMap: true })
137
+ ```
138
+
139
+ Pass a string to choose a custom JSON path inside the output directory:
140
+
141
+ ```ts
142
+ render("./output", context, { reverseMap: "meta/reverse-map.json" })
143
+ ```
144
+
145
+ The map contains a flat lookup from rendered strings to template tokens plus per-file occurrences with path/range context:
146
+
147
+ ```json
148
+ {
149
+ "version": 1,
150
+ "files": [
151
+ {
152
+ "outputPath": "web/index.html",
153
+ "templatePath": "<@if(context.web)>web/index.html",
154
+ "tokens": [
155
+ {
156
+ "kind": "content",
157
+ "result": "Hello",
158
+ "token": "<@var(context.header.title)>",
159
+ "contextPath": "header.title",
160
+ "outputPath": "web/index.html",
161
+ "templatePath": "<@if(context.web)>web/index.html",
162
+ "range": { "start": 16, "end": 21 }
163
+ }
164
+ ]
165
+ }
166
+ ],
167
+ "tokens": {
168
+ "Hello": ["<@var(context.header.title)>"]
169
+ }
170
+ }
171
+ ```
172
+
173
+ ## Reverse CLI
174
+
175
+ Use the reverse map to rebuild template files from an edited rendered directory:
176
+
177
+ ```sh
178
+ tdir reverse ./output ./templates
179
+ ```
180
+
181
+ Without installing the package first, run the published CLI through Bun:
182
+
183
+ ```sh
184
+ bunx @gregorlohaus/tdir reverse ./output ./templates
185
+ ```
186
+
187
+ By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory:
188
+
189
+ ```sh
190
+ tdir reverse ./output ./templates --map meta/reverse-map.json
191
+ bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json
192
+ ```
193
+
194
+ 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.
195
+
120
196
  ## Unmatched directives
121
197
 
122
- A `<@if>` without a matching `<@endif>` throws at render time:
198
+ A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
123
199
 
124
200
  ```ts
125
201
  // If a template file contains <@if(context.x)> with no <@endif>
126
- render("./output", { x: true })
202
+ initRenderer("./templates")
127
203
  // Error: Unmatched <@if> without <@endif>
128
204
  ```
package/dist/cli.js ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+
3
+ // reverse.ts
4
+ import {
5
+ copyFileSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ statSync,
10
+ writeFileSync
11
+ } from "node:fs";
12
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
13
+ import { TextDecoder } from "node:util";
14
+ function isInsidePath(parent, child) {
15
+ const rel = relative(parent, child);
16
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
17
+ }
18
+ function resolveInside(root, path) {
19
+ const resolved = resolvePath(root, path);
20
+ if (!isInsidePath(root, resolved)) {
21
+ throw new Error(`Refusing to write outside target directory: ${path}`);
22
+ }
23
+ return resolved;
24
+ }
25
+ function isUtf8Text(buffer) {
26
+ if (buffer.indexOf(0) !== -1)
27
+ return false;
28
+ try {
29
+ new TextDecoder("utf-8", { fatal: true }).decode(buffer);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ function readManifest(mapPath) {
36
+ const manifest = JSON.parse(readFileSync(mapPath, "utf-8"));
37
+ if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
38
+ throw new Error(`Unsupported reverse map: ${mapPath}`);
39
+ }
40
+ return manifest;
41
+ }
42
+ function replaceAtRange(content, token) {
43
+ if (!token.range)
44
+ return null;
45
+ const { start, end } = token.range;
46
+ if (start < 0 || end < start || end > content.length)
47
+ return null;
48
+ if (content.slice(start, end) !== token.result)
49
+ return null;
50
+ return `${content.slice(0, start)}${token.token}${content.slice(end)}`;
51
+ }
52
+ function replaceFirst(content, token) {
53
+ const index = content.indexOf(token.result);
54
+ if (index === -1)
55
+ return null;
56
+ return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
57
+ }
58
+ function reverseContent(content, file, warnings) {
59
+ const tokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
60
+ let reversed = content;
61
+ for (const token of tokens) {
62
+ const rangeResult = replaceAtRange(reversed, token);
63
+ if (rangeResult !== null) {
64
+ reversed = rangeResult;
65
+ continue;
66
+ }
67
+ const fallbackResult = replaceFirst(reversed, token);
68
+ if (fallbackResult !== null) {
69
+ reversed = fallbackResult;
70
+ continue;
71
+ }
72
+ warnings.push({
73
+ outputPath: file.outputPath,
74
+ token: token.token,
75
+ result: token.result,
76
+ message: "Rendered value was not found; token was not restored"
77
+ });
78
+ }
79
+ return reversed;
80
+ }
81
+ function reverseDir(renderedDir, templateDir, options = {}) {
82
+ const renderedRoot = resolvePath(renderedDir);
83
+ const templateRoot = resolvePath(templateDir);
84
+ const mapPath = options.mapPath ? resolvePath(renderedRoot, options.mapPath) : resolvePath(renderedRoot, ".tdir-map.json");
85
+ const manifest = readManifest(mapPath);
86
+ const warnings = [];
87
+ let filesWritten = 0;
88
+ for (const file of manifest.files) {
89
+ const renderedPath = resolveInside(renderedRoot, file.outputPath);
90
+ const templatePath = resolveInside(templateRoot, file.templatePath);
91
+ if (!existsSync(renderedPath)) {
92
+ warnings.push({
93
+ outputPath: file.outputPath,
94
+ token: "",
95
+ result: "",
96
+ message: "Rendered path does not exist; skipped"
97
+ });
98
+ continue;
99
+ }
100
+ const stat = statSync(renderedPath);
101
+ if (stat.isDirectory()) {
102
+ mkdirSync(templatePath, { recursive: true });
103
+ continue;
104
+ }
105
+ if (!stat.isFile())
106
+ continue;
107
+ mkdirSync(dirname(templatePath), { recursive: true });
108
+ const content = readFileSync(renderedPath);
109
+ if (!isUtf8Text(content)) {
110
+ copyFileSync(renderedPath, templatePath);
111
+ filesWritten += 1;
112
+ continue;
113
+ }
114
+ writeFileSync(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
115
+ filesWritten += 1;
116
+ }
117
+ return { filesWritten, warnings };
118
+ }
119
+
120
+ // cli.ts
121
+ function printHelp() {
122
+ console.log(`tdir
123
+
124
+ Usage:
125
+ tdir reverse <rendered-dir> <template-dir> [--map <path>]
126
+
127
+ Commands:
128
+ reverse Rebuild template files from a rendered directory and reverse map
129
+
130
+ Options:
131
+ --map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
132
+ Relative paths are resolved from <rendered-dir>.
133
+ --help Show this help message.
134
+ `);
135
+ }
136
+ function parseReverseArgs(args) {
137
+ const positional = [];
138
+ let mapPath;
139
+ for (let i = 0;i < args.length; i++) {
140
+ const arg = args[i];
141
+ if (arg === "--help" || arg === "-h") {
142
+ return { help: true, positional, mapPath };
143
+ }
144
+ if (arg === "--map") {
145
+ const value = args[++i];
146
+ if (!value)
147
+ throw new Error("Missing value for --map");
148
+ mapPath = value;
149
+ continue;
150
+ }
151
+ positional.push(arg);
152
+ }
153
+ return { help: false, positional, mapPath };
154
+ }
155
+ function main(argv) {
156
+ const [command, ...args] = argv;
157
+ if (!command || command === "--help" || command === "-h") {
158
+ printHelp();
159
+ return 0;
160
+ }
161
+ if (command !== "reverse") {
162
+ throw new Error(`Unknown command: ${command}`);
163
+ }
164
+ const parsed = parseReverseArgs(args);
165
+ if (parsed.help) {
166
+ printHelp();
167
+ return 0;
168
+ }
169
+ const [renderedDir, templateDir] = parsed.positional;
170
+ if (!renderedDir || !templateDir || parsed.positional.length > 2) {
171
+ throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>]");
172
+ }
173
+ const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath });
174
+ console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`);
175
+ for (const warning of result.warnings) {
176
+ console.warn(`Warning: ${warning.outputPath}: ${warning.message}`);
177
+ }
178
+ return result.warnings.length > 0 ? 2 : 0;
179
+ }
180
+ try {
181
+ process.exitCode = main(process.argv.slice(2));
182
+ } catch (error) {
183
+ console.error(error instanceof Error ? error.message : String(error));
184
+ process.exitCode = 1;
185
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { type RenderOptions } from "./render";
3
+ export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render";
4
+ export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse";
2
5
  interface Stringable {
3
6
  toString: () => string;
4
7
  }
@@ -9,5 +12,4 @@ interface Issue {
9
12
  export declare class SchemaMismatchError extends Error {
10
13
  constructor(issue: Issue);
11
14
  }
12
- export declare const initRenderer: (dirPath: string) => <S extends z.ZodType>(userSchema: S) => (targetPath: string, context: z.infer<S>) => void;
13
- export {};
15
+ export declare const initRenderer: (dirPath: string) => <S extends z.ZodType>(userSchema: S) => (targetPath: string, context: z.infer<S>, options?: RenderOptions) => void;
package/dist/index.js CHANGED
@@ -4,16 +4,77 @@ import { z } from "zod";
4
4
  // parser.ts
5
5
  import { readdirSync, statSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- var IF_RE = /<@if\(context\.(.+?)\)>/g;
7
+ import { TextDecoder } from "node:util";
8
+ var IF_RE = /<@(?:if|elseif)\((.+?)\)>/g;
8
9
  var VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
10
+ var DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g;
11
+ var EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/;
12
+ var PATH_RE = /^context\.(.+)$/;
13
+ function extractCondition(expr, vars) {
14
+ if (!expr)
15
+ throw new Error("Missing condition expression");
16
+ const eqMatch = expr.match(EQ_RE);
17
+ if (eqMatch) {
18
+ vars.push({ path: eqMatch[1], type: "string" });
19
+ return;
20
+ }
21
+ const pathMatch = expr.match(PATH_RE);
22
+ if (pathMatch) {
23
+ vars.push({ path: pathMatch[1], type: "boolean" });
24
+ return;
25
+ }
26
+ throw new Error(`Invalid condition expression: ${expr}`);
27
+ }
9
28
  function extractFromString(text, vars) {
10
29
  for (const match of text.matchAll(IF_RE)) {
11
- vars.push({ path: match[1], type: "boolean" });
30
+ extractCondition(match[1], vars);
12
31
  }
13
32
  for (const match of text.matchAll(VAR_RE)) {
14
33
  vars.push({ path: match[1], type: match[2] ?? "string" });
15
34
  }
16
35
  }
36
+ function validateIfBlocks(content, vars) {
37
+ const stack = [];
38
+ for (const match of content.matchAll(DIRECTIVE_RE)) {
39
+ const directive = match[1];
40
+ const condition = match[2];
41
+ if (directive === "if") {
42
+ extractCondition(condition, vars);
43
+ stack.push({ sawElse: false });
44
+ } else if (directive === "elseif") {
45
+ const frame = stack[stack.length - 1];
46
+ if (!frame)
47
+ throw new Error("Unexpected <@elseif> without <@if>");
48
+ if (frame.sawElse)
49
+ throw new Error("Unexpected <@elseif> after <@else>");
50
+ extractCondition(condition, vars);
51
+ } else if (directive === "else") {
52
+ const frame = stack[stack.length - 1];
53
+ if (!frame)
54
+ throw new Error("Unexpected <@else> without <@if>");
55
+ if (frame.sawElse)
56
+ throw new Error("Unexpected duplicate <@else>");
57
+ frame.sawElse = true;
58
+ } else if (directive === "endif") {
59
+ if (stack.length === 0)
60
+ throw new Error("Unexpected <@endif> without <@if>");
61
+ stack.pop();
62
+ }
63
+ }
64
+ if (stack.length > 0) {
65
+ throw new Error("Unmatched <@if> without <@endif>");
66
+ }
67
+ }
68
+ function isUtf8Text(buffer) {
69
+ if (buffer.indexOf(0) !== -1)
70
+ return false;
71
+ try {
72
+ new TextDecoder("utf-8", { fatal: true }).decode(buffer);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
17
78
  function walkDir(dirPath, vars) {
18
79
  const entries = readdirSync(dirPath).sort();
19
80
  for (const entry of entries) {
@@ -23,8 +84,12 @@ function walkDir(dirPath, vars) {
23
84
  if (stat.isDirectory()) {
24
85
  walkDir(fullPath, vars);
25
86
  } else if (stat.isFile()) {
26
- const content = readFileSync(fullPath, "utf-8");
27
- extractFromString(content, vars);
87
+ const content = readFileSync(fullPath);
88
+ if (isUtf8Text(content)) {
89
+ const text = content.toString("utf-8");
90
+ extractFromString(text, vars);
91
+ validateIfBlocks(text, vars);
92
+ }
28
93
  }
29
94
  }
30
95
  }
@@ -42,12 +107,37 @@ function parse(dirPath) {
42
107
  }
43
108
 
44
109
  // render.ts
45
- import { readdirSync as readdirSync2, statSync as statSync2, readFileSync as readFileSync2, mkdirSync, writeFileSync } from "node:fs";
46
- import { join as join2 } from "node:path";
47
- var IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/;
110
+ import {
111
+ copyFileSync,
112
+ mkdirSync,
113
+ readFileSync as readFileSync2,
114
+ readdirSync as readdirSync2,
115
+ rmSync,
116
+ statSync as statSync2,
117
+ writeFileSync
118
+ } from "node:fs";
119
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
120
+ import { homedir } from "node:os";
121
+ import { TextDecoder as TextDecoder2 } from "node:util";
122
+ var IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/;
48
123
  var VAR_RE2 = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
49
- var DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g;
50
- function resolve(context, path) {
124
+ var DIRECTIVE_RE2 = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g;
125
+ var EQ_RE2 = /^eq\(context\.(.+?),\s*"(.*)"\)$/;
126
+ var PATH_RE2 = /^context\.(.+)$/;
127
+ function evalCondition(expr, context) {
128
+ if (!expr)
129
+ throw new Error("Missing condition expression");
130
+ const eqMatch = expr.match(EQ_RE2);
131
+ if (eqMatch) {
132
+ return resolveContext(context, eqMatch[1]) === eqMatch[2];
133
+ }
134
+ const pathMatch = expr.match(PATH_RE2);
135
+ if (pathMatch) {
136
+ return !!resolveContext(context, pathMatch[1]);
137
+ }
138
+ throw new Error(`Invalid condition expression: ${expr}`);
139
+ }
140
+ function resolveContext(context, path) {
51
141
  const segments = path.split(".");
52
142
  let current = context;
53
143
  for (const seg of segments) {
@@ -57,11 +147,95 @@ function resolve(context, path) {
57
147
  }
58
148
  return current;
59
149
  }
150
+ function isInsidePath(parent, child) {
151
+ const rel = relative(parent, child);
152
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
153
+ }
154
+ function assertSafeRenderTarget(srcDir, destDir) {
155
+ const sourceRoot = resolvePath(srcDir);
156
+ const destRoot = resolvePath(destDir);
157
+ if (destRoot === resolvePath(destRoot, "..")) {
158
+ throw new Error("Refusing to render into filesystem root");
159
+ }
160
+ if (destRoot === resolvePath(process.cwd())) {
161
+ throw new Error("Refusing to render into the current working directory");
162
+ }
163
+ if (destRoot === resolvePath(homedir())) {
164
+ throw new Error("Refusing to render into the home directory");
165
+ }
166
+ if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
167
+ throw new Error("Refusing to render when source and target directories overlap");
168
+ }
169
+ }
170
+ function resolveOutputPath(destRoot, outputName) {
171
+ const outputPath = resolvePath(destRoot, outputName);
172
+ if (!isInsidePath(destRoot, outputPath)) {
173
+ throw new Error(`Refusing to write outside target directory: ${outputName}`);
174
+ }
175
+ return outputPath;
176
+ }
177
+ function createReverseMapManifest() {
178
+ return { version: 1, files: [], tokens: {} };
179
+ }
180
+ function addReverseMapToken(state, file, token) {
181
+ if (!state?.manifest || !file)
182
+ return;
183
+ file.tokens.push(token);
184
+ const tokens = state.manifest.tokens[token.result] ?? [];
185
+ if (!tokens.includes(token.token))
186
+ tokens.push(token.token);
187
+ state.manifest.tokens[token.result] = tokens;
188
+ }
189
+ function getReverseMapPath(destRoot, reverseMap) {
190
+ if (reverseMap === true)
191
+ return resolveOutputPath(destRoot, ".tdir-map.json");
192
+ return resolveOutputPath(destRoot, reverseMap);
193
+ }
194
+ function getOutputName(entry, context, state, file) {
195
+ const ifMatch = entry.match(IF_PATH_RE);
196
+ let outputName = entry;
197
+ if (ifMatch) {
198
+ if (!evalCondition(ifMatch[1], context))
199
+ return null;
200
+ outputName = ifMatch[2];
201
+ if (ifMatch[0] !== outputName) {
202
+ addReverseMapToken(state, file, {
203
+ kind: "path",
204
+ result: outputName,
205
+ token: ifMatch[0],
206
+ outputPath: file?.outputPath ?? "",
207
+ templatePath: file?.templatePath ?? ""
208
+ });
209
+ }
210
+ }
211
+ return outputName.replace(VAR_RE2, (_match, path) => {
212
+ const result = String(resolveContext(context, path) ?? "");
213
+ addReverseMapToken(state, file, {
214
+ kind: "path",
215
+ result,
216
+ token: _match,
217
+ contextPath: path,
218
+ outputPath: file?.outputPath ?? "",
219
+ templatePath: file?.templatePath ?? ""
220
+ });
221
+ return result;
222
+ });
223
+ }
224
+ function isUtf8Text2(buffer) {
225
+ if (buffer.indexOf(0) !== -1)
226
+ return false;
227
+ try {
228
+ new TextDecoder2("utf-8", { fatal: true }).decode(buffer);
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
60
234
  function processIfBlocks(content, context) {
61
235
  let result = "";
62
236
  let pos = 0;
63
237
  const stack = [];
64
- const re = new RegExp(DIRECTIVE_RE.source, "g");
238
+ const re = new RegExp(DIRECTIVE_RE2.source, "g");
65
239
  let match;
66
240
  function isEmitting() {
67
241
  return stack.every((f) => f.active);
@@ -72,19 +246,21 @@ function processIfBlocks(content, context) {
72
246
  if (directive === "if") {
73
247
  if (isEmitting())
74
248
  result += content.slice(pos, match.index);
75
- const truthy = !!resolve(context, condPath);
76
- stack.push({ matched: truthy, active: truthy });
249
+ const truthy = evalCondition(condPath, context);
250
+ stack.push({ matched: truthy, active: truthy, sawElse: false });
77
251
  pos = re.lastIndex;
78
252
  } else if (directive === "elseif") {
79
253
  if (stack.length === 0)
80
254
  throw new Error("Unexpected <@elseif> without <@if>");
81
255
  const top = stack[stack.length - 1];
256
+ if (top.sawElse)
257
+ throw new Error("Unexpected <@elseif> after <@else>");
82
258
  if (isEmitting())
83
259
  result += content.slice(pos, match.index);
84
260
  if (top.matched) {
85
261
  top.active = false;
86
262
  } else {
87
- const truthy = !!resolve(context, condPath);
263
+ const truthy = evalCondition(condPath, context);
88
264
  top.matched = truthy;
89
265
  top.active = truthy;
90
266
  }
@@ -93,10 +269,13 @@ function processIfBlocks(content, context) {
93
269
  if (stack.length === 0)
94
270
  throw new Error("Unexpected <@else> without <@if>");
95
271
  const top = stack[stack.length - 1];
272
+ if (top.sawElse)
273
+ throw new Error("Unexpected duplicate <@else>");
96
274
  if (isEmitting())
97
275
  result += content.slice(pos, match.index);
98
276
  top.active = !top.matched;
99
277
  top.matched = true;
278
+ top.sawElse = true;
100
279
  pos = re.lastIndex;
101
280
  } else if (directive === "endif") {
102
281
  if (stack.length === 0)
@@ -113,48 +292,256 @@ function processIfBlocks(content, context) {
113
292
  result += content.slice(pos);
114
293
  return result;
115
294
  }
116
- function renderContent(content, context) {
295
+ function renderContentWithMap(content, context, state, file) {
117
296
  const processed = processIfBlocks(content, context);
118
- return processed.replace(VAR_RE2, (_match, path) => {
119
- return String(resolve(context, path) ?? "");
120
- });
297
+ let result = "";
298
+ let pos = 0;
299
+ for (const match of processed.matchAll(VAR_RE2)) {
300
+ const token = match[0];
301
+ const path = match[1];
302
+ const rendered = String(resolveContext(context, path) ?? "");
303
+ result += processed.slice(pos, match.index);
304
+ const start = result.length;
305
+ result += rendered;
306
+ addReverseMapToken(state, file, {
307
+ kind: "content",
308
+ result: rendered,
309
+ token,
310
+ contextPath: path,
311
+ outputPath: file?.outputPath ?? "",
312
+ templatePath: file?.templatePath ?? "",
313
+ range: { start, end: start + rendered.length }
314
+ });
315
+ pos = match.index + token.length;
316
+ }
317
+ result += processed.slice(pos);
318
+ return result;
319
+ }
320
+ function renderDir(srcDir, destDir, context, options = {}) {
321
+ const destRoot = resolvePath(destDir);
322
+ const sourceRoot = resolvePath(srcDir);
323
+ const state = {
324
+ sourceRoot,
325
+ destRoot,
326
+ manifest: options.reverseMap ? createReverseMapManifest() : undefined
327
+ };
328
+ const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined;
329
+ assertSafeRenderTarget(srcDir, destDir);
330
+ validateOutputPaths(srcDir, destRoot, context);
331
+ rmSync(destDir, { recursive: true, force: true });
332
+ renderDirInner(srcDir, destRoot, context, state);
333
+ if (state.manifest && mapPath) {
334
+ mkdirSync(dirname(mapPath), { recursive: true });
335
+ writeFileSync(mapPath, `${JSON.stringify(state.manifest, null, 2)}
336
+ `);
337
+ }
338
+ }
339
+ function validateOutputPaths(srcDir, destDir, context) {
340
+ const entries = readdirSync2(srcDir).sort();
341
+ for (const entry of entries) {
342
+ const srcPath = resolvePath(srcDir, entry);
343
+ const stat = statSync2(srcPath);
344
+ const outputName = getOutputName(entry, context);
345
+ if (outputName === null)
346
+ continue;
347
+ const destPath = resolveOutputPath(destDir, outputName);
348
+ if (stat.isDirectory()) {
349
+ validateOutputPaths(srcPath, destPath, context);
350
+ }
351
+ }
121
352
  }
122
- function renderDir(srcDir, destDir, context) {
353
+ function renderDirInner(srcDir, destDir, context, state) {
123
354
  mkdirSync(destDir, { recursive: true });
124
355
  const entries = readdirSync2(srcDir).sort();
125
356
  for (const entry of entries) {
126
- const srcPath = join2(srcDir, entry);
357
+ const srcPath = resolvePath(srcDir, entry);
127
358
  const stat = statSync2(srcPath);
128
- const ifMatch = entry.match(IF_PATH_RE);
129
- let outputName = entry;
130
- if (ifMatch) {
131
- const conditionPath = ifMatch[1];
132
- if (!resolve(context, conditionPath))
133
- continue;
134
- outputName = ifMatch[2];
359
+ const templatePath = relative(state.sourceRoot, srcPath);
360
+ const tempFile = {
361
+ outputPath: "",
362
+ templatePath,
363
+ tokens: []
364
+ };
365
+ const outputName = getOutputName(entry, context, state, tempFile);
366
+ if (outputName === null)
367
+ continue;
368
+ const destPath = resolveOutputPath(destDir, outputName);
369
+ const outputPath = relative(state.destRoot, destPath);
370
+ tempFile.outputPath = outputPath;
371
+ for (const token of tempFile.tokens) {
372
+ token.outputPath = outputPath;
373
+ token.templatePath = templatePath;
135
374
  }
136
- outputName = outputName.replace(VAR_RE2, (_match, path) => {
137
- return String(resolve(context, path) ?? "");
138
- });
139
- const destPath = join2(destDir, outputName);
140
375
  if (stat.isDirectory()) {
141
- renderDir(srcPath, destPath, context);
376
+ if (tempFile.tokens.length > 0)
377
+ state.manifest?.files.push(tempFile);
378
+ renderDirInner(srcPath, destPath, context, state);
142
379
  } else {
143
- mkdirSync(destDir, { recursive: true });
144
- const content = readFileSync2(srcPath, "utf-8");
145
- writeFileSync(destPath, renderContent(content, context));
380
+ mkdirSync(dirname(destPath), { recursive: true });
381
+ const content = readFileSync2(srcPath);
382
+ if (isUtf8Text2(content)) {
383
+ const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile);
384
+ writeFileSync(destPath, rendered);
385
+ } else {
386
+ copyFileSync(srcPath, destPath);
387
+ }
388
+ if (tempFile.tokens.length > 0)
389
+ state.manifest?.files.push(tempFile);
146
390
  }
147
391
  }
148
392
  }
149
393
 
394
+ // reverse.ts
395
+ import {
396
+ copyFileSync as copyFileSync2,
397
+ existsSync,
398
+ mkdirSync as mkdirSync2,
399
+ readFileSync as readFileSync3,
400
+ statSync as statSync3,
401
+ writeFileSync as writeFileSync2
402
+ } from "node:fs";
403
+ import { dirname as dirname2, isAbsolute as isAbsolute2, relative as relative2, resolve as resolvePath2 } from "node:path";
404
+ import { TextDecoder as TextDecoder3 } from "node:util";
405
+ function isInsidePath2(parent, child) {
406
+ const rel = relative2(parent, child);
407
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
408
+ }
409
+ function resolveInside(root, path) {
410
+ const resolved = resolvePath2(root, path);
411
+ if (!isInsidePath2(root, resolved)) {
412
+ throw new Error(`Refusing to write outside target directory: ${path}`);
413
+ }
414
+ return resolved;
415
+ }
416
+ function isUtf8Text3(buffer) {
417
+ if (buffer.indexOf(0) !== -1)
418
+ return false;
419
+ try {
420
+ new TextDecoder3("utf-8", { fatal: true }).decode(buffer);
421
+ return true;
422
+ } catch {
423
+ return false;
424
+ }
425
+ }
426
+ function readManifest(mapPath) {
427
+ const manifest = JSON.parse(readFileSync3(mapPath, "utf-8"));
428
+ if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
429
+ throw new Error(`Unsupported reverse map: ${mapPath}`);
430
+ }
431
+ return manifest;
432
+ }
433
+ function replaceAtRange(content, token) {
434
+ if (!token.range)
435
+ return null;
436
+ const { start, end } = token.range;
437
+ if (start < 0 || end < start || end > content.length)
438
+ return null;
439
+ if (content.slice(start, end) !== token.result)
440
+ return null;
441
+ return `${content.slice(0, start)}${token.token}${content.slice(end)}`;
442
+ }
443
+ function replaceFirst(content, token) {
444
+ const index = content.indexOf(token.result);
445
+ if (index === -1)
446
+ return null;
447
+ return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
448
+ }
449
+ function reverseContent(content, file, warnings) {
450
+ const tokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
451
+ let reversed = content;
452
+ for (const token of tokens) {
453
+ const rangeResult = replaceAtRange(reversed, token);
454
+ if (rangeResult !== null) {
455
+ reversed = rangeResult;
456
+ continue;
457
+ }
458
+ const fallbackResult = replaceFirst(reversed, token);
459
+ if (fallbackResult !== null) {
460
+ reversed = fallbackResult;
461
+ continue;
462
+ }
463
+ warnings.push({
464
+ outputPath: file.outputPath,
465
+ token: token.token,
466
+ result: token.result,
467
+ message: "Rendered value was not found; token was not restored"
468
+ });
469
+ }
470
+ return reversed;
471
+ }
472
+ function reverseDir(renderedDir, templateDir, options = {}) {
473
+ const renderedRoot = resolvePath2(renderedDir);
474
+ const templateRoot = resolvePath2(templateDir);
475
+ const mapPath = options.mapPath ? resolvePath2(renderedRoot, options.mapPath) : resolvePath2(renderedRoot, ".tdir-map.json");
476
+ const manifest = readManifest(mapPath);
477
+ const warnings = [];
478
+ let filesWritten = 0;
479
+ for (const file of manifest.files) {
480
+ const renderedPath = resolveInside(renderedRoot, file.outputPath);
481
+ const templatePath = resolveInside(templateRoot, file.templatePath);
482
+ if (!existsSync(renderedPath)) {
483
+ warnings.push({
484
+ outputPath: file.outputPath,
485
+ token: "",
486
+ result: "",
487
+ message: "Rendered path does not exist; skipped"
488
+ });
489
+ continue;
490
+ }
491
+ const stat = statSync3(renderedPath);
492
+ if (stat.isDirectory()) {
493
+ mkdirSync2(templatePath, { recursive: true });
494
+ continue;
495
+ }
496
+ if (!stat.isFile())
497
+ continue;
498
+ mkdirSync2(dirname2(templatePath), { recursive: true });
499
+ const content = readFileSync3(renderedPath);
500
+ if (!isUtf8Text3(content)) {
501
+ copyFileSync2(renderedPath, templatePath);
502
+ filesWritten += 1;
503
+ continue;
504
+ }
505
+ writeFileSync2(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
506
+ filesWritten += 1;
507
+ }
508
+ return { filesWritten, warnings };
509
+ }
510
+
150
511
  // index.ts
151
512
  class SchemaMismatchError extends Error {
152
513
  constructor(issue) {
153
- super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}`);
514
+ super(`Schema doesn't match used template variables: ${issue.path}: ${issue.message}`);
515
+ this.name = "SchemaMismatchError";
154
516
  }
155
517
  }
518
+ function zodTypeName(schema) {
519
+ if (schema instanceof z.ZodObject)
520
+ return "object";
521
+ if (schema instanceof z.ZodString)
522
+ return "string";
523
+ if (schema instanceof z.ZodBoolean)
524
+ return "boolean";
525
+ if (schema instanceof z.ZodNumber)
526
+ return "number";
527
+ return schema.constructor.name;
528
+ }
529
+ function zodDisplayName(schema) {
530
+ const type = zodTypeName(schema);
531
+ return type.startsWith("Zod") ? type : `z.${type}()`;
532
+ }
533
+ function setExpectedType(expected, path, type) {
534
+ const existing = expected.get(path);
535
+ if (existing && existing !== type) {
536
+ throw new SchemaMismatchError({
537
+ path,
538
+ message: `conflicting template variable types: expected both z.${existing}() and z.${type}()`
539
+ });
540
+ }
541
+ expected.set(path, type);
542
+ }
156
543
  function validateSchemaMatchesTemplates(userSchema, variables) {
157
- if (userSchema._zod.def.type !== "object") {
544
+ if (!(userSchema instanceof z.ZodObject)) {
158
545
  throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" });
159
546
  }
160
547
  const expected = new Map;
@@ -162,11 +549,9 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
162
549
  const segments = v.path.split(".");
163
550
  for (let i = 0;i < segments.length - 1; i++) {
164
551
  const intermediate = segments.slice(0, i + 1).join(".");
165
- if (!expected.has(intermediate)) {
166
- expected.set(intermediate, "object");
167
- }
552
+ setExpectedType(expected, intermediate, "object");
168
553
  }
169
- expected.set(v.path, v.type);
554
+ setExpectedType(expected, v.path, v.type);
170
555
  }
171
556
  for (const [path, expectedType] of expected) {
172
557
  const segments = path.split(".");
@@ -174,8 +559,8 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
174
559
  let currentPath = "";
175
560
  for (const seg of segments) {
176
561
  currentPath = currentPath ? `${currentPath}.${seg}` : seg;
177
- if (current._zod.def.type !== "object") {
178
- throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type}()` });
562
+ if (!(current instanceof z.ZodObject)) {
563
+ throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has ${zodDisplayName(current)}` });
179
564
  }
180
565
  const shape = current.shape;
181
566
  if (!(seg in shape)) {
@@ -183,9 +568,9 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
183
568
  }
184
569
  current = shape[seg];
185
570
  }
186
- const actual = current._zod.def.type;
571
+ const actual = zodTypeName(current);
187
572
  if (actual !== expectedType) {
188
- throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` });
573
+ throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has ${zodDisplayName(current)}` });
189
574
  }
190
575
  }
191
576
  }
@@ -193,14 +578,15 @@ var initRenderer = (dirPath) => {
193
578
  const variables = parse(dirPath);
194
579
  const createRenderer = (userSchema) => {
195
580
  validateSchemaMatchesTemplates(userSchema, variables);
196
- return (targetPath, context) => {
581
+ return (targetPath, context, options) => {
197
582
  userSchema.parse(context);
198
- renderDir(dirPath, targetPath, context);
583
+ renderDir(dirPath, targetPath, context, options);
199
584
  };
200
585
  };
201
586
  return createRenderer;
202
587
  };
203
588
  export {
589
+ reverseDir,
204
590
  initRenderer,
205
591
  SchemaMismatchError
206
592
  };
package/dist/render.d.ts CHANGED
@@ -1,3 +1,28 @@
1
+ export type ReverseMapToken = {
2
+ kind: "path" | "content";
3
+ result: string;
4
+ token: string;
5
+ contextPath?: string;
6
+ outputPath: string;
7
+ templatePath: string;
8
+ range?: {
9
+ start: number;
10
+ end: number;
11
+ };
12
+ };
13
+ export type ReverseMapFile = {
14
+ outputPath: string;
15
+ templatePath: string;
16
+ tokens: ReverseMapToken[];
17
+ };
18
+ export type ReverseMapManifest = {
19
+ version: 1;
20
+ files: ReverseMapFile[];
21
+ tokens: Record<string, string[]>;
22
+ };
23
+ export type RenderOptions = {
24
+ reverseMap?: boolean | string;
25
+ };
1
26
  declare function renderContent(content: string, context: Record<string, unknown>): string;
2
- declare function renderDir(srcDir: string, destDir: string, context: Record<string, unknown>): void;
27
+ declare function renderDir(srcDir: string, destDir: string, context: Record<string, unknown>, options?: RenderOptions): void;
3
28
  export { renderDir, renderContent };
@@ -0,0 +1,14 @@
1
+ export type ReverseOptions = {
2
+ mapPath?: string;
3
+ };
4
+ export type ReverseWarning = {
5
+ outputPath: string;
6
+ token: string;
7
+ result: string;
8
+ message: string;
9
+ };
10
+ export type ReverseResult = {
11
+ filesWritten: number;
12
+ warnings: ReverseWarning[];
13
+ };
14
+ export declare function reverseDir(renderedDir: string, templateDir: string, options?: ReverseOptions): ReverseResult;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@gregorlohaus/tdir",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "tdir": "./dist/cli.js"
10
+ },
8
11
  "exports": {
9
12
  ".": {
10
13
  "import": "./dist/index.js",
@@ -13,7 +16,7 @@
13
16
  },
14
17
  "files": ["dist"],
15
18
  "scripts": {
16
- "build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
19
+ "build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
17
20
  "test": "bun test"
18
21
  },
19
22
  "devDependencies": {