@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.0

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
package/lib/batch-runs.js CHANGED
@@ -101,15 +101,35 @@ export function buildCompletedSlotMap(priorResults) {
101
101
  return completed;
102
102
  }
103
103
 
104
+ // SDL-H7: the only shape a batch id can ever legitimately have, mirroring
105
+ // the prefix/date/seq scheme generateBatchId() builds above. Validated
106
+ // BEFORE the id is joined into a path, so `--resume
107
+ // ../../../../some/dir/leftover` never reaches existsSync/readFile —
108
+ // executeBatchRuns checkpoints into batchDir/manifest.json after every
109
+ // slot, so an unvalidated id here meant repeatedly overwriting a file
110
+ // outside the sandbox. A mismatch reuses the SAME BATCH_NOT_FOUND code (not
111
+ // a distinct one) because no real batch can ever have an id outside this
112
+ // shape, and this avoids leaking validation internals.
113
+ const BATCH_ID_RE = /^batch_\d{4}-\d{2}-\d{2}_\d{3}$/;
114
+
104
115
  /**
105
116
  * Resume an existing batch directory. Loads its prior manifest so the caller
106
117
  * can replay only the incomplete slots while keeping the same batch_id.
107
118
  *
108
- * Throws BATCH_NOT_FOUND if the batch dir or manifest is missing.
119
+ * Throws BATCH_NOT_FOUND if the batch dir or manifest is missing (or the id
120
+ * is not shaped like a real batch id).
109
121
  * Throws BATCH_NO_PROGRESS if the manifest predates the incremental-checkpoint
110
122
  * format (no `slots` array) — those batches can't be partially resumed.
111
123
  */
