create-portfoliable 1.2.14 → 1.2.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-portfoliable",
3
- "version": "1.2.14",
3
+ "version": "1.2.16",
4
4
  "description": "Unified Portfoliable package with initializer and runtime CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { pathToFileURL } from "node:url";
12
12
  const REPO_URL = "https://github.com/portfoliablejs/portfoliable";
13
13
  // Enables dry-run behavior when --dry-run is provided on process args.
14
14
  const isDryRun = process.argv.includes("--dry-run");
15
+ const isPreparePr = process.argv.includes("--prepare-pr");
15
16
  // Enables signing release commit and tag objects when RELEASE_SIGN=true.
16
17
  const shouldSign = process.env.RELEASE_SIGN === "true";
17
18
 
@@ -410,6 +411,11 @@ function run() {
410
411
  }
411
412
  git(commitArgs);
412
413
 
414
+ if (isPreparePr) {
415
+ console.log("Release PR preparation enabled; skipping tag creation until merge.");
416
+ return;
417
+ }
418
+
413
419
  // Creates annotated or signed tags for each release if tag is not already present.
414
420
  for (const plan of releases) {
415
421
  // Checks whether target tag already exists in repository.
@@ -10,11 +10,13 @@ import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from '../templa
10
10
 
11
11
  // MARK: DEFAULTS
12
12
  // Defines default output path used when no known case root can be resolved.
13
- const DEFAULT_OUTPUT = 'src/content/cases/my-case/case.md';
13
+ const DEFAULT_OUTPUT = 'src/content/cases/my-case/my-case.md';
14
14
  // Defines default case name used when no name is provided.
15
15
  const DEFAULT_NAME = 'My Case';
16
16
  // Defines candidate case roots in priority order for local repo and consumer apps.
17
17
  const DEFAULT_CASES_ROOTS = ['templates/src/content/cases', 'src/content/cases'];
18
+ // Defines order increment applied when choosing the next generated case order value.
19
+ const CASE_ORDER_STEP = 10;
18
20
 
19
21
  // MARK: DEFAULT CASE TEMPLATE
20
22
  // Centralized defaults used when scaffolding new cases.
@@ -178,15 +180,110 @@ function resolveCasesRoots(cwd) {
178
180
  }
179
181
 
180
182
  // Computes default output markdown path from first discovered cases root.
181
- function resolveDefaultOutFile(cwd, name) {
182
- const caseSlug = toSlug(name);
183
+ function resolvePrimaryCasesRoot(cwd) {
183
184
  const roots = resolveCasesRoots(cwd);
184
185
  if (roots.length > 0) {
185
- const target = path.join(roots[0], caseSlug, 'case.md');
186
- return path.relative(cwd, target);
186
+ return roots[0];
187
187
  }
188
188
 
189
- return DEFAULT_OUTPUT;
189
+ return path.resolve(cwd, DEFAULT_CASES_ROOTS[0]);
190
+ }
191
+
192
+ function walkMarkdownFiles(rootDir, files = []) {
193
+ if (!fs.existsSync(rootDir)) {
194
+ return files;
195
+ }
196
+
197
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
198
+ entries.forEach((entry) => {
199
+ const entryPath = path.join(rootDir, entry.name);
200
+ if (entry.isDirectory()) {
201
+ walkMarkdownFiles(entryPath, files);
202
+ return;
203
+ }
204
+
205
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
206
+ files.push(entryPath);
207
+ }
208
+ });
209
+
210
+ return files;
211
+ }
212
+
213
+ function collectExistingCaseMetadata(caseRoot) {
214
+ const usedSlugs = new Set();
215
+ const caseOrders = [];
216
+ const markdownFiles = walkMarkdownFiles(caseRoot, []);
217
+
218
+ markdownFiles.forEach((filePath) => {
219
+ const relativePath = path.relative(caseRoot, filePath);
220
+ const pathSegments = relativePath.split(path.sep).filter(Boolean);
221
+ const folderSlug = pathSegments.length > 1 ? pathSegments[0] : '';
222
+ if (folderSlug) {
223
+ usedSlugs.add(toSlug(folderSlug));
224
+ }
225
+
226
+ const fileSlug = toSlug(path.basename(filePath, '.md'));
227
+ if (fileSlug) {
228
+ usedSlugs.add(fileSlug);
229
+ }
230
+
231
+ let source = '';
232
+ try {
233
+ source = fs.readFileSync(filePath, 'utf8');
234
+ } catch {
235
+ return;
236
+ }
237
+
238
+ const idMatch = source.match(/"id"\s*:\s*"([^"]+)"/);
239
+ if (idMatch?.[1]) {
240
+ usedSlugs.add(toSlug(idMatch[1]));
241
+ }
242
+
243
+ const slugBlockMatch = source.match(/"slugByLocale"\s*:\s*\{([\s\S]*?)\}/);
244
+ if (slugBlockMatch?.[1]) {
245
+ const slugValues = slugBlockMatch[1].matchAll(/:\s*"([^"]+)"/g);
246
+ for (const slugMatch of slugValues) {
247
+ if (slugMatch?.[1]) {
248
+ usedSlugs.add(toSlug(slugMatch[1]));
249
+ }
250
+ }
251
+ }
252
+
253
+ const orderMatch = source.match(/"caseOrder"\s*:\s*(\d+)/);
254
+ if (orderMatch?.[1]) {
255
+ const parsedOrder = Number.parseInt(orderMatch[1], 10);
256
+ if (Number.isFinite(parsedOrder)) {
257
+ caseOrders.push(parsedOrder);
258
+ }
259
+ }
260
+ });
261
+
262
+ return { usedSlugs, caseOrders };
263
+ }
264
+
265
+ function resolveUniqueSlug(baseSlug, usedSlugs) {
266
+ if (!usedSlugs.has(baseSlug)) {
267
+ return baseSlug;
268
+ }
269
+
270
+ let index = 2;
271
+ while (usedSlugs.has(`${baseSlug}-${index}`)) {
272
+ index += 1;
273
+ }
274
+
275
+ return `${baseSlug}-${index}`;
276
+ }
277
+
278
+ function resolveDefaultScaffoldValues(cwd, name) {
279
+ const caseRoot = resolvePrimaryCasesRoot(cwd);
280
+ const { usedSlugs, caseOrders } = collectExistingCaseMetadata(caseRoot);
281
+ const baseSlug = toSlug(name);
282
+ const slug = resolveUniqueSlug(baseSlug, usedSlugs);
283
+ const maxOrder = caseOrders.length > 0 ? Math.max(...caseOrders) : DEFAULT_CASE_TEMPLATE.caseOrder - CASE_ORDER_STEP;
284
+ const caseOrder = maxOrder + CASE_ORDER_STEP;
285
+ const outFile = path.relative(cwd, path.join(caseRoot, slug, `${slug}.md`));
286
+ return { outFile, slug, caseOrder };
190
287
  }
