create-portfoliable 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/create-portfoliable.mjs +425 -0
  2. package/cli/portfoliable.mjs +836 -0
  3. package/index.html +18 -0
  4. package/package.json +102 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/favicon.png +0 -0
  7. package/scripts/check-updates.mjs +31 -0
  8. package/scripts/ensure-valence-index-css.mjs +245 -0
  9. package/scripts/generate-password-hash.mjs +25 -0
  10. package/scripts/release-orchestrator.mjs +363 -0
  11. package/scripts/scaffold-case.mjs +460 -0
  12. package/scripts/scaffold-consumer.mjs +87 -0
  13. package/scripts/smoke-homeview.mjs +133 -0
  14. package/scripts/smoke-initializer.mjs +129 -0
  15. package/scripts/smoke-packed.mjs +184 -0
  16. package/scripts/switch-valence-source.mjs +162 -0
  17. package/scripts/sync-template-shared.mjs +136 -0
  18. package/scripts/terminal-ui.mjs +274 -0
  19. package/scripts/verify-integration.mjs +72 -0
  20. package/src/App.js +10015 -0
  21. package/src/header-visibility.contract.js +31 -0
  22. package/src/header-visibility.contract.test.js +41 -0
  23. package/src/main.js +12 -0
  24. package/src/parser/markdown.js +855 -0
  25. package/src/style.css +88 -0
  26. package/templates/README.md +242 -0
  27. package/templates/configs/i18n/i18n.config.js +230 -0
  28. package/templates/configs/i18n/i18n.labels.js +188 -0
  29. package/templates/configs/portfoliable.a11y.config.js +262 -0
  30. package/templates/configs/portfoliable.design.config.js +651 -0
  31. package/templates/gitignore +9 -0
  32. package/templates/index.html +20 -0
  33. package/templates/public/api/.htaccess +4 -0
  34. package/templates/public/api/README.md +27 -0
  35. package/templates/public/api/password.config.example.json +16 -0
  36. package/templates/public/api/password.config.json +16 -0
  37. package/templates/public/api/unlock-case.php +56 -0
  38. package/templates/public/favicon.ico +0 -0
  39. package/templates/public/favicon.png +0 -0
  40. package/templates/public/portfoliable-logo.svg +3 -0
  41. package/templates/scripts/add-language.mjs +253 -0
  42. package/templates/scripts/convert-video-to-mp4.mjs +207 -0
  43. package/templates/scripts/convert-wav-to-mp3.mjs +184 -0
  44. package/templates/scripts/delete-case.mjs +320 -0
  45. package/templates/scripts/delete-language.mjs +195 -0
  46. package/templates/scripts/generate-password-hash.mjs +98 -0
  47. package/templates/scripts/i18n-config-utils.mjs +123 -0
  48. package/templates/scripts/scaffold-case.mjs +444 -0
  49. package/templates/scripts/sync-locales.mjs +1048 -0
  50. package/templates/scripts/validate-content.mjs +257 -0
  51. package/templates/scripts/validate-protection.mjs +187 -0
  52. package/templates/src/cases/index.js +188 -0
  53. package/templates/src/content/about/ABOUTME.md +202 -0
  54. package/templates/src/content/cases/template-case/case.md +479 -0
  55. package/templates/src/content/cases/template-case/cover.png +0 -0
  56. package/templates/src/content/cases/template-case/he-cover.png +0 -0
  57. package/templates/src/content/cases/template-case/template-audio.mp3 +0 -0
  58. package/templates/src/content/cases/template-case/template-video.mp4 +0 -0
  59. package/templates/src/content/thumbnail-options.generated.json +1044 -0
  60. package/templates/src/main.js +23 -0
  61. package/templates/src/parser/markdown.js +805 -0
  62. package/vite.config.js +111 -0
