@gregorlohaus/tdir 0.1.3 → 0.1.5

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
@@ -142,7 +142,7 @@ Pass a string to choose a custom JSON path inside the output directory:
142
142
  render("./output", context, { reverseMap: "meta/reverse-map.json" })
143
143
  ```
144
144
 
145
- The map contains a flat lookup from rendered strings to template tokens plus per-file occurrences with path/range context:
145
+ 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:
146
146
 
147
147
  ```json
148
148
  {
@@ -160,10 +160,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per
160
160
  "outputPath": "web/index.html",
161
161
  "templatePath": "<@if(context.web)>web/index.html",
162
162
  "range": { "start": 16, "end": 21 }
163
+ },
164
+ {
165
+ "kind": "conditional",
166
+ "result": "<head><@var(context.header.title)></head>",
167
+ "token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
168
+ "outputPath": "web/index.html",
169
+ "templatePath": "<@if(context.web)>web/index.html",
170
+ "range": { "start": 9, "end": 53 },
171
+ "activeRange": { "start": 27, "end": 71 }
163
172
  }
164
173
  ]
165
174
  }
166
175
  ],
176
+ "skipped": [
177
+ {
178
+ "kind": "file",
179
+ "templatePath": "<@if(context.docs)>docs/readme.md",
180
+ "encoding": "utf8",
181
+ "content": "# Docs"
182
+ }
183
+ ],
167
184
  "tokens": {
168
185
  "Hello": ["<@var(context.header.title)>"]
169
186
  }
@@ -191,7 +208,22 @@ tdir reverse ./output ./templates --map meta/reverse-map.json
191
208
  bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json