191
288
 
192
289
  // Derives case slug from output path patterns or fallback case name.
@@ -277,7 +374,7 @@ function formatCaseConfigBlock(caseConfig) {
277
374
  }
278
375
 
279
376
  // Build the markdown scaffold body used for new cases.
280
- function buildTemplate({ name, slug, localeCodes }) {
377
+ function buildTemplate({ name, slug, caseOrder, localeCodes }) {
281
378
  // Derives normalized title for localized title fields.
282
379
  const title = toTitle(name);
283
380
  // Derives normalized slug for id and localized route slug fields.
@@ -292,7 +389,7 @@ function buildTemplate({ name, slug, localeCodes }) {
292
389
 
293
390
  const caseConfig = {
294
391
  id: cleanSlug,
295
- caseOrder: DEFAULT_CASE_TEMPLATE.caseOrder,
392
+ caseOrder,
296
393
  slugByLocale: localizedValue({ default: cleanSlug }),
297
394
  socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
298
395
  title: {
@@ -421,11 +518,24 @@ function buildTemplate({ name, slug, localeCodes }) {
421
518
  export function runCaseScaffold(options = {}) {
422
519
  // Resolves working directory used for output file path calculation.
423
520
  const cwd = options.cwd || process.cwd();
424
- // Resolves output filename using option or context-aware default fallback.
425
- const outFile = options.outFile || resolveDefaultOutFile(cwd, options.name || DEFAULT_NAME);
426
521
  // Resolves case display name using option or default fallback.
427
522
  const name = options.name || DEFAULT_NAME;
428
- const caseSlug = resolveCaseSlugFromOutput(outFile, name);
523
+ const hasExplicitOutFile = typeof options.outFile === 'string' && options.outFile.trim().length > 0;
524
+
525
+ const defaultScaffold = hasExplicitOutFile
526
+ ? null
527
+ : resolveDefaultScaffoldValues(cwd, name);
528
+
529
+ // Resolves output filename using option or context-aware default fallback.
530
+ const outFile = hasExplicitOutFile
531
+ ? options.outFile
532
+ : (defaultScaffold?.outFile || DEFAULT_OUTPUT);
533
+
534
+ const caseSlug = hasExplicitOutFile
535
+ ? resolveCaseSlugFromOutput(outFile, name)
536
+ : defaultScaffold.slug;
537
+
538
+ const caseOrder = defaultScaffold?.caseOrder ?? DEFAULT_CASE_TEMPLATE.caseOrder;
429
539
  // Coerces force flag to explicit boolean.
430
540
  const force = Boolean(options.force);
431
541
  // Resolves absolute output path for scaffold file.
@@ -444,7 +554,7 @@ export function runCaseScaffold(options = {}) {
444
554
  }
445
555
 
446
556
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
447
- fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, localeCodes }), 'utf8');
557
+ fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, caseOrder, localeCodes }), 'utf8');
448
558
  console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
449
559
  return 0;
450
560
  }
