@xenonbyte/da-vinci-workflow 0.2.4 → 0.2.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/lib/scaffold.js CHANGED
@@ -1,22 +1,37 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { STATUS } = require("./workflow-contract");
4
- const { writeFileAtomic } = require("./utils");
4
+ const { writeFileAtomic, normalizeRelativePath, pathWithinRoot } = require("./utils");
5
5
  const {
6
6
  unique,
7
7
  resolveChangeDir,
8
8
  parseBindingsArtifact,
9
9
  readChangeArtifacts,
10
- readArtifactTexts
10
+ readArtifactTexts,
11
+ resolveImplementationLanding
11
12
  } = require("./planning-parsers");
12
13
 
13
- function sanitizeRouteToFileName(route) {
14
+ const FRAMEWORKS = ["next", "react", "vue", "svelte"];
15
+ const FRAMEWORK_EXTENSIONS = {
16
+ next: ".tsx",
17
+ react: ".tsx",
18
+ vue: ".vue",
19
+ svelte: ".svelte",
20
+ html: ".html"
21
+ };
22
+ const TARGET_SCAN_MAX_DEPTH = 6;
23
+ const TARGET_SCAN_MAX_FILES = 3000;
24
+
25
+ function sanitizeRoute(route) {
14
26
  const normalized = String(route || "")
15
27
  .trim()
16
28
  .split(/[?#]/, 1)[0]
17
29
  .replace(/\\/g, "/");
18
30
  if (!normalized || normalized === "/") {
19
- return "index.html";
31
+ return {
32
+ safeSegments: [],
33
+ hasTraversal: false
34
+ };
20
35
  }
21
36
 
22
37
  const rawSegments = normalized.replace(/^\/+/, "").split("/").filter(Boolean);
@@ -34,34 +49,327 @@ function sanitizeRouteToFileName(route) {
34
49
  safeSegments.push(safeSegment || "index");
35
50
  }
36
51
 
37
- if (safeSegments.length === 0) {
38
- if (hasTraversal) {
39
- return null;
40
- }
52
+ return {
53
+ safeSegments,
54
+ hasTraversal
55
+ };
56
+ }
57
+
58
+ function stripKnownFileExtension(segment) {
59
+ return String(segment || "").replace(/\.(html?|jsx?|tsx?|vue|svelte)$/i, "");
60
+ }
61
+
62
+ function sanitizeRouteToHtmlFile(route) {
63
+ const parsed = sanitizeRoute(route);
64
+ if (parsed.hasTraversal) {
65
+ return null;
66
+ }
67
+ if (parsed.safeSegments.length === 0) {
41
68
  return "index.html";
42
69
  }
43
70
 
44
- let relativePath = safeSegments.join("/");
71
+ let relativePath = parsed.safeSegments.join("/");
45
72
  if (!path.extname(relativePath)) {
46
73
  relativePath = `${relativePath}.html`;
47
74
  }
48
- if (hasTraversal) {
75
+ return relativePath;
76
+ }
77
+
78
+ function deriveFrameworkTargetPath(route, templateMode) {
79
+ if (!FRAMEWORKS.includes(templateMode)) {
80
+ return sanitizeRouteToHtmlFile(route);
81
+ }
82
+
83
+ const parsed = sanitizeRoute(route);
84
+ if (parsed.hasTraversal) {
49
85
  return null;
50
86
  }
51
- return relativePath;
87
+
88
+ const cleanedSegments = parsed.safeSegments
89
+ .map((segment) => stripKnownFileExtension(segment))
90
+ .filter(Boolean);
91
+ const extension = FRAMEWORK_EXTENSIONS[templateMode];
92
+ if (templateMode === "next") {
93
+ if (cleanedSegments.length === 0) {
94
+ return `app/page${extension}`;
95
+ }
96
+ return path.join("app", ...cleanedSegments, `page${extension}`);
97
+ }
98
+ if (templateMode === "react") {
99
+ if (cleanedSegments.length === 0) {
100
+ return `src/pages/index${extension}`;
101
+ }
102
+ return path.join("src", "pages", ...cleanedSegments, `index${extension}`);
103
+ }
104
+ if (templateMode === "vue") {
105
+ if (cleanedSegments.length === 0) {
106
+ return `src/pages/index${extension}`;
107
+ }
108
+ return path.join("src", "pages", `${cleanedSegments.join("/")}${extension}`);
109
+ }
110
+ if (templateMode === "svelte") {
111
+ if (cleanedSegments.length === 0) {
112
+ return "src/routes/+page.svelte";
113
+ }
114
+ return path.join("src", "routes", ...cleanedSegments, "+page.svelte");
115
+ }
116
+ return sanitizeRouteToHtmlFile(route);
117
+ }
118
+
119
+ function collectPackageFrameworkSignals(projectRoot) {
120
+ const reasons = {
121
+ next: [],
122
+ react: [],
123
+ vue: [],
124
+ svelte: []
125
+ };
126
+ const packageJsonPath = path.join(projectRoot, "package.json");
127
+ if (!fs.existsSync(packageJsonPath)) {
128
+ return reasons;
129
+ }
130
+
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
134
+ } catch (_error) {
135
+ return reasons;
136
+ }
137
+ const allDeps = {
138
+ ...(parsed.dependencies || {}),
139
+ ...(parsed.devDependencies || {}),
140
+ ...(parsed.peerDependencies || {})
141
+ };
142
+
143
+ if (allDeps.next) {
144
+ reasons.next.push("package:next");
145
+ }
146
+ if (allDeps.react || allDeps["react-dom"]) {
147
+ reasons.react.push("package:react");
148
+ }
149
+ if (allDeps.vue || allDeps.nuxt || allDeps["@vue/runtime-dom"]) {
150
+ reasons.vue.push("package:vue");
151
+ }
152
+ if (allDeps.svelte || allDeps["@sveltejs/kit"]) {
153
+ reasons.svelte.push("package:svelte");
154
+ }
155
+ return reasons;
156
+ }
157
+
158
+ function projectHasAnyFiles(projectRoot, options = {}) {
159
+ const {
160
+ roots = ["."],
161
+ extensions = [],
162
+ names = [],
163
+ maxDepth = TARGET_SCAN_MAX_DEPTH
164
+ } = options;
165
+ const extensionSet = new Set(extensions.map((value) => String(value || "").toLowerCase()));
166
+ const nameSet = new Set(names.map((value) => String(value || "").toLowerCase()));
167
+ const queue = [];
168
+ for (const root of roots) {
169
+ const absoluteRoot = path.resolve(projectRoot, root);
170
+ if (!pathWithinRoot(projectRoot, absoluteRoot) || !fs.existsSync(absoluteRoot)) {
171
+ continue;
172
+ }
173
+ queue.push({
174
+ dir: absoluteRoot,
175
+ depth: 0
176
+ });
177
+ }
178
+ const visited = new Set();
179
+ let scannedFiles = 0;
180
+ let scanLimitHit = false;
181
+ while (queue.length > 0) {
182
+ const current = queue.pop();
183
+ if (current.depth > maxDepth) {
184
+ continue;
185
+ }
186
+
187
+ let resolvedCurrent;
188
+ try {
189
+ resolvedCurrent = fs.realpathSync(current.dir);
190
+ } catch (_error) {
191
+ continue;
192
+ }
193
+ if (visited.has(resolvedCurrent)) {
194
+ continue;
195
+ }
196
+ visited.add(resolvedCurrent);
197
+
198
+ let entries = [];
199
+ try {
200
+ entries = fs.readdirSync(current.dir, { withFileTypes: true });
201
+ } catch (_error) {
202
+ continue;
203
+ }
204
+ for (const entry of entries) {
205
+ const absolutePath = path.join(current.dir, entry.name);
206
+ if (entry.isSymbolicLink()) {
207
+ continue;
208
+ }
209
+ if (entry.isDirectory()) {
210
+ queue.push({
211
+ dir: absolutePath,
212
+ depth: current.depth + 1
213
+ });
214
+ continue;
215
+ }
216
+ if (!entry.isFile()) {
217
+ continue;
218
+ }
219
+ scannedFiles += 1;
220
+ if (scannedFiles > TARGET_SCAN_MAX_FILES) {
221
+ scanLimitHit = true;
222
+ return {
223
+ found: false,
224
+ scanLimitHit
225
+ };
226
+ }
227
+ const normalizedName = entry.name.toLowerCase();
228
+ if (nameSet.has(normalizedName)) {
229
+ return {
230
+ found: true,
231
+ scanLimitHit
232
+ };
233
+ }
234
+ const ext = path.extname(normalizedName);
235
+ if (extensionSet.has(ext)) {
236
+ return {
237
+ found: true,
238
+ scanLimitHit
239
+ };
240
+ }
241
+ }
242
+ }
243
+ return {
244
+ found: false,
245
+ scanLimitHit
246
+ };
247
+ }
248
+
249
+ function detectFramework(projectRoot) {
250
+ const reasons = collectPackageFrameworkSignals(projectRoot);
251
+
252
+ const nextConfigFiles = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
253
+ if (nextConfigFiles.some((file) => fs.existsSync(path.join(projectRoot, file)))) {
254
+ reasons.next.push("file:next.config");
255
+ }
256
+ const scanLimitSignals = [];
257
+ const nextAppSignal = projectHasAnyFiles(projectRoot, {
258
+ roots: ["app", "src/app"],
259
+ names: ["page.tsx", "page.jsx", "page.js", "page.ts"]
260
+ });
261
+ if (nextAppSignal.found) {
262
+ reasons.next.push("convention:app/page");
263
+ } else if (nextAppSignal.scanLimitHit) {
264
+ scanLimitSignals.push("next:convention:app/page");
265
+ }
266
+ const reactPagesSignal = projectHasAnyFiles(projectRoot, {
267
+ roots: ["src", "pages", "src/pages"],
268
+ extensions: [".tsx", ".jsx"]
269
+ });
270
+ if (reactPagesSignal.found) {
271
+ reasons.react.push("convention:tsx-jsx-pages");
272
+ } else if (reactPagesSignal.scanLimitHit) {
273
+ scanLimitSignals.push("react:convention:tsx-jsx-pages");
274
+ }
275
+ const vueSignal = projectHasAnyFiles(projectRoot, {
276
+ roots: ["src", "."],
277
+ extensions: [".vue"]
278
+ });
279
+ if (vueSignal.found) {
280
+ reasons.vue.push("convention:vue-files");
281
+ } else if (vueSignal.scanLimitHit) {
282
+ scanLimitSignals.push("vue:convention:vue-files");
283
+ }
284
+ const svelteSignal = projectHasAnyFiles(projectRoot, {
285
+ roots: ["src", "."],
286
+ extensions: [".svelte"]
287
+ });
288
+ if (svelteSignal.found) {
289
+ reasons.svelte.push("convention:svelte-files");
290
+ } else if (svelteSignal.scanLimitHit) {
291
+ scanLimitSignals.push("svelte:convention:svelte-files");
292
+ }
293
+
294
+ const candidates = FRAMEWORKS.filter((framework) => reasons[framework].length > 0);
295
+ const candidateSet = new Set(candidates);
296
+ if (candidateSet.has("next") && candidateSet.has("react")) {
297
+ // Next.js naturally implies React; treat this pair as Next.js rather than ambiguity.
298
+ candidateSet.delete("react");
299
+ }
300
+ const reducedCandidates = FRAMEWORKS.filter((framework) => candidateSet.has(framework));
301
+
302
+ if (reducedCandidates.length === 0) {
303
+ if (scanLimitSignals.length > 0) {
304
+ return {
305
+ mode: "ambiguous",
306
+ candidates: FRAMEWORKS,
307
+ reasons,
308
+ scanLimitSignals
309
+ };
310
+ }
311
+ return {
312
+ mode: "unknown",
313
+ candidates: [],
314
+ reasons,
315
+ scanLimitSignals
316
+ };
317
+ }
318
+
319
+ if (reducedCandidates.length === 1) {
320
+ return {
321
+ mode: reducedCandidates[0],
322
+ candidates: reducedCandidates,
323
+ reasons,
324
+ scanLimitSignals
325
+ };
326
+ }
327
+
328
+ return {
329
+ mode: "ambiguous",
330
+ candidates: reducedCandidates,
331
+ reasons,
332
+ scanLimitSignals
333
+ };
334
+ }
335
+
336
+ function inferTemplateModeFromLanding(landingPath, frameworkDetection) {
337
+ const ext = path.extname(landingPath).toLowerCase();
338
+ const normalizedPath = normalizeRelativePath(landingPath);
339
+
340
+ if (ext === ".vue") {
341
+ return "vue";
342
+ }
343
+ if (ext === ".svelte") {
344
+ return "svelte";
345
+ }
346
+ if (ext === ".html") {
347
+ return "html";
348
+ }
349
+ if (ext === ".tsx" || ext === ".jsx" || ext === ".ts" || ext === ".js") {
350
+ if (/\/app\/.+\/page\.[tj]sx?$/i.test(normalizedPath) || /\/app\/page\.[tj]sx?$/i.test(normalizedPath)) {
351
+ return "next";
352
+ }
353
+ if (frameworkDetection.mode === "next") {
354
+ return "next";
355
+ }
356
+ return "react";
357
+ }
358
+ return "html";
52
359
  }
53
360
 
54
- function buildSkeletonDocument(mapping) {
361
+ function buildHtmlTemplate(mapping, metadata) {
55
362
  return [
56
363
  "<!doctype html>",
57
- '<html lang="en">',
364
+ "<html lang=\"en\">",
58
365
  "<head>",
59
- ' <meta charset="utf-8" />',
60
- ' <meta name="viewport" content="width=device-width,initial-scale=1" />',
366
+ " <meta charset=\"utf-8\" />",
367
+ " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />",
61
368
  ` <title>${mapping.designPage || "Scaffold Page"}</title>`,
62
369
  "</head>",
63
370
  "<body>",
64
371
  " <!-- TODO(scaffold): Replace placeholder skeleton with production implementation. -->",
372
+ ` <!-- template-mode: ${metadata.templateMode} | framework-detection: ${metadata.frameworkMode} | source: ${metadata.source} -->`,
65
373
  ` <!-- mapping: ${mapping.implementation} -> ${mapping.designPage}${mapping.screenId ? ` (${mapping.screenId})` : ""} -->`,
66
374
  " <main>",
67
375
  " <section data-scaffold-region=\"hero\">TODO: implement hero region</section>",
@@ -74,10 +382,76 @@ function buildSkeletonDocument(mapping) {
74
382
  ].join("\n");
75
383
  }
76
384
 
385
+ function buildReactTemplate(mapping, metadata) {
386
+ return [
387
+ "// TODO(scaffold): Replace this reviewable scaffold component with production implementation.",
388
+ `// template-mode: ${metadata.templateMode} | framework-detection: ${metadata.frameworkMode} | source: ${metadata.source}`,
389
+ `// mapping: ${mapping.implementation} -> ${mapping.designPage}${mapping.screenId ? ` (${mapping.screenId})` : ""}`,
390
+ "",
391
+ "export default function ScaffoldPage() {",
392
+ " return (",
393
+ " <main data-scaffold=\"reviewable-only\">",
394
+ " <section data-scaffold-region=\"hero\">TODO(scaffold): implement hero region</section>",
395
+ " <section data-scaffold-region=\"content\">TODO(scaffold): implement content regions</section>",
396
+ " <section data-scaffold-region=\"actions\">TODO(scaffold): implement action regions</section>",
397
+ " </main>",
398
+ " );",
399
+ "}",
400
+ ""
401
+ ].join("\n");
402
+ }
403
+
404
+ function buildVueTemplate(mapping, metadata) {
405
+ return [
406
+ "<template>",
407
+ " <main data-scaffold=\"reviewable-only\">",
408
+ " <section data-scaffold-region=\"hero\">TODO(scaffold): implement hero region</section>",
409
+ " <section data-scaffold-region=\"content\">TODO(scaffold): implement content regions</section>",
410
+ " <section data-scaffold-region=\"actions\">TODO(scaffold): implement action regions</section>",
411
+ " </main>",
412
+ "</template>",
413
+ "",
414
+ "<script setup>",
415
+ "// TODO(scaffold): Replace this reviewable scaffold component with production implementation.",
416
+ `// template-mode: ${metadata.templateMode} | framework-detection: ${metadata.frameworkMode} | source: ${metadata.source}`,
417
+ `// mapping: ${mapping.implementation} -> ${mapping.designPage}${mapping.screenId ? ` (${mapping.screenId})` : ""}`,
418
+ "</script>",
419
+ ""
420
+ ].join("\n");
421
+ }
422
+
423
+ function buildSvelteTemplate(mapping, metadata) {
424
+ return [
425
+ "<!-- TODO(scaffold): Replace this reviewable scaffold component with production implementation. -->",
426
+ `<!-- template-mode: ${metadata.templateMode} | framework-detection: ${metadata.frameworkMode} | source: ${metadata.source} -->`,
427
+ `<!-- mapping: ${mapping.implementation} -> ${mapping.designPage}${mapping.screenId ? ` (${mapping.screenId})` : ""} -->`,
428
+ "<main data-scaffold=\"reviewable-only\">",
429
+ " <section data-scaffold-region=\"hero\">TODO(scaffold): implement hero region</section>",
430
+ " <section data-scaffold-region=\"content\">TODO(scaffold): implement content regions</section>",
431
+ " <section data-scaffold-region=\"actions\">TODO(scaffold): implement action regions</section>",
432
+ "</main>",
433
+ ""
434
+ ].join("\n");
435
+ }
436
+
437
+ function buildScaffoldDocument(mapping, metadata) {
438
+ if (metadata.templateMode === "next" || metadata.templateMode === "react") {
439
+ return buildReactTemplate(mapping, metadata);
440
+ }
441
+ if (metadata.templateMode === "vue") {
442
+ return buildVueTemplate(mapping, metadata);
443
+ }
444
+ if (metadata.templateMode === "svelte") {
445
+ return buildSvelteTemplate(mapping, metadata);
446
+ }
447
+ return buildHtmlTemplate(mapping, metadata);
448
+ }
449
+
77
450
  function scaffoldFromBindings(projectPathInput, options = {}) {
78
451
  const projectRoot = path.resolve(projectPathInput || process.cwd());
79
452
  const requestedChangeId = options.changeId ? String(options.changeId).trim() : "";
80
453
  const outputDir = path.resolve(options.outputDir || path.join(projectRoot, ".da-vinci", "scaffold"));
454
+ const frameworkDetection = detectFramework(projectRoot);
81
455
  const result = {
82
456
  status: STATUS.PASS,
83
457
  failures: [],
@@ -87,7 +461,9 @@ function scaffoldFromBindings(projectPathInput, options = {}) {
87
461
  changeId: null,
88
462
  outputDir,
89
463
  files: [],
90
- mvpScope: "single static HTML skeleton per binding; multi-framework expansion is intentionally out of scope."
464
+ frameworkDetection,
465
+ mvpScope:
466
+ "framework-aware reviewable scaffold templates only; generated output remains TODO-marked and non-final."
91
467
  };
92
468
 
93
469
  const resolved = resolveChangeDir(projectRoot, requestedChangeId);
@@ -113,11 +489,47 @@ function scaffoldFromBindings(projectPathInput, options = {}) {
113
489
  return result;
114
490
  }
115
491
 
492
+ if (frameworkDetection.mode === "unknown") {
493
+ result.warnings.push("Framework detection is unknown; scaffold falls back to HTML template mode when landing shape is not known.");
494
+ } else if (frameworkDetection.mode === "ambiguous") {
495
+ result.warnings.push(
496
+ `Framework detection is ambiguous (${frameworkDetection.candidates.join(", ")}); scaffold falls back to HTML template mode when landing shape is not known.`
497
+ );
498
+ } else {
499
+ result.notes.push(
500
+ `Framework detection selected ${frameworkDetection.mode} (${frameworkDetection.reasons[frameworkDetection.mode].join(", ")}).`
501
+ );
502
+ }
503
+ if (Array.isArray(frameworkDetection.scanLimitSignals) && frameworkDetection.scanLimitSignals.length > 0) {
504
+ result.warnings.push(
505
+ `Framework detection hit scan limits (${TARGET_SCAN_MAX_FILES} files); unresolved probes: ${frameworkDetection.scanLimitSignals.join(", ")}.`
506
+ );
507
+ }
508
+
116
509
  fs.mkdirSync(outputDir, { recursive: true });
117
510
  const outputRoot = path.resolve(outputDir);
118
511
  const outputRootPrefix = outputRoot.endsWith(path.sep) ? outputRoot : `${outputRoot}${path.sep}`;
119
512
  for (const mapping of bindings.mappings) {
120
- const targetRelativePath = sanitizeRouteToFileName(mapping.implementation);
513
+ const landing = resolveImplementationLanding(projectRoot, mapping.implementation);
514
+
515
+ let templateMode = "html";
516
+ let source = "framework-fallback";
517
+ let targetRelativePath = null;
518
+ if (landing && pathWithinRoot(projectRoot, landing)) {
519
+ targetRelativePath = normalizeRelativePath(path.relative(projectRoot, landing));
520
+ templateMode = inferTemplateModeFromLanding(targetRelativePath, frameworkDetection);
521
+ source = "existing-landing";
522
+ } else {
523
+ if (frameworkDetection.mode === "unknown" || frameworkDetection.mode === "ambiguous") {
524
+ templateMode = "html";
525
+ source = frameworkDetection.mode === "unknown" ? "unknown-fallback" : "ambiguous-fallback";
526
+ } else {
527
+ templateMode = frameworkDetection.mode;
528
+ source = "framework-detected";
529
+ }
530
+ targetRelativePath = deriveFrameworkTargetPath(mapping.implementation, templateMode);
531
+ }
532
+
121
533
  if (!targetRelativePath) {
122
534
  result.failures.push(
123
535
  `Blocked scaffold route \`${mapping.implementation}\`: traversal segments are not allowed.`
@@ -132,12 +544,22 @@ function scaffoldFromBindings(projectPathInput, options = {}) {
132
544
  );
133
545
  continue;
134
546
  }
547
+
135
548
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
136
- writeFileAtomic(targetPath, buildSkeletonDocument(mapping));
549
+ writeFileAtomic(
550
+ targetPath,
551
+ buildScaffoldDocument(mapping, {
552
+ templateMode,
553
+ frameworkMode: frameworkDetection.mode,
554
+ source
555
+ })
556
+ );
137
557
  result.files.push({
138
558
  mapping: mapping.implementation,
139
559
  designPage: mapping.designPage,
140
- path: targetPath
560
+ path: targetPath,
561
+ templateMode,
562
+ source
141
563
  });
142
564
  }
143
565
 
@@ -151,10 +573,10 @@ function scaffoldFromBindings(projectPathInput, options = {}) {
151
573
  "Run `da-vinci verify-bindings`, `da-vinci verify-implementation`, and `da-vinci verify-structure` before accepting scaffold output."
152
574
  );
153
575
  result.notes.push(
154
- "Scaffold boundaries are derived from `pencil-bindings.md` implementation-to-design mappings; no framework-specific expansion is performed."
576
+ "Known implementation landings keep precedence over framework defaults so scaffold targets match existing project shapes."
155
577
  );
156
- result.warnings = unique(result.warnings);
157
578
  result.notes = unique(result.notes);
579
+ result.warnings = unique(result.warnings);
158
580
  return result;
159
581
  }
160
582
 
@@ -164,7 +586,8 @@ function formatScaffoldReport(result) {
164
586
  `Project: ${result.projectRoot}`,
165
587
  `Change: ${result.changeId || "(not selected)"}`,
166
588
  `Status: ${result.status}`,
167
- `Output dir: ${result.outputDir}`
589
+ `Output dir: ${result.outputDir}`,
590
+ `Framework detection: ${result.frameworkDetection ? result.frameworkDetection.mode : "unknown"}`
168
591
  ];
169
592
  if (result.failures.length > 0) {
170
593
  lines.push("", "Failures:");
@@ -172,10 +595,18 @@ function formatScaffoldReport(result) {
172
595
  lines.push(`- ${failure}`);
173
596
  }
174
597
  }
598
+ if (result.warnings.length > 0) {
599
+ lines.push("", "Warnings:");
600
+ for (const warning of result.warnings) {
601
+ lines.push(`- ${warning}`);
602
+ }
603
+ }
175
604
  if (result.files.length > 0) {
176
605
  lines.push("", "Generated files:");
177
606
  for (const file of result.files) {
178
- lines.push(`- ${file.path} (${file.mapping} -> ${file.designPage})`);
607
+ lines.push(
608
+ `- ${file.path} (${file.mapping} -> ${file.designPage}; mode=${file.templateMode}; source=${file.source})`
609
+ );
179
610
  }
180
611
  }
181
612
  if (result.notes.length > 0) {
package/lib/utils.js CHANGED
@@ -28,6 +28,23 @@ function readTextIfExists(targetPath, options = {}) {
28
28
  return fs.readFileSync(targetPath, encoding);
29
29
  }
30
30
 
31
+ function normalizeRelativePath(relativePath) {
32
+ return String(relativePath || "")
33
+ .split(path.sep)
34
+ .filter(Boolean)
35
+ .join("/");
36
+ }
37
+
38
+ function pathWithinRoot(projectRoot, candidatePath) {
39
+ const root = path.resolve(projectRoot);
40
+ const candidate = path.resolve(candidatePath);
41
+ if (candidate === root) {
42
+ return true;
43
+ }
44
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
45
+ return candidate.startsWith(prefix);
46
+ }
47
+
31
48
  function uniqueValues(values) {
32
49
  return Array.from(new Set((values || []).filter(Boolean)));
33
50
  }
@@ -120,6 +137,8 @@ module.exports = {
120
137
  escapeRegExp,
121
138
  pathExists,
122
139
  readTextIfExists,
140
+ normalizeRelativePath,
141
+ pathWithinRoot,
123
142
  uniqueValues,
124
143
  parseJsonText,
125
144
  readJsonFile,