112
124
  export function openBatchDirForResume(projectRoot, batchId) {
125
+ if (typeof batchId !== 'string' || !BATCH_ID_RE.test(batchId)) {
126
+ throw inputError(
127
+ 'BATCH_NOT_FOUND',
128
+ `Batch "${batchId}" not found`,
129
+ 'Run `sdlab batch list` to see available batch IDs.'
130
+ );
131
+ }
132
+
113
133
  const batchesDir = getBatchesDir(projectRoot);
114
134
  const batchDir = join(batchesDir, batchId);
115
135
  const manifestPath = join(batchDir, 'manifest.json');
@@ -271,6 +291,14 @@ export async function saveBatchManifest(batchDir, manifest) {
271
291
  * Load a batch manifest by ID.
272
292
  */
273
293
  export function loadBatchManifest(projectRoot, batchId) {
294
+ if (typeof batchId !== 'string' || !BATCH_ID_RE.test(batchId)) {
295
+ throw inputError(
296
+ 'BATCH_NOT_FOUND',
297
+ `Batch "${batchId}" not found`,
298
+ 'Check the batch ID or run: sdlab batch generate --mode <id>'
299
+ );
300
+ }
301
+
274
302
  const batchDir = join(getBatchesDir(projectRoot), batchId);
275
303
  const manifestPath = join(batchDir, 'manifest.json');
276
304
 
@@ -100,8 +100,11 @@ export function renderSheetHTML({ manifest, mode, projectRoot, batchDir }) {
100
100
 
101
101
  /**
102
102
  * Compute optimal column count based on slot count.
103
+ *
104
+ * Exported for reuse by lib/candidate-sheet.js — the ad-hoc directory sheet
105
+ * renderer shares this grid-sizing heuristic rather than forking it.
103
106
  */
104
- function computeColumns(slotCount) {
107
+ export function computeColumns(slotCount) {
105
108
  if (slotCount <= 3) return slotCount;
106
109
  if (slotCount <= 4) return 2;
107
110
  if (slotCount <= 6) return 3;
@@ -111,8 +114,12 @@ function computeColumns(slotCount) {
111
114
 
112
115
  /**
113
116
  * Generate CSS for the layout.
117
+ *
118
+ * Exported for reuse by lib/candidate-sheet.js, which appends its own
119
+ * supplemental rules (status badges, prompt text) rather than duplicating
120
+ * the base grid/cell/dark-theme rules defined here.
114
121
  */
115
- function generateCSS(layout, columns, slotCount) {
122
+ export function generateCSS(layout, columns, slotCount) {
116
123
  return `
117
124
  * { margin: 0; padding: 0; box-sizing: border-box; }
118
125
  body {
@@ -188,8 +195,11 @@ function findSlotSeed(manifest, slot) {
188
195
 
189
196
  /**
190
197
  * Escape HTML entities.
198
+ *
199
+ * Exported for reuse by lib/candidate-sheet.js — one escaping implementation
200
+ * for every HTML sheet this tool produces.
191
201
  */
192
- function escapeHtml(str) {
202
+ export function escapeHtml(str) {
193
203
  if (!str) return '';
194
204
  return String(str)
195
205
  .replace(/&/g, '&amp;')
@@ -487,6 +487,25 @@ export async function compileBrief({
487
487
  subjectContext = extractSubjectContext(subjectRecords);
488
488
  }
489
489
 
490
+ // H1: subject_mode 'required' is enforced above on the *string* being
491
+ // non-empty (BRIEF_SUBJECT_REQUIRED), but resolveSubject() swallows every
492
+ // per-file parse failure (catch {}) and returns [] when nothing matches —
493
+ // a typo'd subject id passed that string check and silently compiled a
494
+ // generic brief wearing a subject label it never anchored to (no
495
+ // subject_identity block, no subject_constraints, both gated on
496
+ // length > 0 below). resolveReferences() fails loudly (BRIEF_REF_NOT_FOUND)
497
+ // on the identical "nothing matched" case — this closes the asymmetry.
498
+ if (subjectMode === 'required' && subjectRecords.length === 0) {
499
+ throw inputError(
500
+ 'BRIEF_SUBJECT_NOT_FOUND',
501
+ `Workflow "${workflowId}" requires a subject (subject_mode: required), but no records matched ` +
502
+ `subject "${subjectId}" in ${recordsDir}.`,
503
+ `Check "${subjectId}" for typos, or confirm records exist with identity.subject_id (or an id ` +
504
+ `prefix) matching it. resolveSubject() matches by identity.subject_id equality or by id-prefix — ` +
505
+ `see lib/brief-compiler.js's resolveSubject().`
506
+ );
507
+ }
508
+
490
509
  // Resolve explicit references
491
510
  const referenceSelection = await resolveReferences(recordsDir, referenceIds);
492
511
 
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Candidate sheet — HTML contact sheet over an ARBITRARY directory of
3
+ * images. No batch manifest required.
4
+ *
5
+ * lib/batch-sheet-render.js already has a complete, zero-dependency HTML
6
+ * contact-sheet renderer, but it can only be reached from `sdlab batch
7
+ * sheet`, which needs a batches/<id>/manifest.json that only `sdlab batch
8
+ * generate` produces. This module reuses that renderer's shared primitives
9
+ * (escapeHtml, computeColumns, generateCSS) rather than forking them, and
10
+ * adds the one thing the batch path doesn't need: turning a plain directory
11
+ * of images into reviewable tiles by matching each image to its record.
12
+ *
13
+ * Matching key: `asset_path` (project-root-relative, e.g.
14
+ * "outputs/candidates/foo.png"), not a guessed id/filename convention.
15
+ * Record id prefixes are NOT uniform across this codebase — hand-curated
16
+ * records use the bare filename stem as their id, but lib/reingest.js
17
+ * prefixes re-ingested records with "gen_". asset_path is the one field
18
+ * every schema version agrees on, so it is the only safe join key.
19
+ *
20
+ * Images with no matching record (pre-ingest — generated but not yet
21
+ * ingested into records/) still render: id/status/prompt degrade to
22
+ * `null` and the tile falls back to the filename. This must never throw.
23
+ */
24
+
25
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
26
+ import { join, relative, extname, basename, dirname } from 'node:path';
27
+ import { escapeHtml, computeColumns, generateCSS } from './batch-sheet-render.js';
28
+
29
+ // Mirrors lib/reingest.js's IMAGE_EXTENSIONS / lib/ingest.js's copy of the
30
+ // same set. Not imported from there — this is a small, stable, load-bearing
31
+ // constant duplicated by convention elsewhere in this codebase rather than
32
+ // centralized; see lib/ingest.js's own comment to that effect.
33
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
34
+
35
+ /**
36
+ * Normalize a path string to forward slashes with no leading "./", so
37
+ * Windows-vs-POSIX path separators and a stray relative-path prefix never
38
+ * cause an asset_path lookup to miss.
39
+ */
40
+ function normalizeRelPath(p) {
41
+ return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
42
+ }
43
+
44
+ /**
45
+ * Recursively collect every image file under `rootDir`, sorted
46
+ * deterministically (lexicographic on the POSIX-normalized absolute path,
47
+ * which — because every result shares the same root prefix — is exactly
48
+ * equivalent to sorting on the relative path). Regenerating a sheet from an
49
+ * unchanged directory always produces the same order.
50
+ *
51
+ * Never throws on a missing directory — returns `[]` instead, so callers
52
+ * that already validated the directory exists (or want to treat "empty" and
53
+ * "missing" the same way) don't need a try/catch.
54
+ *
55
+ * @param {string} rootDir — absolute directory to walk
56
+ * @returns {Promise<string[]>} absolute file paths
57
+ */
58
+ export async function walkImages(rootDir) {
59
+ const found = [];
60
+
61
+ async function walk(current) {
62
+ let entries;
63
+ try {
64
+ entries = await readdir(current, { withFileTypes: true });
65
+ } catch (err) {
66
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return;
67
+ throw err;
68
+ }
69
+ for (const entry of entries) {
70
+ const full = join(current, entry.name);
71
+ if (entry.isDirectory()) {
72
+ await walk(full);
73
+ } else if (entry.isFile() && IMAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
74
+ found.push(full);
75
+ }
76
+ }
77
+ }
78
+
79
+ await walk(rootDir);
80
+ found.sort((a, b) => {
81
+ const pa = normalizeRelPath(a);
82
+ const pb = normalizeRelPath(b);
83
+ return pa < pb ? -1 : pa > pb ? 1 : 0;
84
+ });
85
+ return found;
86
+ }
87
+
88
+ /**
89
+ * Build an index of every record in a project, keyed by its normalized
90
+ * asset_path. A malformed record file is skipped (not fatal) — same
91
+ * resilience pattern as lib/batch-modes.js's listBatchModes(): one bad file
92
+ * must not hide every good one.
93
+ *
94
+ * @param {string} projectRoot
95
+ * @returns {Promise<Map<string, Object>>}
96
+ */
97
+ export async function buildAssetPathIndex(projectRoot) {
98
+ const recordsDir = join(projectRoot, 'records');
99
+ const index = new Map();
100
+
101
+ let files;
102
+ try {
103
+ files = await readdir(recordsDir);
104
+ } catch (err) {
105
+ if (err && err.code === 'ENOENT') return index;
106
+ throw err;
107
+ }
108
+
109
+ for (const file of files) {
110
+ if (!file.endsWith('.json')) continue;
111
+ let record;
112
+ try {
113
+ record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
114
+ } catch {
115
+ continue; // malformed record file — skip, don't abort the whole scan
116
+ }
117
+ if (record && typeof record.asset_path === 'string' && record.asset_path.length > 0) {
118
+ index.set(normalizeRelPath(record.asset_path), record);
119
+ }
120
+ }
121
+
122
+ return index;
123
+ }
124
+
125
+ /**
126
+ * Build the per-tile item list for a candidate sheet: every image under
127
+ * `targetDir`, matched against the project's records by asset_path.
128
+ *
129
+ * Each item degrades gracefully when no record matches (pre-ingest state):
130
+ * id/status/prompt are `null` and `hasRecord` is `false` — the caller
131
+ * renders the filename instead. This never throws for that case.
132
+ *
133
+ * `status` distinguishes three states:
134
+ * - no record at all → status: null, hasRecord: false
135
+ * - record exists, unjudged → status: 'uncurated', hasRecord: true
136
+ * - record exists, judged → status: <judgment.status>, hasRecord: true
137
+ *
138
+ * Ordering is deterministic (inherited from walkImages' sort) — regenerating
139
+ * a sheet over an unchanged directory produces byte-identical item order.
140
+ *
141
+ * @param {string} projectRoot
142
+ * @param {string} targetDir — absolute directory to sheet
143
+ * @returns {Promise<Array<Object>>}
144
+ */
145
+ export async function buildCandidateSheetItems(projectRoot, targetDir) {
146
+ const [images, index] = await Promise.all([
147
+ walkImages(targetDir),
148
+ buildAssetPathIndex(projectRoot),
149
+ ]);
150
+
151
+ return images.map((absPath) => {
152
+ const relFromRoot = normalizeRelPath(relative(projectRoot, absPath));
153
+ const record = index.get(relFromRoot) || null;
154
+ const hasRecord = !!record;
155
+ return {
156
+ absPath,
157
+ relFromRoot,
158
+ filename: basename(absPath),
159
+ id: hasRecord ? (record.id ?? null) : null,
160
+ status: hasRecord ? (record.judgment?.status ?? 'uncurated') : null,
161
+ prompt: hasRecord ? (record.provenance?.prompt ?? null) : null,
162
+ hasRecord,
163
+ };
164
+ });
165
+ }
166
+
167
+ /** Truncate long prompt text for inline display; full text stays in the title attribute. */
168
+ function truncate(str, max) {
169
+ if (str.length <= max) return str;
170
+ return `${str.slice(0, max - 1)}…`;
171
+ }
172
+
173
+ // Supplemental CSS for candidate-specific tile chrome (id/status/prompt).
174
+ // Appended to lib/batch-sheet-render.js's generateCSS() output rather than
175
+ // duplicating its grid/.cell/.label-bar/dark-theme rules.
176
+ const CANDIDATE_EXTRA_CSS = `
177
+ .cell .cand-id-row {
178
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
179
+ }
180
+ .cell .cand-id {
181
+ font-weight: 600; color: #ccc;
182
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
183
+ }
184
+ .cell .cand-id--missing { color: #888; font-style: italic; font-weight: 400; }
185
+ .cell .cand-status {
186
+ flex: none;
187
+ font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em;
188
+ padding: 2px 6px; border-radius: 3px;
189
+ background: #333; color: #aaa;
190
+ }
191
+ .cell .cand-status--approved { background: #1f4d2e; color: #8fdfa8; }
192
+ .cell .cand-status--rejected { background: #4d1f1f; color: #df8f8f; }
193
+ .cell .cand-status--borderline { background: #4d451f; color: #dfd08f; }
194
+ .cell .cand-status--uncurated { background: #333333; color: #999; }
195
+ .cell .cand-status--none { background: #262626; color: #666; }
196
+ .cell .cand-prompt {
197
+ margin-top: 4px; font-size: 0.72rem; color: #888; line-height: 1.3;
198
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
199
+ overflow: hidden;
200
+ }
201
+ `;
202
+
203
+ /**
204
+ * Render a single candidate tile. Image src is a RELATIVE path from the
205
+ * directory the sheet HTML will be written to, to the image file — never
206
+ * base64. These corpora run into the thousands of images; embedding would
207
+ * make the sheet unopenable.
208
+ */
209
+ function renderCandidateCell(item, sheetOutputDir) {
210
+ const relImg = normalizeRelPath(relative(sheetOutputDir, item.absPath));
211
+ const imgTag = `<img src="${escapeHtml(relImg)}" alt="${escapeHtml(item.filename)}" loading="lazy">`;
212
+
213
+ const idHtml = item.id
214
+ ? `<span class="cand-id">${escapeHtml(item.id)}</span>`
215
+ : `<span class="cand-id cand-id--missing">${escapeHtml(item.filename)}</span>`;
216
+
217
+ const statusText = item.hasRecord ? (item.status || 'uncurated') : 'no record';
218
+ const statusClass = item.hasRecord ? `cand-status--${item.status || 'uncurated'}` : 'cand-status--none';
219
+ const statusHtml = `<span class="cand-status ${escapeHtml(statusClass)}">${escapeHtml(statusText)}</span>`;
220
+
221
+ const promptHtml = item.prompt
222
+ ? `<div class="cand-prompt" title="${escapeHtml(item.prompt)}">${escapeHtml(truncate(item.prompt, 200))}</div>`
223
+ : '';
224
+
225
+ return ` <div class="cell">${imgTag}
226
+ <div class="label-bar">
227
+ <div class="cand-id-row">${idHtml}${statusHtml}</div>
228
+ ${promptHtml}
229
+ </div>
230
+ </div>`;
231
+ }
232
+
233
+ /**
234
+ * Render a full candidate sheet HTML document.
235
+ *
236
+ * @param {Object} opts
237
+ * @param {Object[]} opts.items — from buildCandidateSheetItems()
238
+ * @param {string} opts.title
239
+ * @param {string} opts.sourceLabel — human-readable source dir (for the meta line)
240
+ * @param {string} opts.sheetOutputDir — absolute dir the HTML file will be written to (image srcs are relative to this)
241
+ * @param {string} [opts.generatedAt] — ISO timestamp; defaults to now (pass explicitly for deterministic tests)
242
+ * @returns {string} HTML
243
+ */
244
+ export function renderCandidateSheetHTML({ items, title, sourceLabel, sheetOutputDir, generatedAt }) {
245
+ const columns = computeColumns(items.length || 1);
246
+ const css = generateCSS('grid', columns, items.length) + CANDIDATE_EXTRA_CSS;
247
+ const cellsHtml = items.map((item) => renderCandidateCell(item, sheetOutputDir)).join('\n');
248
+
249
+ const matched = items.filter((i) => i.hasRecord).length;
250
+ const preIngest = items.length - matched;
251
+
252
+ const statusCounts = {};
253
+ for (const item of items) {
254
+ if (!item.hasRecord) continue;
255
+ const key = item.status || 'uncurated';
256
+ statusCounts[key] = (statusCounts[key] || 0) + 1;
257
+ }
258
+ const statusSummary = Object.entries(statusCounts)
259
+ .map(([k, v]) => `${k}: ${v}`)
260
+ .join(', ');
261
+
262
+ const metaLine = [
263
+ `Source: ${escapeHtml(sourceLabel)}`,
264
+ `${items.length} image${items.length === 1 ? '' : 's'}`,
265
+ `${matched} with record${matched === 1 ? '' : 's'}${statusSummary ? ` (${escapeHtml(statusSummary)})` : ''}`,
266
+ `${preIngest} pre-ingest`,
267
+ ].join(' | ');
268
+
269
+ const stamp = generatedAt || new Date().toISOString();
270
+
271
+ return `<!DOCTYPE html>
272
+ <html lang="en">
273
+ <head>
274
+ <meta charset="utf-8">
275
+ <title>${escapeHtml(title)}</title>
276
+ <style>${css}</style>
277
+ </head>
278
+ <body>
279
+ <header>
280
+ <h1>${escapeHtml(title)}</h1>
281
+ <p class="meta">${metaLine}</p>
282
+ </header>
283
+ <div class="grid">${cellsHtml}
284
+ </div>
285
+ <footer>
286
+ <p>Generated by style-dataset-lab — ${escapeHtml(stamp)}</p>
287
+ </footer>
288
+ </body>
289
+ </html>`;
290
+ }
291
+
292
+ /**
293
+ * Turn a project-relative directory path into a filesystem-safe filename
294
+ * stem, so the same source dir always maps to the same output filename
295
+ * (regenerating overwrites in place instead of accumulating duplicates).
296
+ */
297
+ export function slugifyRelPath(relPath) {
298
+ const norm = normalizeRelPath(relPath || '');
299
+ const slug = norm
300
+ .replace(/[\\/]+/g, '-')
301
+ .replace(/[^a-zA-Z0-9-_.]+/g, '-')
302
+ .replace(/-+/g, '-')
303
+ .replace(/^-|-$/g, '');
304
+ return slug || 'sheet';
305
+ }
306
+
307
+ /**
308
+ * Default output path for a candidate sheet over `relDir`.
309
+ * @param {string} projectRoot
310
+ * @param {string} relDir — project-relative source directory
311
+ */
312
+ export function defaultSheetOutputPath(projectRoot, relDir) {
313
+ return join(projectRoot, 'outputs', 'sheets', `${slugifyRelPath(relDir)}.html`);
314
+ }
315
+
316
+ /**
317
+ * Write a candidate sheet HTML document to disk, creating parent
318
+ * directories as needed.
319
+ * @param {string} outputPath — absolute path to write
320
+ * @param {string} html
321
+ * @returns {Promise<string>} outputPath
322
+ */
323
+ export async function saveCandidateSheet(outputPath, html) {
324
+ await mkdir(dirname(outputPath), { recursive: true });
325
+ await writeFile(outputPath, html);
326
+ return outputPath;
327
+ }