package/src/App.js CHANGED
@@ -4662,7 +4662,7 @@ class AppShell extends HTMLElement {
4662
4662
  ].join('::');
4663
4663
 
4664
4664
  if (this._caseViewRenderSignature !== caseRenderSignature) {
4665
- caseView.innerHTML = caseMarkup;
4665
+ caseView.innerHTML = this._normalizeArticleMediaUrls(caseMarkup);
4666
4666
  this._caseViewRenderSignature = caseRenderSignature;
4667
4667
  }
4668
4668
 
@@ -5365,7 +5365,9 @@ class AppShell extends HTMLElement {
5365
5365
  const thumbBrand = activeCase.thumbBrand || 'apple';
5366
5366
  const thumbModel = activeCase.thumbModel || 'Apple iPhone 12';
5367
5367
  const thumbColor = activeCase.thumbColor || 'Black';
5368
- const subtitleSrc = String(this.getLangCurrentLocaleOnly(this._resolveCasePrimaryVttProp(activeCase)) || '').trim();
5368
+ const subtitleSrc = this._resolveRuntimeMediaUrl(
5369
+ this.getLangCurrentLocaleOnly(this._resolveCasePrimaryVttProp(activeCase))
5370
+ );
5369
5371
  const rawSubtitleConfig = this._resolveCasePrimaryVttProp(activeCase);
5370
5372
  const {
5371
5373
  breadcrumbMenuLabels,
@@ -5547,7 +5549,7 @@ class AppShell extends HTMLElement {
5547
5549
  const composedHtml = this.getLang(aboutPayload.bodyHtml) || '';
5548
5550
 
5549
5551
  if (aboutArticle.dataset.aboutHtml !== composedHtml) {
5550
- aboutArticle.innerHTML = composedHtml;
5552
+ aboutArticle.innerHTML = this._normalizeArticleMediaUrls(composedHtml);
5551
5553
  aboutArticle.dataset.aboutHtml = composedHtml;
5552
5554
  }
5553
5555
 
@@ -5648,6 +5650,34 @@ class AppShell extends HTMLElement {
5648
5650
  });
5649
5651
  }
5650
5652
 
5653
+ _normalizeArticleMediaUrls(html) {
5654
+ if (typeof document === 'undefined') return String(html || '');
5655
+
5656
+ const template = document.createElement('template');
5657
+ template.innerHTML = String(html || '');
5658
+ template.content.querySelectorAll('[src]').forEach((element) => {
5659
+ const source = element.getAttribute('src');
5660
+ if (source) element.setAttribute('src', this._resolveRuntimeMediaUrl(source));
5661
+ });
5662
+ template.content.querySelectorAll('[poster]').forEach((element) => {
5663
+ const poster = element.getAttribute('poster');
5664
+ if (poster) element.setAttribute('poster', this._resolveRuntimeMediaUrl(poster));
5665
+ });
5666
+ template.content.querySelectorAll('[srcset]').forEach((element) => {
5667
+ const srcset = element.getAttribute('srcset');
5668
+ if (!srcset) return;
5669
+ const normalizedSrcset = srcset.split(',').map((candidate) => {
5670
+ const parts = candidate.trim().split(/\s+/);
5671
+ if (!parts[0]) return candidate;
5672
+ parts[0] = this._resolveRuntimeMediaUrl(parts[0]);
5673
+ return parts.join(' ');
5674
+ }).join(', ');
5675
+ element.setAttribute('srcset', normalizedSrcset);
5676
+ });
5677
+
5678
+ return template.innerHTML;
5679
+ }
5680
+
5651
5681
  _resolveLocalizedConfigText(value) {
5652
5682
  if (typeof value === 'string') return value;
5653
5683
  if (!value || typeof value !== 'object') return '';
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { renderLocalizedMarkdownHtml } from './markdown.js';
4
+
5
+ describe('case markdown rendering', () => {
6
+ it('preserves authored HTML image sizing attributes', () => {
7
+ const html = renderLocalizedMarkdownHtml(
8
+ '<img src="src/content/cases/example/image.avif" alt="Example" width="300">',
9
+ ['en']
10
+ ).htmlByLocale.en;
11
+
12
+ expect(html).toContain('<img src="src/content/cases/example/image.avif" alt="Example" width="300">');
13
+ });
14
+
15
+ it('renders Mermaid fences as diagram elements', () => {
16
+ const html = renderLocalizedMarkdownHtml('```mermaid\ngraph TD\n A --> B\n```', ['en'])
17
+ .htmlByLocale.en;
18
+
19
+ expect(html).toContain('<mermaid-diagram>');
20
+ expect(html).toContain('graph TD');
21
+ expect(html).not.toContain('<pre><code>');
22
+ });
23
+
24
+ it('keeps ordinary fenced code blocks as code', () => {
25
+ const html = renderLocalizedMarkdownHtml('```javascript\nconst answer = 42;\n```', ['en'])
26
+ .htmlByLocale.en;
27
+
28
+ expect(html).toContain('<pre><code>');
29
+ expect(html).toContain('const answer = 42;');
30
+ });
31
+ });
@@ -10,7 +10,7 @@ import markdownItTaskLists from 'markdown-it-task-lists';
10
10
  // MARK: MARKDOWN RENDERER
11
11
  // Uses markdown-it + plugins to support broad markdown syntax for case content.
12
12
  const markdownRenderer = new MarkdownIt({
13
- html: false,
13
+ html: true,
14
14
  linkify: true,
15
15
  typographer: true,
16
16
  breaks: false
@@ -56,6 +56,16 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
56
56
  return self.renderToken(tokens, idx, options);
57
57
  };
58
58
 
59
+ // Emits Mermaid fences as the Valence custom element instead of a code block.
60
+ markdownRenderer.renderer.rules.fence = (tokens, idx) => {
61
+ const token = tokens[idx];
62
+ if (String(token.info || '').trim().split(/\s+/)[0].toLowerCase() !== 'mermaid') {
63
+ return `<pre><code>${markdownRenderer.utils.escapeHtml(token.content)}</code></pre>\n`;
64
+ }
65
+
66
+ return `<mermaid-diagram>${markdownRenderer.utils.escapeHtml(token.content)}</mermaid-diagram>\n`;
67
+ };
68
+
59
69
  // MARK: VALIDATION CONTRACTS
60
70
  // Lists required scalar config fields that every case must define.
61
71
  const REQUIRED_SCALAR_FIELDS = ['id', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor'];
@@ -127,10 +127,14 @@ function readSupportedLocales(cwd = process.cwd()) {
127
127
  }
128
128
 
129
129
  // === DEFAULTS ===
130
- // Defines the default output path used when callers do not provide --out.
131
- const DEFAULT_OUTPUT = 'src/content/cases/my-case/case.md';
130
+ // Defines the default output path fallback used when no cases root is available.
131
+ const DEFAULT_OUTPUT = 'src/content/cases/my-case/my-case.md';
132
132
  // Defines fallback title text used when callers do not provide --name.
133
133
  const DEFAULT_NAME = 'My Case';
134
+ // Defines candidate case roots in priority order for local repo and consumer apps.
135
+ const DEFAULT_CASES_ROOTS = ['src/content/cases', 'templates/src/content/cases'];
136
+ // Defines order increment applied when choosing the next generated case order value.
137
+ const CASE_ORDER_STEP = 10;
134
138
 
135
139
  // === STRING NORMALIZATION HELPERS ===
136
140
  // Converts arbitrary text into a markdown-safe slug used for id and localized slug fields.
@@ -165,7 +169,7 @@ function parseArgs(argv) {
165
169
  const args = argv.slice(2);
166
170
  // Initializes parser output with defaults so missing flags still produce valid output.
167
171
  const options = {
168
- outFile: DEFAULT_OUTPUT,
172
+ outFile: '',
169
173
  name: DEFAULT_NAME,
170
174
  force: false
171
175
  };
@@ -194,9 +198,130 @@ function parseArgs(argv) {
194
198
  return options;
195
199
  }
196
200
 
201
+ function resolveCasesRoots(cwd) {
202
+ return DEFAULT_CASES_ROOTS
203
+ .map((relativeRoot) => path.resolve(cwd, relativeRoot))
204
+ .filter((absoluteRoot) => fs.existsSync(absoluteRoot) && fs.statSync(absoluteRoot).isDirectory());
205
+ }
206
+
207
+ function resolvePrimaryCasesRoot(cwd) {
208
+ const discoveredRoots = resolveCasesRoots(cwd);
209
+ if (discoveredRoots.length > 0) {
210
+ return discoveredRoots[0];
211
+ }
212
+
213
+ return path.resolve(cwd, DEFAULT_CASES_ROOTS[0]);
214
+ }
215
+
216
+ function walkMarkdownFiles(rootDir, files = []) {
217
+ if (!fs.existsSync(rootDir)) {
218
+ return files;
219
+ }
220
+
221
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
222
+ entries.forEach((entry) => {
223
+ const entryPath = path.join(rootDir, entry.name);
224
+ if (entry.isDirectory()) {
225
+ walkMarkdownFiles(entryPath, files);
226
+ return;
227
+ }
228
+
229
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
230
+ files.push(entryPath);
231
+ }
232
+ });
233
+
234
+ return files;
235
+ }
236
+
237
+ function collectExistingCaseMetadata(caseRoot) {
238
+ const usedSlugs = new Set();
239
+ const caseOrders = [];
240
+ const markdownFiles = walkMarkdownFiles(caseRoot, []);
241
+
242
+ markdownFiles.forEach((filePath) => {
243
+ const relativePath = path.relative(caseRoot, filePath);
244
+ const pathSegments = relativePath.split(path.sep).filter(Boolean);
245
+ const folderSlug = pathSegments.length > 1 ? pathSegments[0] : '';
246
+ if (folderSlug) {
247
+ usedSlugs.add(toSlug(folderSlug));
248
+ }
249
+
250
+ const fileSlug = toSlug(path.basename(filePath, '.md'));
251
+ if (fileSlug) {
252
+ usedSlugs.add(fileSlug);
253
+ }
254
+
255
+ let source = '';
256
+ try {
257
+ source = fs.readFileSync(filePath, 'utf8');
258
+ } catch {
259
+ return;
260
+ }
261
+
262
+ const idMatch = source.match(/"id"\s*:\s*"([^"]+)"/);
263
+ if (idMatch?.[1]) {
264
+ usedSlugs.add(toSlug(idMatch[1]));
265
+ }
266
+
267
+ const slugBlockMatch = source.match(/"slugByLocale"\s*:\s*\{([\s\S]*?)\}/);
268
+ if (slugBlockMatch?.[1]) {
269
+ const slugValues = slugBlockMatch[1].matchAll(/:\s*"([^"]+)"/g);
270
+ for (const slugMatch of slugValues) {
271
+ if (slugMatch?.[1]) {
272
+ usedSlugs.add(toSlug(slugMatch[1]));
273
+ }
274
+ }
275
+ }
276
+
277
+ const orderMatch = source.match(/"caseOrder"\s*:\s*(\d+)/);
278
+ if (orderMatch?.[1]) {
279
+ const parsedOrder = Number.parseInt(orderMatch[1], 10);
280
+ if (Number.isFinite(parsedOrder)) {
281
+ caseOrders.push(parsedOrder);
282
+ }
283
+ }
284
+ });
285
+
286
+ return { usedSlugs, caseOrders };
287
+ }
288
+
289
+ function resolveUniqueSlug(baseSlug, usedSlugs) {
290
+ if (!usedSlugs.has(baseSlug)) {
291
+ return baseSlug;
292
+ }
293
+
294
+ let index = 2;
295
+ while (usedSlugs.has(`${baseSlug}-${index}`)) {
296
+ index += 1;
297
+ }
298
+
299
+ return `${baseSlug}-${index}`;
300
+ }
301
+
302
+ function resolveCaseSlugFromOutput(outFile, name) {
303
+ const basename = path.basename(outFile || '');
304
+ if (basename.toLowerCase() === 'case.md') {
305
+ return toSlug(path.basename(path.dirname(outFile || '')) || name);
306
+ }
307
+
308
+ return toSlug(path.basename(outFile || '', '.md') || name);
309
+ }
310
+
311
+ function resolveDefaultScaffoldValues(cwd, name) {
312
+ const caseRoot = resolvePrimaryCasesRoot(cwd);
313
+ const { usedSlugs, caseOrders } = collectExistingCaseMetadata(caseRoot);
314
+ const baseSlug = toSlug(name);
315
+ const slug = resolveUniqueSlug(baseSlug, usedSlugs);
316
+ const maxOrder = caseOrders.length > 0 ? Math.max(...caseOrders) : DEFAULT_CASE_TEMPLATE.caseOrder - CASE_ORDER_STEP;
317
+ const caseOrder = maxOrder + CASE_ORDER_STEP;
318
+ const outFile = path.relative(cwd, path.join(caseRoot, slug, `${slug}.md`));
319
+ return { outFile, slug, caseOrder };
320
+ }
321
+
197
322
  // === TEMPLATE CONSTRUCTION ===
198
323
  // Produces localized starter markdown content for a new case study.
199
- function buildTemplate({ name, slug }) {
324
+ function buildTemplate({ name, slug, caseOrder }) {
200
325
  // Derives a normalized visible title from provided input.
201
326
  const title = toTitle(name);
202
327
  // Derives a safe slug fallback from either explicit slug or case name.
@@ -212,7 +337,7 @@ function buildTemplate({ name, slug }) {
212
337
 
213
338
  const caseConfig = {
214
339
  id: cleanSlug,
215
- caseOrder: DEFAULT_CASE_TEMPLATE.caseOrder,
340
+ caseOrder,
216
341
  slugByLocale: localizedValue({ default: cleanSlug }),
217
342
  socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
218
343
  title: {
@@ -397,25 +522,29 @@ function formatCaseConfigBlock(caseConfig) {
397
522
  return lines.join('\n');
398
523
  }
399
524
 
400
- function resolveCaseSlugFromOutput(outFile, name) {
401
- const basename = path.basename(outFile || '');
402
- if (basename.toLowerCase() === 'case.md') {
403
- return toSlug(path.basename(path.dirname(outFile || '')) || name);
404
- }
405
-
406
- return toSlug(path.basename(outFile || '', '.md') || name);
407
- }
408
-
409
525
  // === SCAFFOLD EXECUTION ===
410
526
  // Creates a new case markdown file, respecting overwrite safety rules.
411
527
  export function runCaseScaffold(options = {}) {
412
528
  // Resolves working directory so script can be invoked from any location.
413
529
  const cwd = options.cwd || process.cwd();
414
- // Reads desired output file path from options or uses default path.
415
- const outFile = options.outFile || DEFAULT_OUTPUT;
416
530
  // Reads desired case display name from options or uses default title.
417
531
  const name = options.name || DEFAULT_NAME;
418
- const caseSlug = resolveCaseSlugFromOutput(outFile, name);
532
+ const hasExplicitOutFile = typeof options.outFile === 'string' && options.outFile.trim().length > 0;
533
+
534
+ const defaultScaffold = hasExplicitOutFile
535
+ ? null
536
+ : resolveDefaultScaffoldValues(cwd, name);
537
+
538
+ // Reads desired output file path from options or uses resolved default path.
539
+ const outFile = hasExplicitOutFile
540
+ ? options.outFile
541
+ : (defaultScaffold?.outFile || DEFAULT_OUTPUT);
542
+
543
+ const caseSlug = hasExplicitOutFile
544
+ ? resolveCaseSlugFromOutput(outFile, name)
545
+ : defaultScaffold.slug;
546
+
547
+ const caseOrder = defaultScaffold?.caseOrder ?? DEFAULT_CASE_TEMPLATE.caseOrder;
419
548
  // Converts force option to explicit boolean for overwrite checks.
420
549
  const force = Boolean(options.force);
421
550
  // Resolves absolute output path to avoid relative path ambiguity.
@@ -428,7 +557,7 @@ export function runCaseScaffold(options = {}) {
428
557
  }
429
558
 
430
559
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
431
- fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug }), 'utf8');
560
+ fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, caseOrder }), 'utf8');
432
561
  console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
433
562
  return 0;
434
563
  }
@@ -10,7 +10,7 @@ import markdownItTaskLists from 'markdown-it-task-lists';
10
10
  // MARK: MARKDOWN RENDERER
11
11
  // Uses markdown-it + plugins to support broad markdown syntax for case content.
12
12
  const markdownRenderer = new MarkdownIt({
13
- html: false,
13
+ html: true,
14
14
  linkify: true,
15
15
  typographer: true,
16
16
  breaks: false
@@ -56,6 +56,16 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
56
56
  return self.renderToken(tokens, idx, options);
57
57
  };
58
58
 
59
+ // Emits Mermaid fences as the Valence custom element instead of a code block.
60
+ markdownRenderer.renderer.rules.fence = (tokens, idx) => {
61
+ const token = tokens[idx];
62
+ if (String(token.info || '').trim().split(/\s+/)[0].toLowerCase() !== 'mermaid') {
63
+ return `<pre><code>${markdownRenderer.utils.escapeHtml(token.content)}</code></pre>\n`;
64
+ }
65
+
66
+ return `<mermaid-diagram>${markdownRenderer.utils.escapeHtml(token.content)}</mermaid-diagram>\n`;
67
+ };
68
+
59
69
  // MARK: VALIDATION CONTRACTS
60
70
  // Lists required scalar config fields that every case must define.
61
71
  const REQUIRED_SCALAR_FIELDS = ['id', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor'];
@@ -107,6 +117,56 @@ function markdownToHtml(markdown) {
107
117
  return markdownRenderer.render(source);
108
118
  }
109
119
 
120
+ // Renders localized markdown sections into HTML using the shared markdown-it pipeline.
121
+ export function renderLocalizedMarkdownHtml(bodyText, localeCodes = DEFAULT_LOCALE_CODES) {
122
+ const normalizedBodyText = String(bodyText || '').replace(/<!--\s*config\s*[\s\S]*?-->/gi, '').trim();
123
+ const targetLocales = normalizeLocaleCodes(localeCodes);
124
+ const sections = Object.fromEntries(targetLocales.map((localeCode) => [localeCode, '']));
125
+ const markerLocales = new Set();
126
+ const langRegex = /<!--\s*lang:([a-z0-9-]+)\s*-->/gi;
127
+ let activeLang = null;
128
+ let lastIndex = 0;
129
+ let match;
130
+
131
+ while ((match = langRegex.exec(normalizedBodyText)) !== null) {
132
+ const markerLocale = String(match[1] || '').trim().toLowerCase();
133
+ if (!markerLocale) continue;
134
+ markerLocales.add(markerLocale);
135
+ if (!(markerLocale in sections)) {
136
+ sections[markerLocale] = '';
137
+ }
138
+
139
+ if (activeLang) {
140
+ sections[activeLang] += normalizedBodyText.slice(lastIndex, match.index);
141
+ }
142
+ activeLang = markerLocale;
143
+ lastIndex = langRegex.lastIndex;
144
+ }
145
+
146
+ if (activeLang) {
147
+ sections[activeLang] += normalizedBodyText.slice(lastIndex);
148
+ } else {
149
+ Object.keys(sections).forEach((localeCode) => {
150
+ sections[localeCode] = normalizedBodyText;
151
+ });
152
+ }
153
+
154
+ const resolvedLocales = normalizeLocaleCodes([...targetLocales, ...Object.keys(sections)]);
155
+ const htmlByLocale = Object.fromEntries(resolvedLocales.map((localeCode) => {
156
+ const sectionRaw = String(sections[localeCode] || '').trim();
157
+ return [localeCode, markdownToHtml(sectionRaw)];
158
+ }));
159
+
160
+ return {
161
+ htmlByLocale,
162
+ meta: {
163
+ localeCodes: resolvedLocales,
164
+ hasAnyLangMarker: markerLocales.size > 0,
165
+ hasLangMarkerByLocale: Object.fromEntries(resolvedLocales.map((localeCode) => [localeCode, markerLocales.has(localeCode)]))
166
+ }
167
+ };
168
+ }
169
+
110
170
  // MARK: CONFIG BLOCK PARSING
111
171
  // Parses a top-level config JSON comment block and returns metadata + remaining body text.
112
172
  function parseConfigMetadata(rawText, contextLabel) {