@@ -0,0 +1,805 @@
1
+ // File: create-portfoliable/templates/src/parser/markdown.js
2
+ // Purpose: Parse template markdown cases into normalized case data.
3
+ // Author: Lio Schimanko
4
+ // Note: Auto-synced from src/parser/markdown.js. Edit the canonical source only.
5
+ import MarkdownIt from 'markdown-it';
6
+ import markdownItDeflist from 'markdown-it-deflist';
7
+ import markdownItFootnote from 'markdown-it-footnote';
8
+ import markdownItTaskLists from 'markdown-it-task-lists';
9
+
10
+ // MARK: MARKDOWN RENDERER
11
+ // Uses markdown-it + plugins to support broad markdown syntax for case content.
12
+ const markdownRenderer = new MarkdownIt({
13
+ html: false,
14
+ linkify: true,
15
+ typographer: true,
16
+ breaks: false
17
+ })
18
+ .use(markdownItFootnote)
19
+ .use(markdownItDeflist)
20
+ .use(markdownItTaskLists, {
21
+ enabled: true,
22
+ label: true,
23
+ labelAfter: true
24
+ });
25
+
26
+ // Keeps legacy paragraph styling hooks while using full markdown rendering.
27
+ const paragraphOpenDefault = markdownRenderer.renderer.rules.paragraph_open;
28
+ markdownRenderer.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => {
29
+ const token = tokens[idx];
30
+ token.attrJoin('class', 'p1');
31
+ if (paragraphOpenDefault) {
32
+ return paragraphOpenDefault(tokens, idx, options, env, self);
33
+ }
34
+ return self.renderToken(tokens, idx, options);
35
+ };
36
+
37
+ // Adds external-link safety attributes expected by the app runtime.
38
+ const linkOpenDefault = markdownRenderer.renderer.rules.link_open;
39
+ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) => {
40
+ const token = tokens[idx];
41
+ const href = String(token.attrGet('href') || '').trim();
42
+ const isExternal = /^(https?:)?\/\//i.test(href) || /^(mailto|tel):/i.test(href);
43
+
44
+ if (isExternal) {
45
+ token.attrSet('target', '_blank');
46
+ token.attrSet('rel', 'noopener noreferrer');
47
+ } else {
48
+ token.attrSet('target', '_self');
49
+ token.attrSet('rel', 'noopener');
50
+ }
51
+
52
+ if (linkOpenDefault) {
53
+ return linkOpenDefault(tokens, idx, options, env, self);
54
+ }
55
+
56
+ return self.renderToken(tokens, idx, options);
57
+ };
58
+
59
+ // MARK: VALIDATION CONTRACTS
60
+ // Lists required scalar config fields that every case must define.
61
+ const REQUIRED_SCALAR_FIELDS = ['id', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor'];
62
+ // Lists required localized fields that must include values for all active locales.
63
+ const REQUIRED_LOCALIZED_FIELDS = ['title', 'shortDesc', 'readTime', 'kicker', 'thumbSrc'];
64
+ // Lists optional localized metadata fields supported by the modern case schema.
65
+ const OPTIONAL_LOCALIZED_FIELDS = ['summary', 'audioLabel', 'audioSrc', 'vttSrc', 'socialImage', 'slugByLocale'];
66
+ // Defines parser fallback locales when callers do not provide locale codes.
67
+ const DEFAULT_LOCALE_CODES = [];
68
+
69
+ // Normalizes locale lists by trimming, lowercasing, deduplicating, and applying fallback values.
70
+ function normalizeLocaleCodes(values, fallback = DEFAULT_LOCALE_CODES) {
71
+ const normalizeList = (list) => {
72
+ const output = [];
73
+ const seen = new Set();
74
+ const valuesToNormalize = Array.isArray(list) ? list : [];
75
+
76
+ valuesToNormalize.forEach((value) => {
77
+ const normalized = String(value || '').trim().toLowerCase();
78
+ if (!normalized || seen.has(normalized)) return;
79
+ seen.add(normalized);
80
+ output.push(normalized);
81
+ });
82
+
83
+ return output;
84
+ };
85
+
86
+ const primary = normalizeList(values);
87
+ if (primary.length > 0) {
88
+ return primary;
89
+ }
90
+
91
+ return normalizeList(fallback);
92
+ }
93
+
94
+ // Resolves the primary locale from explicit default and available locale candidates.
95
+ function resolvePrimaryLocale(localeCodes, defaultLocale = '') {
96
+ const candidates = normalizeLocaleCodes([defaultLocale, ...(Array.isArray(localeCodes) ? localeCodes : [])]);
97
+ return candidates[0] || '';
98
+ }
99
+
100
+ // Converts markdown into HTML for case body rendering using the shared renderer.
101
+ function markdownToHtml(markdown) {
102
+ const source = String(markdown || '').trim();
103
+ if (!source) {
104
+ return '';
105
+ }
106
+
107
+ return markdownRenderer.render(source);
108
+ }
109
+
110
+ // MARK: CONFIG BLOCK PARSING
111
+ // Parses a top-level config JSON comment block and returns metadata + remaining body text.
112
+ function parseConfigMetadata(rawText, contextLabel) {
113
+ const configRegex = /<!--\s*config\s*([\s\S]*?)-->/i;
114
+ const match = String(rawText || '').match(configRegex);
115
+ if (!match) {
116
+ return {
117
+ metadata: null,
118
+ bodyText: String(rawText || '').trim(),
119
+ errors: []
120
+ };
121
+ }
122
+
123
+ const objectSegment = String(match[1] || '').trim();
124
+ let parsedConfig = null;
125
+
126
+ try {
127
+ const evaluateObject = new Function(`return (${objectSegment});`);
128
+ parsedConfig = evaluateObject();
129
+ } catch {
130
+ return {
131
+ metadata: null,
132
+ bodyText: String(rawText || '').replace(configRegex, '').trim(),
133
+ errors: [`${contextLabel}: invalid config object in config block.`]
134
+ };
135
+ }
136
+
137
+ const withoutConfig = String(rawText || '').replace(configRegex, '').trim();
138
+ return {
139
+ metadata: parsedConfig && typeof parsedConfig === 'object' ? parsedConfig : {},
140
+ bodyText: withoutConfig,
141
+ errors: []
142
+ };
143
+ }
144
+
145
+ // MARK: LOCALE METADATA HELPERS
146
+ // Converts a suffix such as "en" or "pt-br" into a readable locale name.
147
+ function resolveLocaleDisplayName(localeCode) {
148
+ const normalized = String(localeCode || '').trim().toLowerCase();
149
+ if (!normalized) return '';
150
+
151
+ const nativeNames = {
152
+ en: 'English',
153
+ pt: 'Português'
154
+ };
155
+
156
+ if (nativeNames[normalized]) {
157
+ return nativeNames[normalized];
158
+ }
159
+
160
+ try {
161
+ if (typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') {
162
+ const displayNames = new Intl.DisplayNames([normalized], { type: 'language' });
163
+ const resolved = displayNames.of(normalized);
164
+ if (resolved && resolved !== normalized) {
165
+ return resolved;
166
+ }
167
+ }
168
+ } catch {
169
+ // Falls through to a capitalization-based fallback.
170
+ }
171
+
172
+ return normalized
173
+ .split('-')
174
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
175
+ .join('-');
176
+ }
177
+
178
+ // Collects locale codes present in localized config objects.
179
+ function collectLocalizedLocales(metadata, fallbackLocales = DEFAULT_LOCALE_CODES) {
180
+ const locales = new Set();
181
+
182
+ [...REQUIRED_LOCALIZED_FIELDS, ...OPTIONAL_LOCALIZED_FIELDS].forEach((field) => {
183
+ const localized = metadata?.[field];
184
+ if (!localized || typeof localized !== 'object') return;
185
+
186
+ Object.keys(localized).forEach((key) => {
187
+ if (key === 'show') return;
188
+ if (isNonEmptyString(localized[key])) {
189
+ locales.add(key.toLowerCase());
190
+ }
191
+ });
192
+ });
193
+
194
+ const actions = metadata?.actions && typeof metadata.actions === 'object' ? metadata.actions : {};
195
+ ['primary', 'secondary', 'tertiary'].forEach((actionKey) => {
196
+ const action = actions?.[actionKey];
197
+ if (!action || typeof action !== 'object') return;
198
+
199
+ ['label', 'tooltip', 'imageAlt', 'ariaLabel', 'url', 'videoSrc', 'vttSrc'].forEach((fieldKey) => {
200
+ const localized = action?.[fieldKey];
201
+ if (!localized || typeof localized !== 'object' || Array.isArray(localized)) return;
202
+
203
+ Object.keys(localized).forEach((localeCode) => {
204
+ const normalizedLocale = String(localeCode || '').trim().toLowerCase();
205
+ if (!normalizedLocale || normalizedLocale === 'show') return;
206
+ if (isNonEmptyString(localized[localeCode])) {
207
+ locales.add(normalizedLocale);
208
+ }
209
+ });
210
+ });
211
+ });
212
+
213
+ if (Array.isArray(metadata?.customButtons)) {
214
+ metadata.customButtons.forEach((button) => {
215
+ if (!button || typeof button !== 'object') return;
216
+ ['label', 'tooltip', 'imageAlt', 'ariaLabel', 'url', 'imageSrc'].forEach((fieldKey) => {
217
+ const localized = button?.[fieldKey] ?? button?.[fieldKey.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)];
218
+ if (!localized || typeof localized !== 'object' || Array.isArray(localized)) return;
219
+
220
+ Object.keys(localized).forEach((localeCode) => {
221
+ const normalizedLocale = String(localeCode || '').trim().toLowerCase();
222
+ if (!normalizedLocale || normalizedLocale === 'show') return;
223
+ if (isNonEmptyString(localized[localeCode])) {
224
+ locales.add(normalizedLocale);
225
+ }
226
+ });
227
+ });
228
+ });
229
+ }
230
+
231
+ const fallback = normalizeLocaleCodes(fallbackLocales);
232
+ fallback.forEach((localeCode) => locales.add(localeCode));
233
+
234
+ return [...locales].sort((a, b) => a.localeCompare(b));
235
+ }
236
+
237
+ // MARK: LOCALIZED BODY PARSING
238
+ // Splits article body into localized language sections and renders each to HTML.
239
+ function parseLocalizedBody(bodyText, localeCodes = DEFAULT_LOCALE_CODES) {
240
+ // Removes optional case config block comments from rendered body content.
241
+ const normalizedBodyText = String(bodyText || '').replace(/<!--\s*config\s*[\s\S]*?-->/gi, '').trim();
242
+ const targetLocales = normalizeLocaleCodes(localeCodes);
243
+
244
+ // Stores raw localized markdown blocks before HTML conversion.
245
+ const sections = Object.fromEntries(targetLocales.map((localeCode) => [localeCode, '']));
246
+ const markerLocales = new Set();
247
+
248
+ // Matches language markers used in case markdown files.
249
+ const langRegex = /<!--\s*lang:([a-z0-9-]+)\s*-->/gi;
250
+ // Tracks active language section while iterating markers.
251
+ let activeLang = null;
252
+ // Tracks previous marker boundary index.
253
+ let lastIndex = 0;
254
+ // Holds current regex match object during loop.
255
+ let match;
256
+
257
+ while ((match = langRegex.exec(normalizedBodyText)) !== null) {
258
+ const markerLocale = String(match[1] || '').trim().toLowerCase();
259
+ if (!markerLocale) continue;
260
+ markerLocales.add(markerLocale);
261
+ if (!(markerLocale in sections)) {
262
+ sections[markerLocale] = '';
263
+ }
264
+
265
+ if (activeLang) {
266
+ sections[activeLang] += normalizedBodyText.slice(lastIndex, match.index);
267
+ }
268
+ activeLang = markerLocale;
269
+ lastIndex = langRegex.lastIndex;
270
+ }
271
+
272
+ // Falls back to mirrored content across configured locales when no explicit markers exist.
273
+ if (activeLang) {
274
+ sections[activeLang] += normalizedBodyText.slice(lastIndex);
275
+ } else {
276
+ Object.keys(sections).forEach((localeCode) => {
277
+ sections[localeCode] = normalizedBodyText;
278
+ });
279
+ }
280
+
281
+ const resolvedLocales = normalizeLocaleCodes([...targetLocales, ...Object.keys(sections)]);
282
+
283
+ const hasHeadings = (markdown) => /(^|\n)#{2,3}\s+\S+/m.test(markdown || '');
284
+
285
+ const descByLocale = {};
286
+ const summaryByLocale = {};
287
+ const readerRawByLocale = {};
288
+ const hasReaderHeadingsByLocale = {};
289
+ const hasLangMarkerByLocale = {};
290
+
291
+ resolvedLocales.forEach((localeCode) => {
292
+ const sectionRaw = String(sections[localeCode] || '').trim();
293
+ descByLocale[localeCode] = markdownToHtml(sectionRaw);
294
+ summaryByLocale[localeCode] = '';
295
+ readerRawByLocale[localeCode] = sectionRaw;
296
+ hasReaderHeadingsByLocale[localeCode] = hasHeadings(sectionRaw);
297
+ hasLangMarkerByLocale[localeCode] = markerLocales.has(localeCode);
298
+ });
299
+
300
+ return {
301
+ html: {
302
+ desc: descByLocale,
303
+ summary: summaryByLocale
304
+ },
305
+ meta: {
306
+ localeCodes: resolvedLocales,
307
+ hasAnyLangMarker: markerLocales.size > 0,
308
+ hasLangMarkerByLocale,
309
+ readerRawByLocale,
310
+ hasReaderHeadingsByLocale
311
+ }
312
+ };
313
+ }
314
+
315
+ // Returns true only for non-empty trimmed strings.
316
+ function isNonEmptyString(value) {
317
+ return typeof value === 'string' && value.trim().length > 0;
318
+ }
319
+
320
+ // MARK: METADATA NORMALIZATION
321
+ // Parses a boolean-ish scalar to true/false when possible.
322
+ function parseBooleanFlag(value) {
323
+ if (typeof value === 'boolean') return value;
324
+ if (typeof value !== 'string') return null;
325
+
326
+ const normalized = value.trim().toLowerCase();
327
+ if (normalized === 'true') return true;
328
+ if (normalized === 'false') return false;
329
+ return null;
330
+ }
331
+
332
+ // Normalizes top-level visibility flags and ensures a stable locales map shape.
333
+ function normalizeVisibilityConfig(value) {
334
+ const visibility = value && typeof value === 'object' && !Array.isArray(value)
335
+ ? { ...value }
336
+ : {};
337
+
338
+ const parseOrDefault = (candidate, fallbackValue) => {
339
+ const parsed = parseBooleanFlag(candidate);
340
+ return parsed === null ? fallbackValue : parsed;
341
+ };
342
+
343
+ visibility.web = parseOrDefault(visibility.web, true);
344
+ visibility.crawlers = parseOrDefault(visibility.crawlers, true);
345
+ visibility.ai = parseOrDefault(visibility.ai, true);
346
+
347
+ if (!visibility.locales || typeof visibility.locales !== 'object' || Array.isArray(visibility.locales)) {
348
+ visibility.locales = {};
349
+ }
350
+
351
+ return visibility;
352
+ }
353
+
354
+ // Builds URL-friendly slugs with accent stripping and fallback handling.
355
+ function toSlug(value, fallbackValue = 'item') {
356
+ const normalized = String(value || '')
357
+ .normalize('NFD')
358
+ .replace(/[\u0300-\u036f]/g, '')
359
+ .toLowerCase()
360
+ .replace(/[^a-z0-9]+/g, '-')
361
+ .replace(/^-+|-+$/g, '');
362
+
363
+ if (normalized.length > 0) {
364
+ return normalized;
365
+ }
366
+
367
+ return String(fallbackValue || 'item')
368
+ .toLowerCase()
369
+ .replace(/[^a-z0-9]+/g, '-')
370
+ .replace(/^-+|-+$/g, '')
371
+ || 'item';
372
+ }
373
+
374
+ // Detects password-like keys recursively so markdown configs cannot embed secrets.
375
+ function objectHasPasswordLikeKeys(value, visited = new Set()) {
376
+ if (!value || typeof value !== 'object') return false;
377
+ if (visited.has(value)) return false;
378
+ visited.add(value);
379
+
380
+ return Object.entries(value).some(([key, entryValue]) => {
381
+ if (/(password|passcode|secret|token)/i.test(String(key || ''))) {
382
+ return true;
383
+ }
384
+
385
+ return objectHasPasswordLikeKeys(entryValue, visited);
386
+ });
387
+ }
388
+
389
+ // MARK: DISPLAY DERIVATION
390
+ // Resolves the first valid boolean flag from a list of metadata keys.
391
+ function resolveBooleanFlag(metadata, keys) {
392
+ for (const key of keys) {
393
+ const parsed = parseBooleanFlag(metadata?.[key]);
394
+ if (parsed !== null) return parsed;
395
+ }
396
+ return null;
397
+ }
398
+
399
+ // Normalizes modern case metadata shape and upgrades legacy-compatible fields.
400
+ function normalizeCaseMetadataSchema(metadata, defaultLocale = '') {
401
+ const normalized = metadata && typeof metadata === 'object' ? { ...metadata } : {};
402
+ const normalizedTitle = normalized.title && typeof normalized.title === 'object' ? { ...normalized.title } : normalized.title;
403
+ const normalizedShortDesc = normalized.shortDesc && typeof normalized.shortDesc === 'object' ? { ...normalized.shortDesc } : normalized.shortDesc;
404
+ const normalizedSummaryProps = normalized.summaryProps && typeof normalized.summaryProps === 'object'
405
+ ? { ...normalized.summaryProps }
406
+ : {};
407
+ const actions = normalized.actions && typeof normalized.actions === 'object' ? { ...normalized.actions } : {};
408
+ const primaryAction = actions.primary && typeof actions.primary === 'object' ? { ...actions.primary } : {};
409
+ const secondaryAction = actions.secondary && typeof actions.secondary === 'object'
410
+ ? { ...actions.secondary }
411
+ : {};
412
+ const tertiaryAction = actions.tertiary && typeof actions.tertiary === 'object'
413
+ ? { ...actions.tertiary }
414
+ : {};
415
+
416
+ const isProtected = parseBooleanFlag(normalized?.isProtected);
417
+ const isUnlocked = parseBooleanFlag(normalized?.isUnlocked);
418
+
419
+ if (!primaryAction.vttSrc && normalized.vttSrc) {
420
+ primaryAction.vttSrc = normalized.vttSrc;
421
+ }
422
+
423
+ if (Object.keys(primaryAction).length > 0) {
424
+ actions.primary = primaryAction;
425
+ }
426
+ if (Object.keys(secondaryAction).length > 0) {
427
+ actions.secondary = secondaryAction;
428
+ }
429
+ if (Object.keys(tertiaryAction).length > 0) {
430
+ actions.tertiary = tertiaryAction;
431
+ }
432
+
433
+ normalized.actions = actions;
434
+ normalized.title = normalizedTitle;
435
+ normalized.shortDesc = normalizedShortDesc;
436
+ normalized.summaryProps = normalizedSummaryProps;
437
+ normalized.visibility = normalizeVisibilityConfig(normalized.visibility);
438
+ normalized.isProtected = isProtected ?? false;
439
+ normalized.isUnlocked = isUnlocked ?? false;
440
+
441
+ const localizedTitle = normalized.title && typeof normalized.title === 'object' ? normalized.title : {};
442
+ const existingSlugByLocale = normalized.slugByLocale && typeof normalized.slugByLocale === 'object' && !Array.isArray(normalized.slugByLocale)
443
+ ? normalized.slugByLocale
444
+ : {};
445
+ const primaryLocale = resolvePrimaryLocale(
446
+ [...Object.keys(localizedTitle), ...Object.keys(existingSlugByLocale)],
447
+ defaultLocale
448
+ );
449
+
450
+ if (typeof normalized.slugByLocale === 'string') {
451
+ normalized.slugByLocale = primaryLocale
452
+ ? { [primaryLocale]: toSlug(normalized.slugByLocale, normalized.slug || normalized.id || 'case') }
453
+ : {};
454
+ }
455
+
456
+ if (!normalized.slugByLocale || typeof normalized.slugByLocale !== 'object' || Array.isArray(normalized.slugByLocale)) {
457
+ normalized.slugByLocale = {};
458
+ }
459
+
460
+ const fallbackSlug = normalized.slug || normalized.id || 'case';
461
+ const resolvedDefaultLocale = resolvePrimaryLocale(
462
+ [...Object.keys(localizedTitle), ...Object.keys(normalized.slugByLocale)],
463
+ defaultLocale
464
+ );
465
+ const fallbackTitle =
466
+ (resolvedDefaultLocale && typeof localizedTitle[resolvedDefaultLocale] === 'string' ? localizedTitle[resolvedDefaultLocale] : '')
467
+ || Object.values(localizedTitle).find((value) => typeof value === 'string')
468
+ || '';
469
+ const localeSource = new Set([...Object.keys(localizedTitle), ...Object.keys(normalized.slugByLocale)]);
470
+ if (resolvedDefaultLocale) {
471
+ localeSource.add(resolvedDefaultLocale);
472
+ }
473
+ localeSource.forEach((localeCodeRaw) => {
474
+ const localeCode = String(localeCodeRaw || '').trim().toLowerCase();
475
+ if (!localeCode) return;
476
+
477
+ const candidate = normalized.slugByLocale?.[localeCode];
478
+ const candidateTitle = typeof localizedTitle[localeCode] === 'string' ? localizedTitle[localeCode] : fallbackTitle;
479
+ normalized.slugByLocale[localeCode] = toSlug(candidate || candidateTitle, fallbackSlug);
480
+ });
481
+
482
+ return normalized;
483
+ }
484
+
485
+ // Resolves case placement mode from markdown file path.
486
+ function resolveCasePlacement(filePath) {
487
+ const normalizedPath = String(filePath || '').replace(/\\/g, '/');
488
+ if (normalizedPath.includes('/content/cases/summary/')) return 'summary-only';
489
+ if (normalizedPath.includes('/content/cases/reader/')) return 'reader-only';
490
+ return 'mixed';
491
+ }
492
+
493
+ // MARK: LOCALIZED CONTENT RESOLUTION
494
+ // Renders localized markdown maps into HTML payloads used by runtime article components.
495
+ function resolveLocalizedMarkdownHtml(value, localeCodes, defaultLocale = '') {
496
+ const locales = normalizeLocaleCodes(localeCodes);
497
+ const resolved = {};
498
+ const primaryLocale = resolvePrimaryLocale(locales, defaultLocale);
499
+ const fallbackLocalizedValue = value && typeof value === 'object'
500
+ ? (
501
+ (primaryLocale && typeof value[primaryLocale] === 'string' ? value[primaryLocale] : '')
502
+ || Object.values(value).find((entry) => typeof entry === 'string')
503
+ || ''
504
+ )
505
+ : '';
506
+
507
+ locales.forEach((localeCode) => {
508
+ let raw = '';
509
+ if (typeof value === 'string') {
510
+ raw = value;
511
+ } else if (value && typeof value === 'object') {
512
+ if (typeof value[localeCode] === 'string') {
513
+ raw = value[localeCode];
514
+ } else {
515
+ raw = fallbackLocalizedValue;
516
+ }
517
+ }
518
+
519
+ resolved[localeCode] = markdownToHtml(String(raw || '').trim());
520
+ });
521
+
522
+ return resolved;
523
+ }
524
+
525
+ // MARK: PARSED CASE DISPLAY SETTINGS
526
+ // Resolves the owning case folder path for a markdown file.
527
+ function resolveCaseFolder(filePath) {
528
+ const normalizedPath = String(filePath || '').replace(/\\/g, '/');
529
+ const folderIndex = normalizedPath.lastIndexOf('/case.md');
530
+ if (folderIndex >= 0) {
531
+ return normalizedPath.slice(0, folderIndex);
532
+ }
533
+
534
+ const lastSlashIndex = normalizedPath.lastIndexOf('/');
535
+ return lastSlashIndex >= 0 ? normalizedPath.slice(0, lastSlashIndex) : normalizedPath;
536
+ }
537
+
538
+ // Derives runtime visibility toggles for summary, reader, TOC, and navigator.
539
+ function deriveDisplayToggles(metadata, bodyMeta, contextLabel) {
540
+ const placement = resolveCasePlacement(contextLabel);
541
+ const localeCodes = Array.isArray(bodyMeta?.localeCodes) && bodyMeta.localeCodes.length > 0
542
+ ? bodyMeta.localeCodes
543
+ : DEFAULT_LOCALE_CODES;
544
+
545
+ const hasSummaryContent = localeCodes.some((localeCode) => {
546
+ const configuredSummary = metadata?.summary?.[localeCode];
547
+ return isNonEmptyString(configuredSummary);
548
+ });
549
+ const hasReaderContent = localeCodes.some((localeCode) => isNonEmptyString(bodyMeta?.readerRawByLocale?.[localeCode]));
550
+ const hasReaderHeadings = localeCodes.some((localeCode) => Boolean(bodyMeta?.hasReaderHeadingsByLocale?.[localeCode]));
551
+
552
+ const explicitShowSummary = resolveBooleanFlag(metadata, ['showSummary', 'show-summary', 'summary']);
553
+ const explicitShowReader = resolveBooleanFlag(metadata, ['showReader', 'show-reader', 'reader']);
554
+ const explicitShowToc = resolveBooleanFlag(metadata, ['showToc', 'show-toc', 'toc']);
555
+ const explicitShowNavigator = resolveBooleanFlag(metadata, ['showNavigator', 'show-navigator', 'navigator']);
556
+ const explicitShowPlayer = resolveBooleanFlag(metadata, ['showPlayer', 'show-player', 'player']);
557
+ const explicitShowCover = resolveBooleanFlag(metadata, ['showCover', 'show-cover', 'cover']);
558
+
559
+ let showSummary = hasSummaryContent;
560
+ let showReader = hasReaderContent;
561
+
562
+ if (placement === 'summary-only') {
563
+ showSummary = hasSummaryContent;
564
+ showReader = false;
565
+ }
566
+
567
+ if (placement === 'reader-only') {
568
+ showSummary = false;
569
+ showReader = hasReaderContent;
570
+ }
571
+
572
+ if (explicitShowSummary !== null) showSummary = explicitShowSummary;
573
+ if (explicitShowReader !== null) showReader = explicitShowReader;
574
+
575
+ let showCover = true;
576
+ if (explicitShowCover !== null) showCover = explicitShowCover;
577
+
578
+ let showPlayer = true;
579
+ let showToc = hasReaderHeadings;
580
+ let showNavigator = true;
581
+
582
+ if (explicitShowPlayer !== null) showPlayer = explicitShowPlayer;
583
+ if (explicitShowToc !== null) showToc = explicitShowToc;
584
+ if (explicitShowNavigator !== null) showNavigator = explicitShowNavigator;
585
+
586
+ return {
587
+ showSummary,
588
+ showReader,
589
+ showCover,
590
+ showPlayer,
591
+ showToc,
592
+ showNavigator
593
+ };
594
+ }
595
+
596
+ // MARK: CASE CONTRACT VALIDATION
597
+ // Validates parsed case data and returns a list of user-facing error messages.
598
+ function validateCaseObject(caseData, bodyMeta, contextLabel) {
599
+ // Collects validation errors produced by contract checks.
600
+ const errors = [];
601
+ const localeCodes = normalizeLocaleCodes(caseData?.locales || bodyMeta?.localeCodes || DEFAULT_LOCALE_CODES);
602
+
603
+ // Validates required scalar metadata fields.
604
+ REQUIRED_SCALAR_FIELDS.forEach((field) => {
605
+ if (!isNonEmptyString(caseData?.[field])) {
606
+ errors.push(`${contextLabel}: missing required field '${field}'.`);
607
+ }
608
+ });
609
+
610
+ // Validates required localized metadata fields.
611
+ REQUIRED_LOCALIZED_FIELDS.forEach((field) => {
612
+ // Resolves localized object for current field.
613
+ const localized = caseData?.[field];
614
+ const missingLocales = localeCodes.filter((localeCode) => !isNonEmptyString(localized?.[localeCode]));
615
+
616
+ if (missingLocales.length > 0) {
617
+ errors.push(`${contextLabel}: field '${field}' must define localized values for: ${missingLocales.join(', ')}.`);
618
+ }
619
+ });
620
+
621
+ OPTIONAL_LOCALIZED_FIELDS.forEach((field) => {
622
+ const localized = caseData?.[field];
623
+ if (!localized) return;
624
+
625
+ if (typeof localized === 'string') return;
626
+
627
+ const localeKeys = Object.keys(localized || {});
628
+ const hasAtLeastOneValue = localeKeys.some((key) => isNonEmptyString(localized?.[key]));
629
+ if (!hasAtLeastOneValue) return;
630
+ });
631
+
632
+ // Enforces non-empty localized reader/summary content contract.
633
+ const missingBodyLocales = localeCodes.filter((localeCode) => {
634
+ const hasDesc = isNonEmptyString(caseData?.desc?.[localeCode]);
635
+ const hasSummary = isNonEmptyString(caseData?.summary?.[localeCode]);
636
+ return !hasDesc && !hasSummary;
637
+ });
638
+ if (missingBodyLocales.length > 0) {
639
+ errors.push(`${contextLabel}: body content must produce non-empty reader or summary sections for: ${missingBodyLocales.join(', ')}.`);
640
+ }
641
+
642
+ // Enforces language marker balance when explicit markers are used.
643
+ if (bodyMeta.hasAnyLangMarker) {
644
+ const missingMarkers = localeCodes.filter((localeCode) => !bodyMeta?.hasLangMarkerByLocale?.[localeCode]);
645
+ if (missingMarkers.length > 0) {
646
+ errors.push(`${contextLabel}: language markers are unbalanced. Missing markers for locales: ${missingMarkers.join(', ')}.`);
647
+ }
648
+ }
649
+
650
+ // Rejects deprecated device source field in favor of catalog metadata fields.
651
+ if (isNonEmptyString(caseData?.thumbDeviceSrc)) {
652
+ errors.push(`${contextLabel}: field 'thumbDeviceSrc' is not supported. Use thumbCategory + thumbBrand + thumbModel + thumbColor.`);
653
+ }
654
+
655
+ if (objectHasPasswordLikeKeys(caseData)) {
656
+ errors.push(`${contextLabel}: password-like config keys are not allowed in case markdown. Store per-case secrets in a server-only password config.`);
657
+ }
658
+
659
+ if (typeof caseData?.tooltip !== 'undefined') {
660
+ errors.push(`${contextLabel}: top-level 'tooltip' is not supported. Use actions.primary/secondary/tertiary.tooltip.`);
661
+ }
662
+
663
+ ['showH1', 'showH2', 'year', 'videoSrc', 'repoSrc', 'demoSrc', 'readerLabel'].forEach((legacyKey) => {
664
+ if (typeof caseData?.[legacyKey] !== 'undefined') {
665
+ errors.push(`${contextLabel}: legacy field '${legacyKey}' is not supported. Use the modern config schema fields instead.`);
666
+ }
667
+ });
668
+
669
+ if (caseData?.summaryProps && typeof caseData.summaryProps === 'object') {
670
+ ['label-header', 'show-metrics', 'aria-label'].forEach((legacySummaryKey) => {
671
+ if (typeof caseData.summaryProps?.[legacySummaryKey] !== 'undefined') {
672
+ errors.push(`${contextLabel}: summaryProps.${legacySummaryKey} is not supported. Use camelCase keys in summaryProps.`);
673
+ }
674
+ });
675
+ }
676
+
677
+ if (caseData?.actions && typeof caseData.actions === 'object') {
678
+ ['secondary1', 'secondary2'].forEach((legacyActionKey) => {
679
+ if (typeof caseData.actions?.[legacyActionKey] !== 'undefined') {
680
+ errors.push(`${contextLabel}: actions.${legacyActionKey} is not supported. Use actions.secondary and actions.tertiary.`);
681
+ }
682
+ });
683
+ }
684
+
685
+ const hasLocalizedContent = (value) => {
686
+ if (isNonEmptyString(value)) return true;
687
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
688
+ return localeCodes.some((localeCode) => isNonEmptyString(value?.[localeCode]));
689
+ };
690
+
691
+ const actions = caseData?.actions && typeof caseData.actions === 'object' ? caseData.actions : {};
692
+ ['primary', 'secondary', 'tertiary'].forEach((actionKey) => {
693
+ const action = actions?.[actionKey];
694
+ if (!action || typeof action !== 'object') return;
695
+
696
+ const enabledFlag = parseBooleanFlag(action?.enabled);
697
+ const isActionEnabled = enabledFlag === null ? true : enabledFlag;
698
+
699
+ if (typeof action?.['video-src'] !== 'undefined') {
700
+ errors.push(`${contextLabel}: actions.${actionKey}.video-src is not supported. Use actions.${actionKey}.videoSrc.`);
701
+ }
702
+
703
+ if (typeof action?.['vtt-src'] !== 'undefined') {
704
+ errors.push(`${contextLabel}: actions.${actionKey}.vtt-src is not supported. Use actions.${actionKey}.vttSrc.`);
705
+ }
706
+
707
+ const hasActionTarget = hasLocalizedContent(action?.url)
708
+ || hasLocalizedContent(action?.videoSrc)
709
+ || hasLocalizedContent(action?.vttSrc);
710
+
711
+ if (!isActionEnabled || !hasActionTarget) return;
712
+
713
+ if (!hasLocalizedContent(action?.label)) {
714
+ errors.push(`${contextLabel}: actions.${actionKey}.label must define localized text when the action has url/media target.`);
715
+ }
716
+
717
+ if (!hasLocalizedContent(action?.tooltip)) {
718
+ errors.push(`${contextLabel}: actions.${actionKey}.tooltip must define localized text when the action is enabled and has url/media target.`);
719
+ }
720
+ });
721
+
722
+ return errors;
723
+ }
724
+
725
+ // MARK: DIAGNOSTIC PARSING ENTRYPOINT
726
+ // Parses one markdown case and returns structured data plus validation diagnostics.
727
+ export function parseCaseMarkdownWithDiagnostics(rawText, options = {}) {
728
+ // Resolves context label used in diagnostic message prefixes.
729
+ const contextLabel = options.filePath || 'markdown-case';
730
+ const requestedLocales = normalizeLocaleCodes(options.locales || DEFAULT_LOCALE_CODES);
731
+ const preferredDefaultLocale = resolvePrimaryLocale(requestedLocales, options.defaultLocale || '');
732
+ let metadata = null;
733
+ let bodyText = String(rawText || '').trim();
734
+ const errors = [];
735
+
736
+ // Preferred format: one top-level config block with JSON payload.
737
+ const configPayload = parseConfigMetadata(rawText, contextLabel);
738
+ errors.push(...configPayload.errors);
739
+
740
+ if (!configPayload.metadata) {
741
+ return {
742
+ caseData: null,
743
+ errors: [...errors, `${contextLabel}: missing required config block. Cases must start with '<!-- config ... -->'.`]
744
+ };
745
+ }
746
+
747
+ metadata = normalizeCaseMetadataSchema(configPayload.metadata, preferredDefaultLocale);
748
+ bodyText = configPayload.bodyText;
749
+
750
+ // Derives locale candidates from caller input and metadata before body parsing.
751
+ const metadataLocales = collectLocalizedLocales(metadata, requestedLocales);
752
+ const preferredLocales = normalizeLocaleCodes([...requestedLocales, ...metadataLocales]);
753
+ const resolvedDefaultLocale = resolvePrimaryLocale(preferredLocales, preferredDefaultLocale);
754
+ metadata = normalizeCaseMetadataSchema(configPayload.metadata, resolvedDefaultLocale);
755
+
756
+ // Parses and renders localized body content.
757
+ const body = parseLocalizedBody(bodyText, preferredLocales);
758
+ const localeCodes = normalizeLocaleCodes([...preferredLocales, ...(body.meta?.localeCodes || [])]);
759
+ const finalDefaultLocale = resolvePrimaryLocale(localeCodes, resolvedDefaultLocale);
760
+
761
+ if (localeCodes.length === 0) {
762
+ errors.push(`${contextLabel}: unable to resolve locales. Provide 'options.locales' or localized metadata/body markers.`);
763
+ }
764
+
765
+ // Builds runtime case payload with parser-provided body fields.
766
+ const caseData = {
767
+ ...metadata,
768
+ desc: body.html.desc,
769
+ summary: (() => {
770
+ const configuredSummary = resolveLocalizedMarkdownHtml(metadata?.summary, localeCodes, finalDefaultLocale);
771
+ return Object.fromEntries(localeCodes.map((localeCode) => {
772
+ const configured = configuredSummary[localeCode];
773
+ const parsed = body.html.summary?.[localeCode] || '';
774
+ return [localeCode, configured && configured.trim().length > 0 ? configured : parsed];
775
+ }));
776
+ })(),
777
+ descRecruiter: metadata.descRecruiter || body.html.desc,
778
+ display: deriveDisplayToggles(metadata, body.meta, contextLabel),
779
+ locales: localeCodes,
780
+ localeNames: Object.fromEntries(
781
+ localeCodes.map((localeCode) => [localeCode, resolveLocaleDisplayName(localeCode)])
782
+ ),
783
+ caseFolder: resolveCaseFolder(contextLabel)
784
+ };
785
+
786
+ // Runs contract validation against parsed payload.
787
+ errors.push(...validateCaseObject(caseData, body.meta, contextLabel));
788
+
789
+ return {
790
+ caseData,
791
+ errors
792
+ };
793
+ }
794
+
795
+ // MARK: STRICT PARSE EXPORT
796
+ // Parses a markdown case and returns null when diagnostics contain errors.
797
+ export function parseCaseMarkdown(rawText) {
798
+ // Reuses diagnostic parser to avoid duplicate parsing logic.
799
+ const { caseData, errors } = parseCaseMarkdownWithDiagnostics(rawText);
800
+ if (errors.length > 0) {
801
+ return null;
802
+ }
803
+
804
+ return caseData;
805
+ }