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,98 @@
1
+ #!/usr/bin/env node
2
+ // File: templates/scripts/generate-password-hash.mjs
3
+ // Purpose: Generate PHP password_hash records for protected case passwords.
4
+ // Author: Lio Schimanko
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ function parseArgs(argv) {
9
+ const args = argv.slice(2);
10
+ const options = {
11
+ caseId: '',
12
+ password: '',
13
+ algorithm: 'argon2id'
14
+ };
15
+
16
+ for (let i = 0; i < args.length; i += 1) {
17
+ const token = args[i];
18
+ if (token === '--case-id' && args[i + 1]) {
19
+ options.caseId = String(args[i + 1]).trim();
20
+ i += 1;
21
+ continue;
22
+ }
23
+
24
+ if (token === '--password' && args[i + 1]) {
25
+ options.password = String(args[i + 1]);
26
+ i += 1;
27
+ continue;
28
+ }
29
+
30
+ if (token === '--algorithm' && args[i + 1]) {
31
+ options.algorithm = String(args[i + 1]).trim().toLowerCase();
32
+ i += 1;
33
+ }
34
+ }
35
+
36
+ return options;
37
+ }
38
+
39
+ function toKebab(value) {
40
+ return String(value || '')
41
+ .trim()
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, '-')
44
+ .replace(/^-+|-+$/g, '');
45
+ }
46
+
47
+ function buildHash(password, algorithm = 'argon2id') {
48
+ const normalizedAlgorithm = algorithm === 'bcrypt' ? 'bcrypt' : 'argon2id';
49
+ const phpCode = [
50
+ '$password = $argv[1] ?? "";',
51
+ '$algorithm = strtolower((string)($argv[2] ?? "argon2id"));',
52
+ '$algoConst = $algorithm === "bcrypt" ? PASSWORD_BCRYPT : PASSWORD_ARGON2ID;',
53
+ '$hash = password_hash($password, $algoConst);',
54
+ 'if ($hash === false) { fwrite(STDERR, "password-hash-failed\\n"); exit(1); }',
55
+ 'echo $hash;'
56
+ ].join(' ');
57
+
58
+ let hash = '';
59
+ try {
60
+ hash = execFileSync('php', ['-r', phpCode, String(password || ''), normalizedAlgorithm], {
61
+ encoding: 'utf8'
62
+ }).trim();
63
+ } catch {
64
+ throw new Error('PHP CLI is required to generate production-compatible password hashes.');
65
+ }
66
+
67
+ return {
68
+ hash,
69
+ algorithm: normalizedAlgorithm
70
+ };
71
+ }
72
+
73
+ function main() {
74
+ const options = parseArgs(process.argv);
75
+ const caseId = toKebab(options.caseId);
76
+ const password = options.password;
77
+
78
+ if (!caseId) {
79
+ console.error('[password-hash] Missing --case-id <id>.');
80
+ process.exit(1);
81
+ }
82
+
83
+ if (!password) {
84
+ console.error('[password-hash] Missing --password <value>.');
85
+ process.exit(1);
86
+ }
87
+
88
+ const record = buildHash(password, options.algorithm);
89
+ const payload = {
90
+ caseId,
91
+ hash: record.hash
92
+ };
93
+
94
+ console.log(JSON.stringify(payload, null, 2));
95
+ console.error('[password-hash] Paste this hash into public/api/password.config.json under cases.<caseId>.hash');
96
+ }
97
+
98
+ main();
@@ -0,0 +1,123 @@
1
+ // File: scripts/i18n-config-utils.mjs
2
+ // Purpose: Read i18n language config from template/consumer i18n config files in Node scripts.
3
+ // Author: Lio Schimanko
4
+
5
+ // MARK: IMPORTS
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ // MARK: CONFIG PATTERNS
10
+ const LANGUAGE_CONFIG_REGEX = /export\s+const\s+LANGUAGE_CONFIG\s*=\s*Object\.freeze\(\s*(\{[\s\S]*?\})\s*\);/;
11
+ const DEFAULT_LOCALE_REGEX = /export\s+const\s+DEFAULT_LOCALE\s*=\s*['\"]([^'\"]+)['\"]/;
12
+ const RTL_LOCALE_CODES_REGEX = /export\s+const\s+RTL_LOCALE_CODES\s*=\s*Object\.freeze\(\s*(\[[\s\S]*?\])\s*\);/;
13
+
14
+ // MARK: PARSING HELPERS
15
+ // Parses exported config literals while supporting JSON and JS object syntax.
16
+ function parseConfigLiteral(literalText, fallbackValue) {
17
+ const raw = String(literalText || '').trim();
18
+ if (!raw) return fallbackValue;
19
+
20
+ try {
21
+ return JSON.parse(raw);
22
+ } catch {
23
+ try {
24
+ const evaluateLiteral = new Function(`return (${raw});`);
25
+ return evaluateLiteral();
26
+ } catch {
27
+ return fallbackValue;
28
+ }
29
+ }
30
+ }
31
+
32
+ // MARK: PUBLIC API
33
+ // Reads locale defaults and language config from the i18n config source file.
34
+ export function readLocaleConfigFromI18nConfig(i18nConfigPath) {
35
+ const fallback = {
36
+ defaultLocale: '',
37
+ supportedLocales: [],
38
+ rtlLocaleCodes: [],
39
+ languageConfig: {}
40
+ };
41
+
42
+ if (!fs.existsSync(i18nConfigPath)) {
43
+ return fallback;
44
+ }
45
+
46
+ const source = fs.readFileSync(i18nConfigPath, 'utf8');
47
+ const defaultLocaleMatch = source.match(DEFAULT_LOCALE_REGEX);
48
+ const defaultLocale = String(defaultLocaleMatch?.[1] || fallback.defaultLocale).trim().toLowerCase();
49
+
50
+ let parsedRtlLocaleCodes = [...fallback.rtlLocaleCodes];
51
+ const rtlLocaleCodesMatch = source.match(RTL_LOCALE_CODES_REGEX);
52
+ if (rtlLocaleCodesMatch?.[1]) {
53
+ const parsed = parseConfigLiteral(rtlLocaleCodesMatch[1], null);
54
+ if (Array.isArray(parsed)) {
55
+ parsedRtlLocaleCodes = parsed
56
+ .map((value) => String(value || '').trim().toLowerCase())
57
+ .filter((value, index, list) => value.length > 0 && list.indexOf(value) === index);
58
+ }
59
+ }
60
+
61
+ const languageConfigMatch = source.match(LANGUAGE_CONFIG_REGEX);
62
+ if (!languageConfigMatch?.[1]) {
63
+ return {
64
+ ...fallback,
65
+ defaultLocale,
66
+ rtlLocaleCodes: parsedRtlLocaleCodes
67
+ };
68
+ }
69
+
70
+ let parsedLanguageConfig = {};
71
+ parsedLanguageConfig = parseConfigLiteral(languageConfigMatch[1], null);
72
+ if (!parsedLanguageConfig || typeof parsedLanguageConfig !== 'object' || Array.isArray(parsedLanguageConfig)) {
73
+ return {
74
+ ...fallback,
75
+ defaultLocale,
76
+ rtlLocaleCodes: parsedRtlLocaleCodes
77
+ };
78
+ }
79
+
80
+ const supportedLocales = Object.keys(parsedLanguageConfig)
81
+ .map((localeCode) => String(localeCode || '').trim().toLowerCase())
82
+ .filter((localeCode, index, list) => localeCode.length > 0 && list.indexOf(localeCode) === index);
83
+
84
+ if (supportedLocales.length === 0) {
85
+ return {
86
+ ...fallback,
87
+ defaultLocale,
88
+ rtlLocaleCodes: parsedRtlLocaleCodes
89
+ };
90
+ }
91
+
92
+ return {
93
+ defaultLocale: supportedLocales.includes(defaultLocale) ? defaultLocale : supportedLocales[0],
94
+ supportedLocales,
95
+ rtlLocaleCodes: parsedRtlLocaleCodes,
96
+ languageConfig: parsedLanguageConfig
97
+ };
98
+ }
99
+
100
+ // Resolves template, consumer, and legacy i18n config paths in priority order.
101
+ export function resolveI18nConfigPath(cwd = process.cwd()) {
102
+ const templatePath = path.join(cwd, 'templates', 'configs', 'i18n', 'i18n.config.js');
103
+ if (fs.existsSync(templatePath)) {
104
+ return templatePath;
105
+ }
106
+
107
+ const consumerPath = path.join(cwd, 'configs', 'i18n', 'i18n.config.js');
108
+ if (fs.existsSync(consumerPath)) {
109
+ return consumerPath;
110
+ }
111
+
112
+ const legacyTemplatePath = path.join(cwd, 'templates', 'configs', 'i18n.config.js');
113
+ if (fs.existsSync(legacyTemplatePath)) {
114
+ return legacyTemplatePath;
115
+ }
116
+
117
+ const legacyConsumerPath = path.join(cwd, 'configs', 'i18n.config.js');
118
+ if (fs.existsSync(legacyConsumerPath)) {
119
+ return legacyConsumerPath;
120
+ }
121
+
122
+ return templatePath;
123
+ }
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+
3
+ // File: create-portfoliable/templates/scripts/scaffold-case.mjs
4
+ // Purpose: Generate starter markdown case files inside template-generated consumer projects.
5
+ // Author: Lio Schimanko
6
+
7
+ // === IMPORTS ===
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ // [EDIT DEFAULTS HERE]
12
+ // Centralized defaults used when scaffolding new cases for consumer projects.
13
+ const DEFAULT_CASE_TEMPLATE = {
14
+ caseOrder: 100,
15
+ socialImage: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=1200&h=675&auto=format&fit=contain&bg=111111',
16
+ titleShow: true,
17
+ shortDescShow: true,
18
+ shortDescByLocale: {
19
+ en: 'Write a short summary for this case.',
20
+ pt: 'Escreva um resumo curto para este case.',
21
+ default: 'Write a short summary for this case.'
22
+ },
23
+ readTimeByLocale: {
24
+ default: '3 min'
25
+ },
26
+ kickerByLocale: {
27
+ en: '2026 - Starter Project',
28
+ pt: '2026 - Projeto Iniciador',
29
+ default: '2026 - Starter Project'
30
+ },
31
+ thumbSrc: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=1200&auto=format&fit=crop',
32
+ thumbCategory: 'mobile',
33
+ thumbBrand: 'apple',
34
+ thumbModel: 'Apple iPhone 17',
35
+ thumbColor: 'iPhone 17 - Black - Landscape',
36
+ showCover: true,
37
+ showSummary: true,
38
+ showReader: true,
39
+ showToc: true,
40
+ showNavigator: true,
41
+ showPlayer: true,
42
+ summaryByLocale: {
43
+ en: 'Add an executive summary for this case.',
44
+ pt: 'Adicione um resumo executivo para este case.',
45
+ default: 'Add an executive summary for this case.'
46
+ },
47
+ summaryPropsTextByLocale: {
48
+ en: 'Add concise impact details, launch scope, and operational confidence outcomes.',
49
+ pt: 'Adicione detalhes de impacto, escopo de lancamento e resultados de confianca operacional.',
50
+ default: 'Add concise impact details, launch scope, and operational confidence outcomes.'
51
+ },
52
+ summaryPropsLabelHeaderByLocale: {
53
+ en: 'Case Snapshot',
54
+ pt: 'Snapshot do Case',
55
+ default: 'Case Snapshot'
56
+ },
57
+ summaryPropsAriaLabelByLocale: {
58
+ en: 'Case summary with key metrics',
59
+ pt: 'Resumo do case com metricas-chave',
60
+ default: 'Case summary with key metrics'
61
+ },
62
+ audioLabelByLocale: {
63
+ en: 'Case Audio Summary',
64
+ pt: 'Resumo em Audio do Case',
65
+ default: 'Case Audio Summary'
66
+ },
67
+ customButtonPrimaryLabelByLocale: {
68
+ en: 'GitHub',
69
+ pt: 'GitHub',
70
+ default: 'GitHub'
71
+ },
72
+ customButtonPrimaryTooltipByLocale: {
73
+ en: 'Open GitHub profile',
74
+ pt: 'Abrir perfil do GitHub',
75
+ default: 'Open GitHub profile'
76
+ },
77
+ customButtonPrimaryUrl: 'https://github.com/your-handle',
78
+ customButtonPrimaryVariant: 'primary',
79
+ customButtonPrimaryIcon: 'github',
80
+ customButtonPrimaryIconVariant: 'outline',
81
+ customButtonPrimaryEnabled: true,
82
+ visibility: {
83
+ web: true,
84
+ crawlers: true,
85
+ ai: true,
86
+ locales: {}
87
+ },
88
+ isProtected: false,
89
+ isUnlocked: false,
90
+ social: {
91
+ share: { enabled: true, icon: 'share', 'icon-variant': 'outline' },
92
+ linkedin: { enabled: true, icon: 'linkedin', 'icon-variant': 'outline' },
93
+ x: { enabled: true, icon: 'x', 'icon-variant': 'outline' },
94
+ facebook: { enabled: true, icon: 'facebook', 'icon-variant': 'outline' }
95
+ },
96
+ actions: {
97
+ primary: { enabled: false, variant: 'primary', icon: 'play', 'icon-variant': 'fill' },
98
+ secondary: { enabled: false, variant: 'secondary', icon: 'link', 'icon-variant': 'outline' },
99
+ tertiary: { enabled: false, variant: 'tertiary' }
100
+ },
101
+ customButtons: []
102
+ };
103
+
104
+ function readSupportedLocales(cwd = process.cwd()) {
105
+ const templateLocaleFallback = Object.keys(DEFAULT_CASE_TEMPLATE.shortDescByLocale || {})
106
+ .filter((localeCode) => localeCode !== 'default');
107
+ const i18nConfigPath = path.resolve(cwd, 'configs', 'i18n', 'i18n.config.js');
108
+ if (!fs.existsSync(i18nConfigPath)) {
109
+ return templateLocaleFallback;
110
+ }
111
+
112
+ const source = fs.readFileSync(i18nConfigPath, 'utf8');
113
+ const match = source.match(/export\s+const\s+LANGUAGE_CONFIG\s*=\s*Object\.freeze\(\s*(\{[\s\S]*?\})\s*\);/);
114
+ if (!match?.[1]) {
115
+ return templateLocaleFallback;
116
+ }
117
+
118
+ try {
119
+ const parsed = JSON.parse(match[1]);
120
+ const locales = Object.keys(parsed)
121
+ .map((localeCode) => String(localeCode || '').trim().toLowerCase())
122
+ .filter((localeCode, index, list) => localeCode.length > 0 && list.indexOf(localeCode) === index);
123
+ return locales.length > 0 ? locales : templateLocaleFallback;
124
+ } catch {
125
+ return templateLocaleFallback;
126
+ }
127
+ }
128
+
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';
132
+ // Defines fallback title text used when callers do not provide --name.
133
+ const DEFAULT_NAME = 'My Case';
134
+
135
+ // === STRING NORMALIZATION HELPERS ===
136
+ // Converts arbitrary text into a markdown-safe slug used for id and localized slug fields.
137
+ function toSlug(value) {
138
+ return String(value || '')
139
+ .trim()
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, '-')
142
+ .replace(/^-+|-+$/g, '') || 'my-case';
143
+ }
144
+
145
+ // Converts arbitrary text into a user-friendly title case string.
146
+ function toTitle(value) {
147
+ // Cleans separators and duplicate whitespace in user-provided title text.
148
+ const cleaned = String(value || '')
149
+ .trim()
150
+ .replace(/[-_]+/g, ' ')
151
+ .replace(/\s+/g, ' ');
152
+
153
+ if (!cleaned) return DEFAULT_NAME;
154
+
155
+ return cleaned
156
+ .split(' ')
157
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
158
+ .join(' ');
159
+ }
160
+
161
+ // === CLI ARGUMENT PARSING ===
162
+ // Reads CLI flags and produces normalized scaffold options.
163
+ function parseArgs(argv) {
164
+ // Slices process arguments to skip node executable and script path.
165
+ const args = argv.slice(2);
166
+ // Initializes parser output with defaults so missing flags still produce valid output.
167
+ const options = {
168
+ outFile: DEFAULT_OUTPUT,
169
+ name: DEFAULT_NAME,
170
+ force: false
171
+ };
172
+
173
+ for (let i = 0; i < args.length; i += 1) {
174
+ // Reads current argument token under evaluation.
175
+ const arg = args[i];
176
+
177
+ if (arg === '--out' && args[i + 1]) {
178
+ options.outFile = args[i + 1];
179
+ i += 1;
180
+ continue;
181
+ }
182
+
183
+ if (arg === '--name' && args[i + 1]) {
184
+ options.name = args[i + 1];
185
+ i += 1;
186
+ continue;
187
+ }
188
+
189
+ if (arg === '--force') {
190
+ options.force = true;
191
+ }
192
+ }
193
+
194
+ return options;
195
+ }
196
+
197
+ // === TEMPLATE CONSTRUCTION ===
198
+ // Produces localized starter markdown content for a new case study.
199
+ function buildTemplate({ name, slug }) {
200
+ // Derives a normalized visible title from provided input.
201
+ const title = toTitle(name);
202
+ // Derives a safe slug fallback from either explicit slug or case name.
203
+ const cleanSlug = toSlug(slug || name);
204
+ const localeCodes = readSupportedLocales();
205
+
206
+ const localizedValue = (defaultsByLocale = {}) => {
207
+ return Object.fromEntries(localeCodes.map((localeCode) => {
208
+ const value = defaultsByLocale[localeCode] ?? defaultsByLocale.default ?? '';
209
+ return [localeCode, value];
210
+ }));
211
+ };
212
+
213
+ const caseConfig = {
214
+ id: cleanSlug,
215
+ caseOrder: DEFAULT_CASE_TEMPLATE.caseOrder,
216
+ slugByLocale: localizedValue({ default: cleanSlug }),
217
+ socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
218
+ title: {
219
+ show: DEFAULT_CASE_TEMPLATE.titleShow,
220
+ ...localizedValue({ default: title })
221
+ },
222
+ shortDesc: {
223
+ show: DEFAULT_CASE_TEMPLATE.shortDescShow,
224
+ ...localizedValue(DEFAULT_CASE_TEMPLATE.shortDescByLocale)
225
+ },
226
+ readTime: localizedValue(DEFAULT_CASE_TEMPLATE.readTimeByLocale),
227
+ kicker: localizedValue(DEFAULT_CASE_TEMPLATE.kickerByLocale),
228
+ thumbSrc: localizedValue({ default: DEFAULT_CASE_TEMPLATE.thumbSrc }),
229
+ thumbCategory: DEFAULT_CASE_TEMPLATE.thumbCategory,
230
+ thumbBrand: DEFAULT_CASE_TEMPLATE.thumbBrand,
231
+ thumbModel: DEFAULT_CASE_TEMPLATE.thumbModel,
232
+ thumbColor: DEFAULT_CASE_TEMPLATE.thumbColor,
233
+ showCover: DEFAULT_CASE_TEMPLATE.showCover,
234
+ showSummary: DEFAULT_CASE_TEMPLATE.showSummary,
235
+ summary: localizedValue(DEFAULT_CASE_TEMPLATE.summaryByLocale),
236
+ summaryProps: {
237
+ text: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsTextByLocale),
238
+ active: true,
239
+ labelHeader: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsLabelHeaderByLocale),
240
+ showMetrics: true,
241
+ ariaLabel: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsAriaLabelByLocale),
242
+ metrics: [
243
+ {
244
+ value: '',
245
+ label: localizedValue({ default: '' }),
246
+ variant: 'default',
247
+ ariaLabel: localizedValue({ default: '' })
248
+ }
249
+ ]
250
+ },
251
+ showReader: DEFAULT_CASE_TEMPLATE.showReader,
252
+ showToc: DEFAULT_CASE_TEMPLATE.showToc,
253
+ showNavigator: DEFAULT_CASE_TEMPLATE.showNavigator,
254
+ audioLabel: localizedValue(DEFAULT_CASE_TEMPLATE.audioLabelByLocale),
255
+ audioSrc: localizedValue({ default: '' }),
256
+ showPlayer: DEFAULT_CASE_TEMPLATE.showPlayer,
257
+ visibility: { ...DEFAULT_CASE_TEMPLATE.visibility },
258
+ isProtected: DEFAULT_CASE_TEMPLATE.isProtected,
259
+ isUnlocked: DEFAULT_CASE_TEMPLATE.isUnlocked,
260
+ social: {
261
+ ...DEFAULT_CASE_TEMPLATE.social,
262
+ links: {
263
+ linkedin: '',
264
+ x: '',
265
+ facebook: ''
266
+ }
267
+ },
268
+ actions: {
269
+ primary: {
270
+ ...DEFAULT_CASE_TEMPLATE.actions.primary,
271
+ label: localizedValue({ en: 'Watch Launch Video', pt: 'Assistir Video do Lancamento', default: 'Watch Launch Video' }),
272
+ tooltip: localizedValue({ en: 'Open launch walkthrough video', pt: 'Abrir video de apresentacao do lancamento', default: 'Open launch walkthrough video' }),
273
+ url: localizedValue({ default: '' }),
274
+ videoSrc: localizedValue({ default: '' }),
275
+ vttSrc: localizedValue({ default: '' }),
276
+ imageAlt: localizedValue({ default: '' }),
277
+ ariaLabel: localizedValue({ default: '' })
278
+ },
279
+ secondary: {
280
+ ...DEFAULT_CASE_TEMPLATE.actions.secondary,
281
+ label: localizedValue({ en: 'Repository', pt: 'Repositorio', default: 'Repository' }),
282
+ tooltip: localizedValue({ en: 'Open implementation repository', pt: 'Abrir repositorio de implementacao', default: 'Open implementation repository' }),
283
+ url: localizedValue({ default: '' }),
284
+ imageAlt: localizedValue({ default: '' }),
285
+ ariaLabel: localizedValue({ default: '' })
286
+ },
287
+ tertiary: {
288
+ ...DEFAULT_CASE_TEMPLATE.actions.tertiary,
289
+ label: localizedValue({ en: 'Live Demo', pt: 'Demo Ao Vivo', default: 'Live Demo' }),
290
+ tooltip: localizedValue({ en: 'Open interactive demo', pt: 'Abrir demo interativa', default: 'Open interactive demo' }),
291
+ url: localizedValue({ default: '' }),
292
+ 'has-image': false,
293
+ 'image-src': '',
294
+ 'image-alt': '',
295
+ imageAlt: localizedValue({ default: '' }),
296
+ ariaLabel: localizedValue({ default: '' })
297
+ }
298
+ },
299
+ customButtons: [
300
+ {
301
+ label: localizedValue(DEFAULT_CASE_TEMPLATE.customButtonPrimaryLabelByLocale),
302
+ tooltip: localizedValue(DEFAULT_CASE_TEMPLATE.customButtonPrimaryTooltipByLocale),
303
+ url: DEFAULT_CASE_TEMPLATE.customButtonPrimaryUrl,
304
+ variant: DEFAULT_CASE_TEMPLATE.customButtonPrimaryVariant,
305
+ icon: DEFAULT_CASE_TEMPLATE.customButtonPrimaryIcon,
306
+ 'icon-variant': DEFAULT_CASE_TEMPLATE.customButtonPrimaryIconVariant,
307
+ enabled: DEFAULT_CASE_TEMPLATE.customButtonPrimaryEnabled,
308
+ imageAlt: localizedValue({ default: '' }),
309
+ ariaLabel: localizedValue({ default: '' })
310
+ }
311
+ ]
312
+ };
313
+
314
+ const sectionBlock = localeCodes.map((localeCode) => {
315
+ if (localeCode === 'pt') {
316
+ return `<!-- lang:${localeCode} -->\n## Problema\nDescreva o problema em portugues.\n\n## Solucao\nDescreva a solucao em portugues.`;
317
+ }
318
+
319
+ return `<!-- lang:${localeCode} -->\n## Problem\nDescribe the problem in ${localeCode}.\n\n## Solution\nDescribe the solution in ${localeCode}.`;
320
+ }).join('\n\n');
321
+
322
+ return `${formatCaseConfigBlock(caseConfig)}\n\n${sectionBlock}\n`;
323
+ }
324
+
325
+ function formatConfigField(key, value, indent = ' ') {
326
+ const serialized = JSON.stringify(value, null, 2);
327
+ if (!serialized.includes('\n')) {
328
+ return `${indent}"${key}": ${serialized},`;
329
+ }
330
+
331
+ const lines = serialized.split('\n');
332
+ const output = [`${indent}"${key}": ${lines[0]}`];
333
+ lines.slice(1).forEach((line) => {
334
+ output.push(`${indent}${line}`);
335
+ });
336
+ output[output.length - 1] = `${output[output.length - 1]},`;
337
+ return output.join('\n');
338
+ }
339
+
340
+ function formatCaseConfigBlock(caseConfig) {
341
+ const lines = [
342
+ '<!-- config',
343
+ '{',
344
+ ' // Identity',
345
+ formatConfigField('id', caseConfig.id),
346
+ formatConfigField('caseOrder', caseConfig.caseOrder),
347
+ formatConfigField('slugByLocale', caseConfig.slugByLocale),
348
+ formatConfigField('socialImage', caseConfig.socialImage),
349
+ '',
350
+ ' // Localized Metadata',
351
+ formatConfigField('title', caseConfig.title),
352
+ formatConfigField('shortDesc', caseConfig.shortDesc),
353
+ formatConfigField('readTime', caseConfig.readTime),
354
+ formatConfigField('kicker', caseConfig.kicker),
355
+ '',
356
+ ' // Thumbnail and Cover',
357
+ ' // Available thumbCategory values: desktop, mobile, tablet, wearable',
358
+ formatConfigField('thumbSrc', caseConfig.thumbSrc),
359
+ formatConfigField('thumbCategory', caseConfig.thumbCategory),
360
+ formatConfigField('thumbBrand', caseConfig.thumbBrand),
361
+ formatConfigField('thumbModel', caseConfig.thumbModel),
362
+ formatConfigField('thumbColor', caseConfig.thumbColor),
363
+ formatConfigField('showCover', caseConfig.showCover),
364
+ '',
365
+ ' // Summary Experience (edit here or in summary:start blocks below)',
366
+ formatConfigField('showSummary', caseConfig.showSummary),
367
+ formatConfigField('summary', caseConfig.summary),
368
+ formatConfigField('summaryProps', caseConfig.summaryProps),
369
+ '',
370
+ ' // Reader Experience',
371
+ formatConfigField('showReader', caseConfig.showReader),
372
+ formatConfigField('showToc', caseConfig.showToc),
373
+ formatConfigField('showNavigator', caseConfig.showNavigator),
374
+ '',
375
+ ' // Audio Experience',
376
+ formatConfigField('audioLabel', caseConfig.audioLabel),
377
+ formatConfigField('audioSrc', caseConfig.audioSrc),
378
+ formatConfigField('showPlayer', caseConfig.showPlayer),
379
+ '',
380
+ ' // Visibility and Protection',
381
+ formatConfigField('visibility', caseConfig.visibility),
382
+ formatConfigField('isProtected', caseConfig.isProtected),
383
+ formatConfigField('isUnlocked', caseConfig.isUnlocked),
384
+ '',
385
+ ' // Social',
386
+ formatConfigField('social', caseConfig.social),
387
+ '',
388
+ ' // Actions',
389
+ formatConfigField('actions', caseConfig.actions),
390
+ '',
391
+ ' // Custom Buttons',
392
+ formatConfigField('customButtons', caseConfig.customButtons),
393
+ '}',
394
+ '-->'
395
+ ];
396
+
397
+ return lines.join('\n');
398
+ }
399
+
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
+ // === SCAFFOLD EXECUTION ===
410
+ // Creates a new case markdown file, respecting overwrite safety rules.
411
+ export function runCaseScaffold(options = {}) {
412
+ // Resolves working directory so script can be invoked from any location.
413
+ 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
+ // Reads desired case display name from options or uses default title.
417
+ const name = options.name || DEFAULT_NAME;
418
+ const caseSlug = resolveCaseSlugFromOutput(outFile, name);
419
+ // Converts force option to explicit boolean for overwrite checks.
420
+ const force = Boolean(options.force);
421
+ // Resolves absolute output path to avoid relative path ambiguity.
422
+ const outputPath = path.resolve(cwd, outFile);
423
+
424
+ if (fs.existsSync(outputPath) && !force) {
425
+ console.error(`[scaffold-case] Refusing to overwrite existing file: ${outputPath}`);
426
+ console.error('[scaffold-case] Use --force to overwrite.');
427
+ return 1;
428
+ }
429
+
430
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
431
+ fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug }), 'utf8');
432
+ console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
433
+ return 0;
434
+ }
435
+
436
+ // === SCRIPT ENTRYPOINT ===
437
+ // Executes argument parsing and scaffold generation when script is run directly.
438
+ if (import.meta.url === `file://${process.argv[1]}`) {
439
+ // Parses CLI options from process arguments.
440
+ const options = parseArgs(process.argv);
441
+ // Executes scaffold flow and captures exit code.
442
+ const exitCode = runCaseScaffold(options);
443
+ process.exit(exitCode);
444
+ }