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,257 @@
1
+ #!/usr/bin/env node
2
+ // File: scripts/validate-content.mjs
3
+ // Purpose: Validate template case markdown before dev, build, or preview.
4
+ // Author: Lio Schimanko
5
+
6
+ // MARK: IMPORTS
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseCaseMarkdownWithDiagnostics } from '../src/parser/markdown.js';
10
+ import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from './i18n-config-utils.mjs';
11
+
12
+ // MARK: LAYOUT RESOLUTION
13
+ // Resolves project paths for template-repo mode and generated-app mode.
14
+ function resolveProjectLayout(cwd = process.cwd()) {
15
+ const templateCasesDir = path.join(cwd, 'templates', 'src', 'content', 'cases');
16
+ const hasTemplateCases = fs.existsSync(templateCasesDir) && fs.statSync(templateCasesDir).isDirectory();
17
+
18
+ if (hasTemplateCases) {
19
+ return {
20
+ mode: 'template',
21
+ root: cwd,
22
+ casesDir: templateCasesDir
23
+ };
24
+ }
25
+
26
+ return {
27
+ mode: 'consumer',
28
+ root: cwd,
29
+ casesDir: path.join(cwd, 'src', 'content', 'cases')
30
+ };
31
+ }
32
+
33
+ // Collect every markdown case file from the cases directory.
34
+ function listMarkdownFiles(dirPath) {
35
+ if (!fs.existsSync(dirPath)) {
36
+ return [];
37
+ }
38
+
39
+ const files = [];
40
+
41
+ // Recursively walks nested case folders to collect markdown files.
42
+ const walk = (currentDir) => {
43
+ fs.readdirSync(currentDir, { withFileTypes: true }).forEach((entry) => {
44
+ const nextPath = path.join(currentDir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ walk(nextPath);
47
+ return;
48
+ }
49
+
50
+ if (entry.name.endsWith('.md')) {
51
+ files.push(nextPath);
52
+ }
53
+ });
54
+ };
55
+
56
+ walk(dirPath);
57
+ return files;
58
+ }
59
+
60
+ // Reads UTF-8 file content from disk.
61
+ function readUtf8(filePath) {
62
+ return fs.readFileSync(filePath, 'utf8');
63
+ }
64
+
65
+ // Converts absolute file path to repository-relative path for diagnostics.
66
+ function toRelPath(filePath, rootDir) {
67
+ return path.relative(rootDir, filePath);
68
+ }
69
+
70
+ // Guard against empty or non-string values.
71
+ function isNonEmptyString(value) {
72
+ return typeof value === 'string' && value.trim().length > 0;
73
+ }
74
+
75
+ // Check localized values that must define both language variants.
76
+ function isLocalizedObject(value, localeCodes) {
77
+ if (!value || typeof value !== 'object') return false;
78
+ return localeCodes.every((localeCode) => isNonEmptyString(value[localeCode]));
79
+ }
80
+
81
+ // Accept only URLs or project-local asset paths for link-like fields.
82
+ function isValidUrlOrAssetPath(value) {
83
+ if (!isNonEmptyString(value)) return false;
84
+ // Normalizes value before URL/path pattern checks.
85
+ const normalized = value.trim();
86
+ return (
87
+ normalized.startsWith('http://')
88
+ || normalized.startsWith('https://')
89
+ || normalized.startsWith('assets/')
90
+ || normalized.startsWith('./')
91
+ || normalized.startsWith('../')
92
+ || normalized.startsWith('/')
93
+ );
94
+ }
95
+
96
+ // Surface non-fatal content quality warnings for case metadata.
97
+ function collectCaseLevelWarnings(caseData, contextLabel, supportedLocales) {
98
+ // Collects warning-level findings that should not fail builds.
99
+ const warnings = [];
100
+ const localeCodes = Array.isArray(caseData?.locales) && caseData.locales.length > 0
101
+ ? caseData.locales
102
+ : supportedLocales;
103
+ // Accepts localized maps only when at least one locale contains non-empty text.
104
+ const hasAnyLocalizedValue = (value) => {
105
+ if (!value || typeof value !== 'object') return false;
106
+ return Object.values(value).some((entry) => isNonEmptyString(entry));
107
+ };
108
+
109
+ if (isNonEmptyString(caseData?.repositoryUrl) && !isValidUrlOrAssetPath(caseData.repositoryUrl)) {
110
+ warnings.push(`${contextLabel}: repositoryUrl should be a valid URL or asset-style path.`);
111
+ }
112
+
113
+ if (isNonEmptyString(caseData?.liveUrl) && !isValidUrlOrAssetPath(caseData.liveUrl)) {
114
+ warnings.push(`${contextLabel}: liveUrl should be a valid URL or asset-style path.`);
115
+ }
116
+
117
+ if (hasAnyLocalizedValue(caseData?.videoSrc) && !isLocalizedObject(caseData.videoSrc, localeCodes)) {
118
+ warnings.push(`${contextLabel}: videoSrc should define localized values for locales: ${localeCodes.join(', ')} when provided.`);
119
+ }
120
+
121
+ if (hasAnyLocalizedValue(caseData?.vttSrc) && !isLocalizedObject(caseData.vttSrc, localeCodes)) {
122
+ warnings.push(`${contextLabel}: vttSrc should define localized values for locales: ${localeCodes.join(', ')} when provided.`);
123
+ }
124
+
125
+ if (hasAnyLocalizedValue(caseData?.audioSrc) && !isLocalizedObject(caseData.audioSrc, localeCodes)) {
126
+ warnings.push(`${contextLabel}: audioSrc should define localized values for locales: ${localeCodes.join(', ')} when provided.`);
127
+ }
128
+
129
+ if (isNonEmptyString(caseData?.id) && !/^[a-z0-9-]+$/.test(caseData.id)) {
130
+ warnings.push(`${contextLabel}: id should use lowercase kebab-case (letters, numbers, hyphens).`);
131
+ }
132
+
133
+ if (caseData?.slugByLocale && typeof caseData.slugByLocale === 'object') {
134
+ Object.entries(caseData.slugByLocale).forEach(([localeCode, slugValue]) => {
135
+ if (!isNonEmptyString(slugValue)) return;
136
+ if (!/^[a-z0-9-]+$/.test(slugValue)) {
137
+ warnings.push(`${contextLabel}: slugByLocale.${localeCode} should use lowercase kebab-case (letters, numbers, hyphens).`);
138
+ }
139
+ });
140
+ }
141
+
142
+ if (isNonEmptyString(caseData?.caseOrder) && !/^\d+$/.test(String(caseData.caseOrder).trim())) {
143
+ warnings.push(`${contextLabel}: caseOrder should be a positive integer (for example: caseOrder: 10).`);
144
+ }
145
+
146
+ return warnings;
147
+ }
148
+
149
+ // Detect duplicate ids and slugs across all parsed case files.
150
+ function collectCrossFileErrors(parsedEntries) {
151
+ // Collects cross-file fatal validation errors.
152
+ const errors = [];
153
+ // Maps case ID values to first defining file.
154
+ const idToFile = new Map();
155
+ const localizedSlugToFile = new Map();
156
+
157
+ parsedEntries.forEach(({ filePath, caseData }) => {
158
+ // Reads case id for duplicate-id detection.
159
+ const id = caseData?.id;
160
+ if (isNonEmptyString(id)) {
161
+ if (idToFile.has(id)) {
162
+ errors.push(`${filePath}: duplicate id '${id}'. First defined in ${idToFile.get(id)}.`);
163
+ } else {
164
+ idToFile.set(id, filePath);
165
+ }
166
+ }
167
+
168
+ const slugByLocale = caseData?.slugByLocale && typeof caseData.slugByLocale === 'object'
169
+ ? caseData.slugByLocale
170
+ : {};
171
+ Object.entries(slugByLocale).forEach(([localeCode, localizedSlug]) => {
172
+ if (!isNonEmptyString(localizedSlug)) return;
173
+ const collisionKey = `${String(localeCode).trim().toLowerCase()}::${localizedSlug}`;
174
+ if (localizedSlugToFile.has(collisionKey)) {
175
+ errors.push(`${filePath}: duplicate localized slug '${localizedSlug}' for locale '${localeCode}'. First defined in ${localizedSlugToFile.get(collisionKey)}.`);
176
+ } else {
177
+ localizedSlugToFile.set(collisionKey, filePath);
178
+ }
179
+ });
180
+ });
181
+
182
+ return errors;
183
+ }
184
+
185
+ // Run the complete content validation pass and report errors or warnings.
186
+ export function runValidation(options = {}) {
187
+ const cwd = options.cwd || process.cwd();
188
+ const layout = resolveProjectLayout(cwd);
189
+ const i18nConfigPath = resolveI18nConfigPath(cwd);
190
+ const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
191
+ const supportedLocales = localeConfig.supportedLocales;
192
+ const defaultLocale = localeConfig.defaultLocale;
193
+
194
+ // Loads markdown files to validate.
195
+ const files = listMarkdownFiles(layout.casesDir);
196
+
197
+ if (files.length === 0) {
198
+ const emptyPathLabel = layout.mode === 'template'
199
+ ? 'templates/src/content/cases'
200
+ : 'src/content/cases';
201
+ console.warn(`[validate-content] No markdown files found in ${emptyPathLabel}.`);
202
+ return 0;
203
+ }
204
+
205
+ // Collects parser/contract errors across all files.
206
+ const allErrors = [];
207
+ // Collects warning-level quality checks across all files.
208
+ const allWarnings = [];
209
+ // Stores successfully parsed case entries for cross-file checks.
210
+ const parsedEntries = [];
211
+
212
+ files.forEach((filePath) => {
213
+ // Loads raw markdown file content.
214
+ const rawText = readUtf8(filePath);
215
+ // Computes relative path used in diagnostic output.
216
+ const relPath = toRelPath(filePath, layout.root);
217
+ // Parses case markdown and returns structured errors/case data.
218
+ const { caseData, errors } = parseCaseMarkdownWithDiagnostics(rawText, {
219
+ filePath: relPath,
220
+ locales: supportedLocales,
221
+ defaultLocale: defaultLocale
222
+ });
223
+ allErrors.push(...errors);
224
+
225
+ if (caseData) {
226
+ parsedEntries.push({ filePath: relPath, caseData });
227
+ allWarnings.push(...collectCaseLevelWarnings(caseData, relPath, supportedLocales));
228
+ }
229
+ });
230
+
231
+ allErrors.push(...collectCrossFileErrors(parsedEntries));
232
+
233
+ if (allErrors.length === 0) {
234
+ if (allWarnings.length > 0) {
235
+ console.warn('[validate-content] WARN: content checks passed with warnings:');
236
+ allWarnings.forEach((message) => {
237
+ console.warn(`- ${message}`);
238
+ });
239
+ }
240
+ console.log('[validate-content] OK: all case markdown files are valid.');
241
+ return 0;
242
+ }
243
+
244
+ console.error('[validate-content] ERROR: found content validation issues:');
245
+ allErrors.forEach((message) => {
246
+ console.error(`- ${message}`);
247
+ });
248
+
249
+ return 1;
250
+ }
251
+
252
+ // Runs validation when script is invoked directly from command line.
253
+ if (import.meta.url === `file://${process.argv[1]}`) {
254
+ // Captures exit code from validation run.
255
+ const exitCode = runValidation();
256
+ process.exit(exitCode);
257
+ }
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // File: scripts/validate-protection.mjs
3
+ // Purpose: Validate PHP protection contract before dev/build/preview/smoke runs.
4
+ // Author: Lio Schimanko
5
+
6
+ // MARK: IMPORTS
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseCaseMarkdownWithDiagnostics } from '../src/parser/markdown.js';
10
+ import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from './i18n-config-utils.mjs';
11
+
12
+ // MARK: PATH RESOLUTION
13
+ // Resolves project paths for template-repo mode and generated-app mode.
14
+ function resolveProjectLayout(cwd = process.cwd()) {
15
+ const templateRoot = path.join(cwd, 'templates');
16
+ const hasTemplateCases = fs.existsSync(path.join(templateRoot, 'src', 'content', 'cases'));
17
+
18
+ if (hasTemplateCases) {
19
+ return {
20
+ mode: 'template',
21
+ root: cwd,
22
+ casesDir: path.join(templateRoot, 'src', 'content', 'cases'),
23
+ designConfigPath: path.join(templateRoot, 'configs', 'portfoliable.design.config.js'),
24
+ phpEndpointPath: path.join(templateRoot, 'public', 'api', 'unlock-case.php'),
25
+ passwordConfigPath: path.join(templateRoot, 'public', 'api', 'password.config.json'),
26
+ passwordExamplePath: path.join(templateRoot, 'public', 'api', 'password.config.example.json')
27
+ };
28
+ }
29
+
30
+ return {
31
+ mode: 'consumer',
32
+ root: cwd,
33
+ casesDir: path.join(cwd, 'src', 'content', 'cases'),
34
+ designConfigPath: path.join(cwd, 'configs', 'portfoliable.design.config.js'),
35
+ phpEndpointPath: path.join(cwd, 'public', 'api', 'unlock-case.php'),
36
+ passwordConfigPath: path.join(cwd, 'public', 'api', 'password.config.json'),
37
+ passwordExamplePath: path.join(cwd, 'public', 'api', 'password.config.example.json')
38
+ };
39
+ }
40
+
41
+ // MARK: VALIDATION HELPERS
42
+ // Collects markdown files recursively from a directory tree.
43
+ function listMarkdownFiles(dirPath) {
44
+ if (!fs.existsSync(dirPath)) return [];
45
+ const files = [];
46
+
47
+ // Walks nested directories to discover all markdown case files.
48
+ const walk = (currentDir) => {
49
+ fs.readdirSync(currentDir, { withFileTypes: true }).forEach((entry) => {
50
+ const nextPath = path.join(currentDir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ walk(nextPath);
53
+ return;
54
+ }
55
+ if (entry.name.endsWith('.md')) {
56
+ files.push(nextPath);
57
+ }
58
+ });
59
+ };
60
+
61
+ walk(dirPath);
62
+ return files;
63
+ }
64
+
65
+ // Reads JSON content from disk for password config checks.
66
+ function readJson(filePath) {
67
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
68
+ }
69
+
70
+ // Converts absolute paths to repository-relative labels for diagnostics.
71
+ function toRel(filePath, rootDir) {
72
+ return path.relative(rootDir, filePath);
73
+ }
74
+
75
+ // Extracts configured unlock endpoint from design config source text.
76
+ function parseUnlockEndpoint(configText) {
77
+ const match = configText.match(/unlockEndpoint\s*:\s*['\"]([^'\"]+)['\"]/);
78
+ return match ? String(match[1]).trim() : '';
79
+ }
80
+
81
+ // Validates supported password hash formats and rejects template placeholders.
82
+ function isRecognizedPasswordHash(value) {
83
+ const hash = String(value || '');
84
+ if (!hash) return false;
85
+ if (hash.includes('replace') || hash.includes('generated-hash')) return false;
86
+ return hash.startsWith('$argon2id$') || hash.startsWith('$argon2i$') || hash.startsWith('$2y$') || hash.startsWith('$2b$');
87
+ }
88
+
89
+ // MARK: VALIDATION ENTRYPOINT
90
+ // Validates unlock endpoint and password hash records for protected cases.
91
+ export function runProtectionValidation(options = {}) {
92
+ const cwd = options.cwd || process.cwd();
93
+ const layout = resolveProjectLayout(cwd);
94
+ const i18nConfigPath = resolveI18nConfigPath(cwd);
95
+ const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
96
+ const supportedLocales = localeConfig.supportedLocales;
97
+ const defaultLocale = localeConfig.defaultLocale;
98
+
99
+ const errors = [];
100
+
101
+ const caseFiles = listMarkdownFiles(layout.casesDir);
102
+ const protectedCaseIds = [];
103
+
104
+ caseFiles.forEach((filePath) => {
105
+ const relPath = toRel(filePath, layout.root);
106
+ const raw = fs.readFileSync(filePath, 'utf8');
107
+ const { caseData, errors: parseErrors } = parseCaseMarkdownWithDiagnostics(raw, {
108
+ filePath: relPath,
109
+ locales: supportedLocales,
110
+ defaultLocale: defaultLocale
111
+ });
112
+
113
+ if (parseErrors.length > 0 || !caseData) {
114
+ return;
115
+ }
116
+
117
+ if (caseData.isProtected === true) {
118
+ protectedCaseIds.push(caseData.id);
119
+ }
120
+ });
121
+
122
+ if (protectedCaseIds.length === 0) {
123
+ console.log('[validate-protection] OK: no protected cases enabled.');
124
+ return 0;
125
+ }
126
+
127
+ if (!fs.existsSync(layout.designConfigPath)) {
128
+ errors.push(`${toRel(layout.designConfigPath, layout.root)} is missing.`);
129
+ } else {
130
+ const configText = fs.readFileSync(layout.designConfigPath, 'utf8');
131
+ const unlockEndpoint = parseUnlockEndpoint(configText);
132
+ if (!unlockEndpoint) {
133
+ errors.push(`${toRel(layout.designConfigPath, layout.root)} must define protection.unlockEndpoint.`);
134
+ } else if (!unlockEndpoint.includes('/api/unlock-case.php')) {
135
+ errors.push(`${toRel(layout.designConfigPath, layout.root)} unlockEndpoint must point to /api/unlock-case.php for PHP self-host parity.`);
136
+ }
137
+ }
138
+
139
+ if (!fs.existsSync(layout.phpEndpointPath)) {
140
+ errors.push(`${toRel(layout.phpEndpointPath, layout.root)} is missing.`);
141
+ }
142
+
143
+ if (!fs.existsSync(layout.passwordConfigPath)) {
144
+ errors.push(`${toRel(layout.passwordConfigPath, layout.root)} is missing. Copy ${toRel(layout.passwordExamplePath, layout.root)} and add hashes for protected cases.`);
145
+ } else {
146
+ let config = null;
147
+ try {
148
+ config = readJson(layout.passwordConfigPath);
149
+ } catch {
150
+ errors.push(`${toRel(layout.passwordConfigPath, layout.root)} is not valid JSON.`);
151
+ }
152
+
153
+ if (config) {
154
+ const cases = config?.cases && typeof config.cases === 'object' ? config.cases : null;
155
+ if (!cases) {
156
+ errors.push(`${toRel(layout.passwordConfigPath, layout.root)} must contain a cases object.`);
157
+ } else {
158
+ protectedCaseIds.forEach((caseId) => {
159
+ const record = cases[caseId];
160
+ const hash = record?.hash;
161
+ if (!record || typeof record !== 'object') {
162
+ errors.push(`${toRel(layout.passwordConfigPath, layout.root)} is missing cases.${caseId}.hash`);
163
+ return;
164
+ }
165
+ if (!isRecognizedPasswordHash(hash)) {
166
+ errors.push(`${toRel(layout.passwordConfigPath, layout.root)} has invalid hash format for cases.${caseId}.hash`);
167
+ }
168
+ });
169
+ }
170
+ }
171
+ }
172
+
173
+ if (errors.length > 0) {
174
+ console.error('[validate-protection] ERROR:');
175
+ errors.forEach((error) => console.error(`- ${error}`));
176
+ return 1;
177
+ }
178
+
179
+ console.log(`[validate-protection] OK: PHP unlock protection is configured for ${protectedCaseIds.length} protected case(s).`);
180
+ return 0;
181
+ }
182
+
183
+ // MARK: SCRIPT ENTRYPOINT
184
+ // CLI entrypoint for protection validation checks.
185
+ if (import.meta.url === `file://${process.argv[1]}`) {
186
+ process.exit(runProtectionValidation());
187
+ }
@@ -0,0 +1,188 @@
1
+ // File: create-portfoliable/templates/src/cases/index.js
2
+ // Purpose: Load and normalize markdown cases for generated consumer apps.
3
+ // Author: Lio Schimanko
4
+
5
+ // MARK: IMPORTS
6
+ import { parseCaseMarkdownWithDiagnostics } from '../parser/markdown.js';
7
+ import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../../configs/i18n/i18n.config.js';
8
+
9
+ // MARK: LOCALIZATION NORMALIZATION
10
+ // Converts a value into canonical localized shape with optional fallback object.
11
+ function toLocalized(value, fallback, localeCodes = SUPPORTED_LOCALES) {
12
+ const locales = Array.isArray(localeCodes) && localeCodes.length > 0
13
+ ? localeCodes
14
+ : SUPPORTED_LOCALES;
15
+
16
+ const fallbackValueByLocale = (localeCode) => {
17
+ if (fallback && typeof fallback === 'object' && typeof fallback[localeCode] === 'string') {
18
+ return fallback[localeCode];
19
+ }
20
+ if (fallback && typeof fallback === 'object' && typeof fallback[DEFAULT_LOCALE] === 'string') {
21
+ return fallback[DEFAULT_LOCALE];
22
+ }
23
+ if (typeof fallback === 'string') {
24
+ return fallback;
25
+ }
26
+ return '';
27
+ };
28
+
29
+ if (typeof value === 'string') {
30
+ return Object.fromEntries(locales.map((localeCode) => [localeCode, value]));
31
+ }
32
+
33
+ if (value && typeof value === 'object') {
34
+ const localized = Object.fromEntries(locales.map((localeCode) => {
35
+ const raw = value[localeCode];
36
+ if (typeof raw === 'string') {
37
+ return [localeCode, raw];
38
+ }
39
+
40
+ const defaultRaw = value[DEFAULT_LOCALE];
41
+ if (typeof defaultRaw === 'string') {
42
+ return [localeCode, defaultRaw];
43
+ }
44
+
45
+ return [localeCode, fallbackValueByLocale(localeCode)];
46
+ }));
47
+
48
+ Object.keys(value).forEach((key) => {
49
+ if (locales.includes(key)) return;
50
+ localized[key] = value[key];
51
+ });
52
+
53
+ return localized;
54
+ }
55
+
56
+ return Object.fromEntries(locales.map((localeCode) => [localeCode, fallbackValueByLocale(localeCode)]));
57
+ }
58
+
59
+ // Parses manual gallery order from markdown metadata.
60
+ function parseCaseOrder(value) {
61
+ if (value === null || value === undefined || value === '') return Number.POSITIVE_INFINITY;
62
+
63
+ const parsed = Number.parseInt(String(value).trim(), 10);
64
+ if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY;
65
+ return parsed;
66
+ }
67
+
68
+ function normalizeSummaryProps(value, localeCodes = SUPPORTED_LOCALES) {
69
+ const summaryProps = value && typeof value === 'object' && !Array.isArray(value)
70
+ ? value
71
+ : {};
72
+
73
+ return {
74
+ ...summaryProps,
75
+ text: toLocalized(summaryProps.text, '', localeCodes),
76
+ labelHeader: toLocalized(summaryProps.labelHeader, '', localeCodes),
77
+ ariaLabel: toLocalized(summaryProps.ariaLabel, '', localeCodes),
78
+ active: typeof summaryProps.active === 'boolean' ? summaryProps.active : false,
79
+ showMetrics: typeof summaryProps.showMetrics === 'boolean' ? summaryProps.showMetrics : false
80
+ };
81
+ }
82
+
83
+ // Normalizes one parsed markdown case into the starter app runtime contract.
84
+ function normalizeMarkdownCase(markdownCase) {
85
+ const localeCodes = Array.isArray(markdownCase?.locales) && markdownCase.locales.length > 0
86
+ ? markdownCase.locales
87
+ : SUPPORTED_LOCALES;
88
+
89
+ return {
90
+ ...markdownCase,
91
+ id: markdownCase.id,
92
+ caseOrder: parseCaseOrder(markdownCase.caseOrder),
93
+ title: toLocalized(markdownCase.title, '', localeCodes),
94
+ shortDesc: toLocalized(markdownCase.shortDesc, '', localeCodes),
95
+ readTime: toLocalized(markdownCase.readTime, '', localeCodes),
96
+ kicker: toLocalized(markdownCase.kicker ?? markdownCase.year, '', localeCodes),
97
+ thumbSrc: toLocalized(markdownCase.thumbSrc, '', localeCodes),
98
+ desc: toLocalized(markdownCase.desc, '', localeCodes),
99
+ summary: toLocalized(markdownCase.summary, '', localeCodes),
100
+ summaryProps: normalizeSummaryProps(markdownCase.summaryProps, localeCodes),
101
+ descRecruiter: toLocalized(markdownCase.descRecruiter, markdownCase.desc, localeCodes),
102
+ locales: localeCodes,
103
+ display: {
104
+ showSummary: Boolean(markdownCase?.display?.showSummary),
105
+ showReader: markdownCase?.display?.showReader !== false,
106
+ showCover: markdownCase?.display?.showCover !== false,
107
+ showPlayer: markdownCase?.display?.showPlayer !== false,
108
+ showToc: Boolean(markdownCase?.display?.showToc),
109
+ showNavigator: markdownCase?.display?.showNavigator !== false
110
+ }
111
+ };
112
+ }
113
+
114
+ // MARK: MARKDOWN CONTENT LOADING
115
+ // Loads markdown modules, parses cases, and aggregates diagnostics for console output.
116
+ function loadMarkdownCases() {
117
+ // Eagerly imports raw case markdown for startup-time parsing and validation.
118
+ const modules = import.meta.glob('../content/cases/*.md', {
119
+ eager: true,
120
+ import: 'default',
121
+ query: '?raw'
122
+ });
123
+
124
+ const nestedModules = import.meta.glob('../content/cases/**/*.md', {
125
+ eager: true,
126
+ import: 'default',
127
+ query: '?raw'
128
+ });
129
+
130
+ const allModules = {
131
+ ...nestedModules,
132
+ ...modules
133
+ };
134
+
135
+ // Collects parser diagnostics encountered across all markdown files.
136
+ const diagnostics = [];
137
+
138
+ // Parses all imported markdown files and returns valid caseData entries.
139
+ const parsedCases = Object.entries(allModules)
140
+ .map(([filePath, rawText]) => {
141
+ // Parses one markdown module and returns case payload plus diagnostics.
142
+ const result = parseCaseMarkdownWithDiagnostics(rawText, {
143
+ filePath,
144
+ locales: SUPPORTED_LOCALES,
145
+ defaultLocale: DEFAULT_LOCALE
146
+ });
147
+ if (result.errors.length > 0) {
148
+ diagnostics.push(...result.errors);
149
+ }
150
+ return result.caseData;
151
+ })
152
+ .filter(Boolean);
153
+
154
+ // Prints parser diagnostics as warnings without aborting app bootstrap.
155
+ if (diagnostics.length > 0) {
156
+ console.warn('[portfoliable] Content validation warnings:');
157
+ diagnostics.forEach((message) => console.warn(`- ${message}`));
158
+ }
159
+
160
+ return parsedCases;
161
+ }
162
+
163
+ // MARK: PUBLIC API
164
+ // Returns normalized portfolio case records for template-generated app runtime.
165
+ export function getPortfolioCases() {
166
+ // Loads all parsed markdown case entries.
167
+ const markdownCases = loadMarkdownCases();
168
+
169
+ if (markdownCases.length === 0) {
170
+ console.warn('[portfoliable] No markdown cases were loaded from src/content/cases.');
171
+ }
172
+
173
+ // Normalizes and sorts cases by optional manual order before runtime render.
174
+ return markdownCases
175
+ .map((markdownCase, sourceIndex) => ({
176
+ sourceIndex,
177
+ caseData: normalizeMarkdownCase(markdownCase)
178
+ }))
179
+ .sort((a, b) => {
180
+ const orderDelta = a.caseData.caseOrder - b.caseData.caseOrder;
181
+ if (Number.isFinite(orderDelta) && orderDelta !== 0) return orderDelta;
182
+ return a.sourceIndex - b.sourceIndex;
183
+ })
184
+ .map((entry) => entry.caseData);
185
+ }
186
+
187
+ // Exports eagerly loaded case list used by template bootstrap.
188
+ export const portfolioCases = getPortfolioCases();