192
209
  ```
193
210
 
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.
211
+ New files created in the rendered directory are ignored by default. Include them explicitly with one or more glob patterns:
212
+
213
+ ```sh
214
+ tdir reverse ./output ./templates --include "components/**"
215
+ tdir reverse ./output ./templates --include "components/**/*.ts" --include "pages/*.html"
216
+ ```
217
+
218
+ Programmatically, pass the same globs to `reverseDir`:
219
+
220
+ ```ts
221
+ reverseDir("./output", "./templates", {
222
+ include: ["components/**/*.ts", "pages/*.html"]
223
+ })
224
+ ```
225
+
226
+ 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.
195
227
 
196
228
  ## Unmatched directives
197
229
 
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  existsSync,
7
7
  mkdirSync,
8
8
  readFileSync,
9
+ readdirSync,
9
10
  statSync,
10
11
  writeFileSync
11
12
  } from "node:fs";
@@ -39,6 +40,45 @@ function readManifest(mapPath) {
39
40
  }
40
41
  return manifest;
41
42
  }
43
+ function normalizePath(path) {
44
+ return path.split("\\").join("/");
45
+ }
46
+ function escapeRegExp(text) {
47
+ return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
48
+ }
49
+ function globToRegExp(glob) {
50
+ let source = "^";
51
+ const pattern = normalizePath(glob);
52
+ for (let i = 0;i < pattern.length; i++) {
53
+ const char = pattern[i];
54
+ const next = pattern[i + 1];
55
+ if (char === "*" && next === "*") {
56
+ if (pattern[i + 2] === "/") {
57
+ source += "(?:.*/)?";
58
+ i += 2;
59
+ } else {
60
+ source += ".*";
61
+ i += 1;
62
+ }
63
+ } else if (char === "*") {
64
+ source += "[^/]*";
65
+ } else if (char === "?") {
66
+ source += "[^/]";
67
+ } else {
68
+ source += escapeRegExp(char);
69
+ }
70
+ }
71
+ return new RegExp(`${source}$`);
72
+ }
73
+ function getIncludeMatchers(include) {
74
+ if (!include)
75
+ return [];
76
+ return (Array.isArray(include) ? include : [include]).map(globToRegExp);
77
+ }
78
+ function matchesAny(path, matchers) {
79
+ const normalized = normalizePath(path);
80
+ return matchers.some((matcher) => matcher.test(normalized));
81
+ }
42
82
  function replaceAtRange(content, token) {
43
83
  if (!token.range)
44
84
  return null;
@@ -55,10 +95,66 @@ function replaceFirst(content, token) {
55
95
  return null;
56
96
  return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
57
97
  }
98
+ function applyActiveBranch(token, branchContent) {
99
+ if (!token.activeRange)
100
+ return token.token;
101
+ return [
102
+ token.token.slice(0, token.activeRange.start),
103
+ branchContent,
104
+ token.token.slice(token.activeRange.end)
105
+ ].join("");
106
+ }
107
+ function findPrefixEnd(content, before) {
108
+ if (before === "")
109
+ return 0;
110
+ let candidate = before;
111
+ while (candidate.length >= 8) {
112
+ const index = content.indexOf(candidate);
113
+ if (index !== -1)
114
+ return index + candidate.length;
115
+ candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)));
116
+ }
117
+ return -1;
118
+ }
119
+ function findSuffixStart(content, after, from) {
120
+ if (after === "")
121
+ return content.length;
122
+ let candidate = after;
123
+ while (candidate.length >= 8) {
124
+ const index = content.indexOf(candidate, from);
125
+ if (index !== -1)
126
+ return index;
127
+ candidate = candidate.slice(0, Math.floor(candidate.length / 2));
128
+ }
129
+ return -1;
130
+ }
131
+ function replaceConditional(content, token) {
132
+ const exactIndex = content.indexOf(token.result);
133
+ if (exactIndex !== -1) {
134
+ return [
135
+ content.slice(0, exactIndex),
136
+ token.token,
137
+ content.slice(exactIndex + token.result.length)
138
+ ].join("");
139
+ }
140
+ if (token.before === undefined || token.after === undefined)
141
+ return null;
142
+ const branchStart = findPrefixEnd(content, token.before);
143
+ if (branchStart === -1)
144
+ return null;
145
+ const afterIndex = findSuffixStart(content, token.after, branchStart);
146
+ if (afterIndex === -1)
147
+ return null;
148
+ return [
149
+ content.slice(0, branchStart),
150
+ applyActiveBranch(token, content.slice(branchStart, afterIndex)),
151
+ content.slice(afterIndex)
152
+ ].join("");
153
+ }
58
154
  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));
155
+ const contentTokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
60
156
  let reversed = content;
61
- for (const token of tokens) {
157
+ for (const token of contentTokens) {
62
158
  const rangeResult = replaceAtRange(reversed, token);
63
159
  if (rangeResult !== null) {
64
160
  reversed = rangeResult;
@@ -76,8 +172,122 @@ function reverseContent(content, file, warnings) {
76
172
  message: "Rendered value was not found; token was not restored"
77
173
  });
78
174
  }
175
+ const conditionalTokens = file.tokens.filter((token) => token.kind === "conditional").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
176
+ for (const token of conditionalTokens) {
177
+ const result = replaceConditional(reversed, token);
178
+ if (result !== null) {
179
+ reversed = result;
180
+ continue;
181
+ }
182
+ warnings.push({
183
+ outputPath: file.outputPath,
184
+ token: token.token,
185
+ result: token.result,
186
+ message: "Rendered conditional block was not found; block was not restored"
187
+ });
188
+ }
79
189
  return reversed;
80
190
  }
191
+ function writeSkippedTemplate(templateRoot, skipped) {
192
+ const templatePath = resolveInside(templateRoot, skipped.templatePath);
193
+ if (skipped.kind === "directory") {
194
+ mkdirSync(templatePath, { recursive: true });
195
+ return 0;
196
+ }
197
+ mkdirSync(dirname(templatePath), { recursive: true });
198
+ const content = skipped.encoding === "base64" ? Buffer.from(skipped.content ?? "", "base64") : skipped.content ?? "";
199
+ writeFileSync(templatePath, content);
200
+ return 1;
201
+ }
202
+ function dirnamePath(path) {
203
+ const normalized = normalizePath(path);
204
+ const index = normalized.lastIndexOf("/");
205
+ return index === -1 ? "" : normalized.slice(0, index);
206
+ }
207
+ function basenamePath(path) {
208
+ const normalized = normalizePath(path);
209
+ const index = normalized.lastIndexOf("/");
210
+ return index === -1 ? normalized : normalized.slice(index + 1);
211
+ }
212
+ function joinPath(...parts) {
213
+ return parts.filter((part) => part !== "").join("/");
214
+ }
215
+ function buildDirectoryMap(manifest) {
216
+ const mappings = new Map([["", ""]]);
217
+ function addMapping(outputDir, templateDir) {
218
+ const outputParts = normalizePath(outputDir).split("/").filter(Boolean);
219
+ const templateParts = normalizePath(templateDir).split("/").filter(Boolean);
220
+ for (let i = 1;i <= outputParts.length; i++) {
221
+ if (i <= templateParts.length) {
222
+ mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/"));
223
+ }
224
+ }
225
+ mappings.set(normalizePath(outputDir), normalizePath(templateDir));
226
+ }
227
+ for (const file of manifest.files) {
228
+ const output = normalizePath(file.outputPath);
229
+ const template = normalizePath(file.templatePath);
230
+ addMapping(dirnamePath(output), dirnamePath(template));
231
+ if (file.tokens.some((token) => token.kind === "path"))
232
+ addMapping(output, template);
233
+ }
234
+ for (const skipped of manifest.skipped ?? []) {
235
+ if (skipped.kind === "directory")
236
+ continue;
237
+ addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath));
238
+ }
239
+ return mappings;
240
+ }
241
+ function inferTemplatePath(outputPath, directoryMap) {
242
+ const normalized = normalizePath(outputPath);
243
+ const outputDir = dirnamePath(normalized);
244
+ let bestOutputDir = "";
245
+ let bestTemplateDir = "";
246
+ for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) {
247
+ if (mappedOutputDir.length >= bestOutputDir.length && (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`))) {
248
+ bestOutputDir = mappedOutputDir;
249
+ bestTemplateDir = mappedTemplateDir;
250
+ }
251
+ }
252
+ const suffix = bestOutputDir === "" ? outputDir : outputDir.slice(bestOutputDir.length).replace(/^\//, "");
253
+ return joinPath(bestTemplateDir, suffix, basenamePath(normalized));
254
+ }
255
+ function walkFiles(root, current = root) {
256
+ const files = [];
257
+ for (const entry of readdirSync(current).sort()) {
258
+ const path = resolvePath(current, entry);
259
+ const stat = statSync(path);
260
+ if (stat.isDirectory()) {
261
+ files.push(...walkFiles(root, path));
262
+ } else if (stat.isFile()) {
263
+ files.push(normalizePath(relative(root, path)));
264
+ }
265
+ }
266
+ return files;
267
+ }
268
+ function copyIncludedRenderedFiles(renderedRoot, templateRoot, mapPath, manifest, include) {
269
+ const matchers = getIncludeMatchers(include);
270
+ if (matchers.length === 0)
271
+ return 0;
272
+ const mappedOutputPaths = new Set(manifest.files.map((file) => normalizePath(file.outputPath)));
273
+ const mapRelativePath = normalizePath(relative(renderedRoot, mapPath));
274
+ const directoryMap = buildDirectoryMap(manifest);
275
+ let filesWritten = 0;
276
+ for (const outputPath of walkFiles(renderedRoot)) {
277
+ if (outputPath === mapRelativePath)
278
+ continue;
279
+ if (mappedOutputPaths.has(outputPath))
280
+ continue;
281
+ if (!matchesAny(outputPath, matchers))
282
+ continue;
283
+ const renderedPath = resolveInside(renderedRoot, outputPath);
284
+ const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap));
285
+ mkdirSync(dirname(templatePath), { recursive: true });
286
+ copyFileSync(renderedPath, templatePath);
287
+ filesWritten += 1;
288
+ }
289
+ return filesWritten;
290
+ }
81
291
  function reverseDir(renderedDir, templateDir, options = {}) {
82
292
  const renderedRoot = resolvePath(renderedDir);
83
293
  const templateRoot = resolvePath(templateDir);
@@ -114,6 +324,10 @@ function reverseDir(renderedDir, templateDir, options = {}) {
114
324
  writeFileSync(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
115
325
  filesWritten += 1;
116
326
  }
327
+ for (const skipped of manifest.skipped ?? []) {
328
+ filesWritten += writeSkippedTemplate(templateRoot, skipped);
329
+ }
330
+ filesWritten += copyIncludedRenderedFiles(renderedRoot, templateRoot, mapPath, manifest, options.include);
117
331
  return { filesWritten, warnings };
118
332
  }
119
333
 
@@ -122,7 +336,7 @@ function printHelp() {
122
336
  console.log(`tdir
123
337
 
124
338
  Usage:
125
- tdir reverse <rendered-dir> <template-dir> [--map <path>]
339
+ tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
126
340
 
127
341
  Commands:
128
342
  reverse Rebuild template files from a rendered directory and reverse map
@@ -130,16 +344,18 @@ Commands:
130
344
  Options:
131
345
  --map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
132
346
  Relative paths are resolved from <rendered-dir>.
347
+ --include Include new rendered files matching a glob. Can be repeated.
133
348
  --help Show this help message.
134
349
  `);
135
350
  }
136
351
  function parseReverseArgs(args) {
137
352
  const positional = [];
353
+ const include = [];
138
354
  let mapPath;
139
355
  for (let i = 0;i < args.length; i++) {
140
356
  const arg = args[i];
141
357
  if (arg === "--help" || arg === "-h") {
142
- return { help: true, positional, mapPath };
358
+ return { help: true, positional, mapPath, include };
143
359
  }
144
360
  if (arg === "--map") {
145
361
  const value = args[++i];
@@ -148,9 +364,16 @@ function parseReverseArgs(args) {
148
364
  mapPath = value;
149
365
  continue;
150
366
  }
367
+ if (arg === "--include") {
368
+ const value = args[++i];
369
+ if (!value)
370
+ throw new Error("Missing value for --include");
371
+ include.push(value);
372
+ continue;
373
+ }
151
374
  positional.push(arg);
152
375
  }
153
- return { help: false, positional, mapPath };
376
+ return { help: false, positional, mapPath, include };
154
377
  }
155
378
  function main(argv) {
156
379
  const [command, ...args] = argv;
@@ -168,9 +391,12 @@ function main(argv) {
168
391
  }
169
392
  const [renderedDir, templateDir] = parsed.positional;
170
393
  if (!renderedDir || !templateDir || parsed.positional.length > 2) {
171
- throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>]");
394
+ throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]");
172
395
  }
173
- const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath });
396
+ const result = reverseDir(renderedDir, templateDir, {
397
+ mapPath: parsed.mapPath,
398
+ include: parsed.include
399
+ });
174
400
  console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`);
175
401
  for (const warning of result.warnings) {
176
402
  console.warn(`Warning: ${warning.outputPath}: ${warning.message}`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type RenderOptions } from "./render";
3
- export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render";
3
+ export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render";
4
4
  export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse";
5
5
  interface Stringable {
6
6
  toString: () => string;
package/dist/index.js CHANGED
@@ -175,7 +175,7 @@ function resolveOutputPath(destRoot, outputName) {
175
175
  return outputPath;
176
176
  }
177
177
  function createReverseMapManifest() {
178
- return { version: 1, files: [], tokens: {} };
178
+ return { version: 1, files: [], skipped: [], tokens: {} };
179
179
  }
180
180
  function addReverseMapToken(state, file, token) {
181
181
  if (!state?.manifest || !file)
@@ -231,69 +231,131 @@ function isUtf8Text2(buffer) {
231
231
  return false;
232
232
  }
233
233
  }
234
- function processIfBlocks(content, context) {
235
- let result = "";
236
- let pos = 0;
237
- const stack = [];
238
- const re = new RegExp(DIRECTIVE_RE2.source, "g");
239
- let match;
240
- function isEmitting() {
241
- return stack.every((f) => f.active);
242
- }
243
- while ((match = re.exec(content)) !== null) {
244
- const directive = match[1];
245
- const condPath = match[2];
246
- if (directive === "if") {
247
- if (isEmitting())
248
- result += content.slice(pos, match.index);
249
- const truthy = evalCondition(condPath, context);
250
- stack.push({ matched: truthy, active: truthy, sawElse: false });
251
- pos = re.lastIndex;
252
- } else if (directive === "elseif") {
253
- if (stack.length === 0)
254
- throw new Error("Unexpected <@elseif> without <@if>");
255
- const top = stack[stack.length - 1];
256
- if (top.sawElse)
257
- throw new Error("Unexpected <@elseif> after <@else>");
258
- if (isEmitting())
259
- result += content.slice(pos, match.index);
260
- if (top.matched) {
261
- top.active = false;
262
- } else {
263
- const truthy = evalCondition(condPath, context);
264
- top.matched = truthy;
265
- top.active = truthy;
266
- }
267
- pos = re.lastIndex;
268
- } else if (directive === "else") {
269
- if (stack.length === 0)
270
- throw new Error("Unexpected <@else> without <@if>");
271
- const top = stack[stack.length - 1];
272
- if (top.sawElse)
273
- throw new Error("Unexpected duplicate <@else>");
274
- if (isEmitting())
275
- result += content.slice(pos, match.index);
276
- top.active = !top.matched;
277
- top.matched = true;
278
- top.sawElse = true;
279
- pos = re.lastIndex;
280
- } else if (directive === "endif") {
281
- if (stack.length === 0)
282
- throw new Error("Unexpected <@endif> without <@if>");
283
- if (isEmitting())
284
- result += content.slice(pos, match.index);
285
- stack.pop();
286
- pos = re.lastIndex;
234
+ function getDirectiveTokens(content) {
235
+ return Array.from(content.matchAll(DIRECTIVE_RE2), (match) => ({
236
+ type: match[1],
237
+ condition: match[2],
238
+ index: match.index,
239
+ end: match.index + match[0].length
240
+ }));
241
+ }
242
+ function parseNodes(content, tokens, tokenIndex, pos, stopTypes) {
243
+ const nodes = [];
244
+ while (tokenIndex < tokens.length) {
245
+ const token = tokens[tokenIndex];
246
+ if (stopTypes.includes(token.type)) {
247
+ if (token.index > pos)
248
+ nodes.push({ type: "text", text: content.slice(pos, token.index) });
249
+ return { nodes, pos: token.index, tokenIndex, stop: token };
250
+ }
251
+ if (token.type !== "if") {
252
+ throw new Error(`Unexpected <@${token.type}> without <@if>`);
253
+ }
254
+ if (token.index > pos)
255
+ nodes.push({ type: "text", text: content.slice(pos, token.index) });
256
+ const parsed = parseIfNode(content, tokens, tokenIndex);
257
+ nodes.push(parsed.node);
258
+ tokenIndex = parsed.tokenIndex;
259
+ pos = parsed.pos;
260
+ }
261
+ if (pos < content.length)
262
+ nodes.push({ type: "text", text: content.slice(pos) });
263
+ return { nodes, pos: content.length, tokenIndex };
264
+ }
265
+ function parseIfNode(content, tokens, tokenIndex) {
266
+ const firstToken = tokens[tokenIndex];
267
+ const sourceStart = firstToken.index;
268
+ const branches = [];
269
+ let branchType = "if";
270
+ let branchCondition = firstToken.condition;
271
+ let branchContentStart = firstToken.end;
272
+ tokenIndex += 1;
273
+ while (true) {
274
+ const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"]);
275
+ if (!parsed.stop)
276
+ throw new Error("Unmatched <@if> without <@endif>");
277
+ branches.push({
278
+ type: branchType,
279
+ condition: branchCondition,
280
+ nodes: parsed.nodes,
281
+ contentStart: branchContentStart,
282
+ contentEnd: parsed.pos
283
+ });
284
+ if (parsed.stop.type === "endif") {
285
+ const sourceEnd = parsed.stop.end;
286
+ return {
287
+ node: {
288
+ type: "if",
289
+ sourceStart,
290
+ sourceEnd,
291
+ source: content.slice(sourceStart, sourceEnd),
292
+ branches
293
+ },
294
+ pos: sourceEnd,
295
+ tokenIndex: parsed.tokenIndex + 1
296
+ };
287
297
  }
298
+ branchType = parsed.stop.type;
299
+ branchCondition = parsed.stop.condition;
300
+ branchContentStart = parsed.stop.end;
301
+ tokenIndex = parsed.tokenIndex + 1;
288
302
  }
289
- if (stack.length > 0) {
290
- throw new Error("Unmatched <@if> without <@endif>");
303
+ }
304
+ function getActiveBranch(node, context) {
305
+ for (const branch of node.branches) {
306
+ if (branch.type === "else" || evalCondition(branch.condition, context))
307
+ return branch;
308
+ }
309
+ return;
310
+ }
311
+ function renderNodes(nodes, context, conditionalTokens, outputStart = 0) {
312
+ let result = "";
313
+ for (const node of nodes) {
314
+ if (node.type === "text") {
315
+ result += node.text;
316
+ continue;
317
+ }
318
+ const activeBranch = getActiveBranch(node, context);
319
+ const start = outputStart + result.length;
320
+ const renderedBranch = activeBranch ? renderNodes(activeBranch.nodes, context, conditionalTokens, start) : "";
321
+ result += renderedBranch;
322
+ conditionalTokens.push({
323
+ result: renderedBranch,
324
+ token: node.source,
325
+ range: { start, end: start + renderedBranch.length },
326
+ activeRange: activeBranch ? {
327
+ start: activeBranch.contentStart - node.sourceStart,
328
+ end: activeBranch.contentEnd - node.sourceStart
329
+ } : { start: node.source.length, end: node.source.length }
330
+ });
291
331
  }
292
- result += content.slice(pos);
293
332
  return result;
294
333
  }
334
+ function processIfBlocksWithMap(content, context) {
335
+ const tokens = getDirectiveTokens(content);
336
+ const parsed = parseNodes(content, tokens, 0, 0, []);
337
+ const conditionalTokens = [];
338
+ return {
339
+ content: renderNodes(parsed.nodes, context, conditionalTokens),
340
+ conditionalTokens
341
+ };
342
+ }
295
343
  function renderContentWithMap(content, context, state, file) {
296
- const processed = processIfBlocks(content, context);
344
+ const processedResult = processIfBlocksWithMap(content, context);
345
+ const processed = processedResult.content;
346
+ for (const token of processedResult.conditionalTokens) {
347
+ addReverseMapToken(state, file, {
348
+ kind: "conditional",
349
+ result: token.result,
350
+ token: token.token,
351
+ outputPath: file?.outputPath ?? "",
352
+ templatePath: file?.templatePath ?? "",
353
+ range: token.range,
354
+ activeRange: token.activeRange,
355
+ before: processed.slice(0, token.range.start),
356
+ after: processed.slice(token.range.end)
357
+ });
358
+ }
297
359
  let result = "";
298
360
  let pos = 0;
299
361
  for (const match of processed.matchAll(VAR_RE2)) {
@@ -350,6 +412,37 @@ function validateOutputPaths(srcDir, destDir, context) {
350
412
  }
351
413
  }
352
414
  }
415
+ function storeSkippedTemplate(srcPath, state) {
416
+ if (!state.manifest)
417
+ return;
418
+ const stat = statSync2(srcPath);
419
+ const templatePath = relative(state.sourceRoot, srcPath);
420
+ if (stat.isDirectory()) {
421
+ state.manifest.skipped.push({ kind: "directory", templatePath });
422
+ for (const entry of readdirSync2(srcPath).sort()) {
423
+ storeSkippedTemplate(resolvePath(srcPath, entry), state);
424
+ }
425
+ return;
426
+ }
427
+ if (!stat.isFile())
428
+ return;
429
+ const content = readFileSync2(srcPath);
430
+ if (isUtf8Text2(content)) {
431
+ state.manifest.skipped.push({
432
+ kind: "file",
433
+ templatePath,
434
+ encoding: "utf8",
435
+ content: content.toString("utf-8")
436
+ });
437
+ } else {
438
+ state.manifest.skipped.push({
439
+ kind: "file",
440
+ templatePath,
441
+ encoding: "base64",
442
+ content: content.toString("base64")
443
+ });
444
+ }
445
+ }
353
446
  function renderDirInner(srcDir, destDir, context, state) {
354
447
  mkdirSync(destDir, { recursive: true });
355
448
  const entries = readdirSync2(srcDir).sort();
@@ -363,8 +456,10 @@ function renderDirInner(srcDir, destDir, context, state) {
363
456
  tokens: []
364
457
  };
365
458
  const outputName = getOutputName(entry, context, state, tempFile);
366
- if (outputName === null)
459
+ if (outputName === null) {
460
+ storeSkippedTemplate(srcPath, state);
367
461
  continue;
462
+ }
368
463
  const destPath = resolveOutputPath(destDir, outputName);
369
464
  const outputPath = relative(state.destRoot, destPath);
370
465
  tempFile.outputPath = outputPath;
@@ -385,8 +480,8 @@ function renderDirInner(srcDir, destDir, context, state) {
385
480
  } else {
386
481
  copyFileSync(srcPath, destPath);
387
482
  }
388
- if (tempFile.tokens.length > 0)
389
- state.manifest?.files.push(tempFile);
483
+ if (state.manifest)
484
+ state.manifest.files.push(tempFile);
390
485
  }
391
486
  }
392
487
  }
@@ -397,6 +492,7 @@ import {
397
492
  existsSync,
398
493
  mkdirSync as mkdirSync2,
399
494
  readFileSync as readFileSync3,
495
+ readdirSync as readdirSync3,
400
496
  statSync as statSync3,
401
497
  writeFileSync as writeFileSync2
402
498
  } from "node:fs";
@@ -430,6 +526,45 @@ function readManifest(mapPath) {
430
526
  }
431
527
  return manifest;
432
528
  }
529
+ function normalizePath(path) {
530
+ return path.split("\\").join("/");
531
+ }
532
+ function escapeRegExp(text) {
533
+ return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
534
+ }
535
+ function globToRegExp(glob) {
536
+ let source = "^";
537
+ const pattern = normalizePath(glob);
538
+ for (let i = 0;i < pattern.length; i++) {
539
+ const char = pattern[i];
540
+ const next = pattern[i + 1];
541
+ if (char === "*" && next === "*") {
542
+ if (pattern[i + 2] === "/") {
543
+ source += "(?:.*/)?";
544
+ i += 2;
545
+ } else {
546
+ source += ".*";
547
+ i += 1;
548
+ }
549
+ } else if (char === "*") {
550
+ source += "[^/]*";
551
+ } else if (char === "?") {
552
+ source += "[^/]";
553
+ } else {
554
+ source += escapeRegExp(char);
555
+ }
556
+ }
557
+ return new RegExp(`${source}$`);
558
+ }
559
+ function getIncludeMatchers(include) {
560
+ if (!include)
561
+ return [];
562
+ return (Array.isArray(include) ? include : [include]).map(globToRegExp);
563
+ }
564
+ function matchesAny(path, matchers) {
565
+ const normalized = normalizePath(path);
566
+ return matchers.some((matcher) => matcher.test(normalized));
567
+ }
433
568
  function replaceAtRange(content, token) {
434
569
  if (!token.range)
435
570
  return null;
@@ -446,10 +581,66 @@ function replaceFirst(content, token) {
446
581
  return null;
447
582
  return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
448
583
  }
584
+ function applyActiveBranch(token, branchContent) {
585
+ if (!token.activeRange)
586
+ return token.token;
587
+ return [
588
+ token.token.slice(0, token.activeRange.start),
589
+ branchContent,
590
+ token.token.slice(token.activeRange.end)
591
+ ].join("");
592
+ }
593
+ function findPrefixEnd(content, before) {
594
+ if (before === "")
595
+ return 0;
596
+ let candidate = before;
597
+ while (candidate.length >= 8) {
598
+ const index = content.indexOf(candidate);
599
+ if (index !== -1)
600
+ return index + candidate.length;
601
+ candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)));
602
+ }
603
+ return -1;
604
+ }
605
+ function findSuffixStart(content, after, from) {
606
+ if (after === "")
607
+ return content.length;
608
+ let candidate = after;
609
+ while (candidate.length >= 8) {
610
+ const index = content.indexOf(candidate, from);
611
+ if (index !== -1)
612
+ return index;
613
+ candidate = candidate.slice(0, Math.floor(candidate.length / 2));
614
+ }
615
+ return -1;
616
+ }
617
+ function replaceConditional(content, token) {
618
+ const exactIndex = content.indexOf(token.result);
619
+ if (exactIndex !== -1) {
620
+ return [
621
+ content.slice(0, exactIndex),
622
+ token.token,
623
+ content.slice(exactIndex + token.result.length)
624
+ ].join("");
625
+ }
626
+ if (token.before === undefined || token.after === undefined)
627
+ return null;
628
+ const branchStart = findPrefixEnd(content, token.before);
629
+ if (branchStart === -1)
630
+ return null;
631
+ const afterIndex = findSuffixStart(content, token.after, branchStart);
632
+ if (afterIndex === -1)
633
+ return null;
634
+ return [
635
+ content.slice(0, branchStart),
636
+ applyActiveBranch(token, content.slice(branchStart, afterIndex)),
637
+ content.slice(afterIndex)
638
+ ].join("");
639
+ }
449
640
  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));
641
+ const contentTokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
451
642
  let reversed = content;
452
- for (const token of tokens) {
643
+ for (const token of contentTokens) {
453
644
  const rangeResult = replaceAtRange(reversed, token);
454
645
  if (rangeResult !== null) {
455
646
  reversed = rangeResult;
@@ -467,8 +658,122 @@ function reverseContent(content, file, warnings) {
467
658
  message: "Rendered value was not found; token was not restored"
468
659
  });
469
660
  }
661
+ const conditionalTokens = file.tokens.filter((token) => token.kind === "conditional").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
662
+ for (const token of conditionalTokens) {
663
+ const result = replaceConditional(reversed, token);
664
+ if (result !== null) {
665
+ reversed = result;
666
+ continue;
667
+ }
668
+ warnings.push({
669
+ outputPath: file.outputPath,
670
+ token: token.token,
671
+ result: token.result,
672
+ message: "Rendered conditional block was not found; block was not restored"
673
+ });
674
+ }
470
675
  return reversed;
471
676
  }
677
+ function writeSkippedTemplate(templateRoot, skipped) {
678
+ const templatePath = resolveInside(templateRoot, skipped.templatePath);
679
+ if (skipped.kind === "directory") {
680
+ mkdirSync2(templatePath, { recursive: true });
681
+ return 0;
682
+ }
683
+ mkdirSync2(dirname2(templatePath), { recursive: true });
684
+ const content = skipped.encoding === "base64" ? Buffer.from(skipped.content ?? "", "base64") : skipped.content ?? "";
685
+ writeFileSync2(templatePath, content);
686
+ return 1;
687
+ }
688
+ function dirnamePath(path) {
689
+ const normalized = normalizePath(path);
690
+ const index = normalized.lastIndexOf("/");
691
+ return index === -1 ? "" : normalized.slice(0, index);
692
+ }
693
+ function basenamePath(path) {
694
+ const normalized = normalizePath(path);
695
+ const index = normalized.lastIndexOf("/");
696
+ return index === -1 ? normalized : normalized.slice(index + 1);
697
+ }
698
+ function joinPath(...parts) {
699
+ return parts.filter((part) => part !== "").join("/");
700
+ }
701
+ function buildDirectoryMap(manifest) {
702
+ const mappings = new Map([["", ""]]);
703
+ function addMapping(outputDir, templateDir) {
704
+ const outputParts = normalizePath(outputDir).split("/").filter(Boolean);
705
+ const templateParts = normalizePath(templateDir).split("/").filter(Boolean);
706
+ for (let i = 1;i <= outputParts.length; i++) {
707
+ if (i <= templateParts.length) {
708
+ mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/"));
709
+ }
710
+ }
711
+ mappings.set(normalizePath(outputDir), normalizePath(templateDir));
712
+ }
713
+ for (const file of manifest.files) {
714
+ const output = normalizePath(file.outputPath);
715
+ const template = normalizePath(file.templatePath);
716
+ addMapping(dirnamePath(output), dirnamePath(template));
717
+ if (file.tokens.some((token) => token.kind === "path"))
718
+ addMapping(output, template);
719
+ }
720
+ for (const skipped of manifest.skipped ?? []) {
721
+ if (skipped.kind === "directory")
722
+ continue;
723
+ addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath));
724
+ }
725
+ return mappings;
726
+ }
727
+ function inferTemplatePath(outputPath, directoryMap) {
728
+ const normalized = normalizePath(outputPath);
729
+ const outputDir = dirnamePath(normalized);
730
+ let bestOutputDir = "";
731
+ let bestTemplateDir = "";
732
+ for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) {
733
+ if (mappedOutputDir.length >= bestOutputDir.length && (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`))) {
734
+ bestOutputDir = mappedOutputDir;
735
+ bestTemplateDir = mappedTemplateDir;
736
+ }
737
+ }
738
+ const suffix = bestOutputDir === "" ? outputDir : outputDir.slice(bestOutputDir.length).replace(/^\//, "");
739
+ return joinPath(bestTemplateDir, suffix, basenamePath(normalized));
740
+ }
741
+ function walkFiles(root, current = root) {
742
+ const files = [];
743
+ for (const entry of readdirSync3(current).sort()) {
744
+ const path = resolvePath2(current, entry);
745
+ const stat = statSync3(path);
746
+ if (stat.isDirectory()) {
747
+ files.push(...walkFiles(root, path));
748
+ } else if (stat.isFile()) {
749
+ files.push(normalizePath(relative2(root, path)));
750
+ }
751
+ }
752
+ return files;
753
+ }
754
+ function copyIncludedRenderedFiles(renderedRoot, templateRoot, mapPath, manifest, include) {
755
+ const matchers = getIncludeMatchers(include);
756
+ if (matchers.length === 0)
757
+ return 0;
758
+ const mappedOutputPaths = new Set(manifest.files.map((file) => normalizePath(file.outputPath)));
759
+ const mapRelativePath = normalizePath(relative2(renderedRoot, mapPath));
760
+ const directoryMap = buildDirectoryMap(manifest);
761
+ let filesWritten = 0;
762
+ for (const outputPath of walkFiles(renderedRoot)) {
763
+ if (outputPath === mapRelativePath)
764
+ continue;
765
+ if (mappedOutputPaths.has(outputPath))
766
+ continue;
767
+ if (!matchesAny(outputPath, matchers))
768
+ continue;
769
+ const renderedPath = resolveInside(renderedRoot, outputPath);
770
+ const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap));
771
+ mkdirSync2(dirname2(templatePath), { recursive: true });
772
+ copyFileSync2(renderedPath, templatePath);
773
+ filesWritten += 1;
774
+ }
775
+ return filesWritten;
776
+ }
472
777
  function reverseDir(renderedDir, templateDir, options = {}) {
473
778
  const renderedRoot = resolvePath2(renderedDir);
474
779
  const templateRoot = resolvePath2(templateDir);
@@ -505,6 +810,10 @@ function reverseDir(renderedDir, templateDir, options = {}) {
505
810
  writeFileSync2(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
506
811
  filesWritten += 1;
507
812
  }
813
+ for (const skipped of manifest.skipped ?? []) {
814
+ filesWritten += writeSkippedTemplate(templateRoot, skipped);
815
+ }
816
+ filesWritten += copyIncludedRenderedFiles(renderedRoot, templateRoot, mapPath, manifest, options.include);
508
817
  return { filesWritten, warnings };
509
818
  }
510
819
 
package/dist/render.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type ReverseMapToken = {
2
- kind: "path" | "content";
2
+ kind: "path" | "content" | "conditional";
3
3
  result: string;
4
4
  token: string;
5
5
  contextPath?: string;
@@ -9,15 +9,28 @@ export type ReverseMapToken = {
9
9
  start: number;
10
10
  end: number;
11
11
  };
12
+ activeRange?: {
13
+ start: number;
14
+ end: number;
15
+ };
16
+ before?: string;
17
+ after?: string;
12
18
  };
13
19
  export type ReverseMapFile = {
14
20
  outputPath: string;
15
21
  templatePath: string;
16
22
  tokens: ReverseMapToken[];
17
23
  };
24
+ export type ReverseMapStoredTemplate = {
25
+ kind: "directory" | "file";
26
+ templatePath: string;
27
+ encoding?: "utf8" | "base64";
28
+ content?: string;
29
+ };
18
30
  export type ReverseMapManifest = {
19
31
  version: 1;
20
32
  files: ReverseMapFile[];
33
+ skipped: ReverseMapStoredTemplate[];
21
34
  tokens: Record<string, string[]>;
22
35
  };
23
36
  export type RenderOptions = {
package/dist/reverse.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type ReverseOptions = {
2
2
  mapPath?: string;
3
+ include?: string | string[];
3
4
  };
4
5
  export type ReverseWarning = {
5
6
  outputPath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregorlohaus/tdir",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",