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.
- package/bin/create-portfoliable.mjs +425 -0
- package/cli/portfoliable.mjs +836 -0
- package/index.html +18 -0
- package/package.json +102 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/scripts/check-updates.mjs +31 -0
- package/scripts/ensure-valence-index-css.mjs +245 -0
- package/scripts/generate-password-hash.mjs +25 -0
- package/scripts/release-orchestrator.mjs +363 -0
- package/scripts/scaffold-case.mjs +460 -0
- package/scripts/scaffold-consumer.mjs +87 -0
- package/scripts/smoke-homeview.mjs +133 -0
- package/scripts/smoke-initializer.mjs +129 -0
- package/scripts/smoke-packed.mjs +184 -0
- package/scripts/switch-valence-source.mjs +162 -0
- package/scripts/sync-template-shared.mjs +136 -0
- package/scripts/terminal-ui.mjs +274 -0
- package/scripts/verify-integration.mjs +72 -0
- package/src/App.js +10015 -0
- package/src/header-visibility.contract.js +31 -0
- package/src/header-visibility.contract.test.js +41 -0
- package/src/main.js +12 -0
- package/src/parser/markdown.js +855 -0
- package/src/style.css +88 -0
- package/templates/README.md +242 -0
- package/templates/configs/i18n/i18n.config.js +230 -0
- package/templates/configs/i18n/i18n.labels.js +188 -0
- package/templates/configs/portfoliable.a11y.config.js +262 -0
- package/templates/configs/portfoliable.design.config.js +651 -0
- package/templates/gitignore +9 -0
- package/templates/index.html +20 -0
- package/templates/public/api/.htaccess +4 -0
- package/templates/public/api/README.md +27 -0
- package/templates/public/api/password.config.example.json +16 -0
- package/templates/public/api/password.config.json +16 -0
- package/templates/public/api/unlock-case.php +56 -0
- package/templates/public/favicon.ico +0 -0
- package/templates/public/favicon.png +0 -0
- package/templates/public/portfoliable-logo.svg +3 -0
- package/templates/scripts/add-language.mjs +253 -0
- package/templates/scripts/convert-video-to-mp4.mjs +207 -0
- package/templates/scripts/convert-wav-to-mp3.mjs +184 -0
- package/templates/scripts/delete-case.mjs +320 -0
- package/templates/scripts/delete-language.mjs +195 -0
- package/templates/scripts/generate-password-hash.mjs +98 -0
- package/templates/scripts/i18n-config-utils.mjs +123 -0
- package/templates/scripts/scaffold-case.mjs +444 -0
- package/templates/scripts/sync-locales.mjs +1048 -0
- package/templates/scripts/validate-content.mjs +257 -0
- package/templates/scripts/validate-protection.mjs +187 -0
- package/templates/src/cases/index.js +188 -0
- package/templates/src/content/about/ABOUTME.md +202 -0
- package/templates/src/content/cases/template-case/case.md +479 -0
- package/templates/src/content/cases/template-case/cover.png +0 -0
- package/templates/src/content/cases/template-case/he-cover.png +0 -0
- package/templates/src/content/cases/template-case/template-audio.mp3 +0 -0
- package/templates/src/content/cases/template-case/template-video.mp4 +0 -0
- package/templates/src/content/thumbnail-options.generated.json +1044 -0
- package/templates/src/main.js +23 -0
- package/templates/src/parser/markdown.js +805 -0
- package/vite.config.js +111 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// File: scripts/scaffold-case.mjs
|
|
3
|
+
// Purpose: Generate a starter markdown case for a Portfoliable consumer app.
|
|
4
|
+
// Author: Lio Schimanko
|
|
5
|
+
|
|
6
|
+
// MARK: IMPORTS
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from '../templates/scripts/i18n-config-utils.mjs';
|
|
10
|
+
|
|
11
|
+
// MARK: DEFAULTS
|
|
12
|
+
// Defines default output path used when no known case root can be resolved.
|
|
13
|
+
const DEFAULT_OUTPUT = 'src/content/cases/my-case/case.md';
|
|
14
|
+
// Defines default case name used when no name is provided.
|
|
15
|
+
const DEFAULT_NAME = 'My Case';
|
|
16
|
+
// Defines candidate case roots in priority order for local repo and consumer apps.
|
|
17
|
+
const DEFAULT_CASES_ROOTS = ['templates/src/content/cases', 'src/content/cases'];
|
|
18
|
+
|
|
19
|
+
// MARK: DEFAULT CASE TEMPLATE
|
|
20
|
+
// Centralized defaults used when scaffolding new cases.
|
|
21
|
+
const DEFAULT_CASE_TEMPLATE = {
|
|
22
|
+
caseOrder: 100,
|
|
23
|
+
socialImage: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=1200&h=675&auto=format&fit=contain&bg=111111',
|
|
24
|
+
titleShow: true,
|
|
25
|
+
shortDescShow: true,
|
|
26
|
+
shortDescByLocale: {
|
|
27
|
+
en: 'Write a short summary for this case.',
|
|
28
|
+
pt: 'Escreva um resumo curto para este case.',
|
|
29
|
+
default: 'Write a short summary for this case.'
|
|
30
|
+
},
|
|
31
|
+
readTimeByLocale: {
|
|
32
|
+
default: '3 min'
|
|
33
|
+
},
|
|
34
|
+
kickerByLocale: {
|
|
35
|
+
en: '2026 - Starter Project',
|
|
36
|
+
pt: '2026 - Projeto Iniciador',
|
|
37
|
+
default: '2026 - Starter Project'
|
|
38
|
+
},
|
|
39
|
+
thumbSrc: 'https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=1200&auto=format&fit=crop',
|
|
40
|
+
thumbCategory: 'mobile',
|
|
41
|
+
thumbBrand: 'apple',
|
|
42
|
+
thumbModel: 'Apple iPhone 17',
|
|
43
|
+
thumbColor: 'iPhone 17 - White - Portrait',
|
|
44
|
+
showCover: true,
|
|
45
|
+
showSummary: true,
|
|
46
|
+
showReader: true,
|
|
47
|
+
showToc: true,
|
|
48
|
+
showNavigator: true,
|
|
49
|
+
showPlayer: true,
|
|
50
|
+
summaryByLocale: {
|
|
51
|
+
en: 'Add an executive summary for this case.',
|
|
52
|
+
pt: 'Adicione um resumo executivo para este case.',
|
|
53
|
+
default: 'Add an executive summary for this case.'
|
|
54
|
+
},
|
|
55
|
+
summaryPropsTextByLocale: {
|
|
56
|
+
en: 'Add concise impact details, launch scope, and operational confidence outcomes.',
|
|
57
|
+
pt: 'Adicione detalhes de impacto, escopo de lancamento e resultados de confianca operacional.',
|
|
58
|
+
default: 'Add concise impact details, launch scope, and operational confidence outcomes.'
|
|
59
|
+
},
|
|
60
|
+
summaryPropsLabelHeaderByLocale: {
|
|
61
|
+
en: 'Case Snapshot',
|
|
62
|
+
pt: 'Snapshot do Case',
|
|
63
|
+
default: 'Case Snapshot'
|
|
64
|
+
},
|
|
65
|
+
summaryPropsAriaLabelByLocale: {
|
|
66
|
+
en: 'Case summary with key metrics',
|
|
67
|
+
pt: 'Resumo do case com metricas-chave',
|
|
68
|
+
default: 'Case summary with key metrics'
|
|
69
|
+
},
|
|
70
|
+
audioLabelByLocale: {
|
|
71
|
+
en: 'Case Audio Summary',
|
|
72
|
+
pt: 'Resumo em Audio do Case',
|
|
73
|
+
default: 'Case Audio Summary'
|
|
74
|
+
},
|
|
75
|
+
customButtonPrimaryLabelByLocale: {
|
|
76
|
+
en: 'GitHub',
|
|
77
|
+
pt: 'GitHub',
|
|
78
|
+
default: 'GitHub'
|
|
79
|
+
},
|
|
80
|
+
customButtonPrimaryTooltipByLocale: {
|
|
81
|
+
en: 'Open GitHub profile',
|
|
82
|
+
pt: 'Abrir perfil do GitHub',
|
|
83
|
+
default: 'Open GitHub profile'
|
|
84
|
+
},
|
|
85
|
+
customButtonPrimaryUrl: 'https://github.com/your-handle',
|
|
86
|
+
customButtonPrimaryVariant: 'primary',
|
|
87
|
+
customButtonPrimaryIcon: 'github',
|
|
88
|
+
customButtonPrimaryIconVariant: 'outline',
|
|
89
|
+
customButtonPrimaryEnabled: true,
|
|
90
|
+
visibility: {
|
|
91
|
+
web: true,
|
|
92
|
+
crawlers: true,
|
|
93
|
+
ai: true,
|
|
94
|
+
locales: {}
|
|
95
|
+
},
|
|
96
|
+
isProtected: false,
|
|
97
|
+
isUnlocked: false,
|
|
98
|
+
social: {
|
|
99
|
+
share: { enabled: true, icon: 'share', 'icon-variant': 'outline' },
|
|
100
|
+
linkedin: { enabled: true, icon: 'linkedin', 'icon-variant': 'outline' },
|
|
101
|
+
x: { enabled: true, icon: 'x', 'icon-variant': 'outline' },
|
|
102
|
+
facebook: { enabled: true, icon: 'facebook', 'icon-variant': 'outline' }
|
|
103
|
+
},
|
|
104
|
+
actions: {
|
|
105
|
+
primary: { enabled: false, variant: 'primary', icon: 'play', 'icon-variant': 'fill' },
|
|
106
|
+
secondary: { enabled: false, variant: 'secondary', icon: 'link', 'icon-variant': 'outline' },
|
|
107
|
+
tertiary: { enabled: false, variant: 'tertiary' }
|
|
108
|
+
},
|
|
109
|
+
customButtons: []
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Convert user input into a URL-safe slug.
|
|
113
|
+
function toSlug(value) {
|
|
114
|
+
return String(value || '')
|
|
115
|
+
.trim()
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
118
|
+
.replace(/^-+|-+$/g, '') || 'my-case';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Normalize a freeform case name into title case.
|
|
122
|
+
function toTitle(value) {
|
|
123
|
+
// Cleans separators and duplicate whitespace in user-provided title text.
|
|
124
|
+
const cleaned = String(value || '')
|
|
125
|
+
.trim()
|
|
126
|
+
.replace(/[-_]+/g, ' ')
|
|
127
|
+
.replace(/\s+/g, ' ');
|
|
128
|
+
|
|
129
|
+
if (!cleaned) return DEFAULT_NAME;
|
|
130
|
+
|
|
131
|
+
return cleaned
|
|
132
|
+
.split(' ')
|
|
133
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
134
|
+
.join(' ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MARK: ARGUMENT PARSING
|
|
138
|
+
// Read CLI flags from the current process argv.
|
|
139
|
+
function parseArgs(argv) {
|
|
140
|
+
// Extracts script args excluding node executable and script path.
|
|
141
|
+
const args = argv.slice(2);
|
|
142
|
+
// Initializes parser output with defaults.
|
|
143
|
+
const options = {
|
|
144
|
+
outFile: '',
|
|
145
|
+
name: DEFAULT_NAME,
|
|
146
|
+
force: false
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
150
|
+
// Reads current argument token under evaluation.
|
|
151
|
+
const arg = args[i];
|
|
152
|
+
|
|
153
|
+
if (arg === '--out' && args[i + 1]) {
|
|
154
|
+
options.outFile = args[i + 1];
|
|
155
|
+
i += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (arg === '--name' && args[i + 1]) {
|
|
160
|
+
options.name = args[i + 1];
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (arg === '--force') {
|
|
166
|
+
options.force = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return options;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Resolves available case roots for template and consumer layouts.
|
|
174
|
+
function resolveCasesRoots(cwd) {
|
|
175
|
+
return DEFAULT_CASES_ROOTS
|
|
176
|
+
.map((relativeRoot) => path.resolve(cwd, relativeRoot))
|
|
177
|
+
.filter((absoluteRoot) => fs.existsSync(absoluteRoot) && fs.statSync(absoluteRoot).isDirectory());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Computes default output markdown path from first discovered cases root.
|
|
181
|
+
function resolveDefaultOutFile(cwd, name) {
|
|
182
|
+
const caseSlug = toSlug(name);
|
|
183
|
+
const roots = resolveCasesRoots(cwd);
|
|
184
|
+
if (roots.length > 0) {
|
|
185
|
+
const target = path.join(roots[0], caseSlug, 'case.md');
|
|
186
|
+
return path.relative(cwd, target);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return DEFAULT_OUTPUT;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Derives case slug from output path patterns or fallback case name.
|
|
193
|
+
function resolveCaseSlugFromOutput(outFile, name) {
|
|
194
|
+
const basename = path.basename(outFile || '');
|
|
195
|
+
if (basename.toLowerCase() === 'case.md') {
|
|
196
|
+
return toSlug(path.basename(path.dirname(outFile || '')) || name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return toSlug(path.basename(outFile || '', '.md') || name);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Serializes one config field while preserving indentation for multiline values.
|
|
203
|
+
function formatConfigField(key, value, indent = ' ') {
|
|
204
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
205
|
+
if (!serialized.includes('\n')) {
|
|
206
|
+
return `${indent}"${key}": ${serialized},`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const lines = serialized.split('\n');
|
|
210
|
+
const output = [`${indent}"${key}": ${lines[0]}`];
|
|
211
|
+
lines.slice(1).forEach((line) => {
|
|
212
|
+
output.push(`${indent}${line}`);
|
|
213
|
+
});
|
|
214
|
+
output[output.length - 1] = `${output[output.length - 1]},`;
|
|
215
|
+
return output.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Builds grouped config comment block injected at the top of scaffolded case markdown.
|
|
219
|
+
function formatCaseConfigBlock(caseConfig) {
|
|
220
|
+
const lines = [
|
|
221
|
+
'<!-- config',
|
|
222
|
+
'{',
|
|
223
|
+
' // Identity',
|
|
224
|
+
formatConfigField('id', caseConfig.id),
|
|
225
|
+
formatConfigField('caseOrder', caseConfig.caseOrder),
|
|
226
|
+
formatConfigField('slugByLocale', caseConfig.slugByLocale),
|
|
227
|
+
formatConfigField('socialImage', caseConfig.socialImage),
|
|
228
|
+
'',
|
|
229
|
+
' // Localized Metadata',
|
|
230
|
+
formatConfigField('title', caseConfig.title),
|
|
231
|
+
formatConfigField('shortDesc', caseConfig.shortDesc),
|
|
232
|
+
formatConfigField('readTime', caseConfig.readTime),
|
|
233
|
+
formatConfigField('kicker', caseConfig.kicker),
|
|
234
|
+
'',
|
|
235
|
+
' // Thumbnail and Cover',
|
|
236
|
+
' // Available thumbCategory values: desktop, mobile, tablet, wearable',
|
|
237
|
+
formatConfigField('thumbSrc', caseConfig.thumbSrc),
|
|
238
|
+
formatConfigField('thumbCategory', caseConfig.thumbCategory),
|
|
239
|
+
formatConfigField('thumbBrand', caseConfig.thumbBrand),
|
|
240
|
+
formatConfigField('thumbModel', caseConfig.thumbModel),
|
|
241
|
+
formatConfigField('thumbColor', caseConfig.thumbColor),
|
|
242
|
+
formatConfigField('showCover', caseConfig.showCover),
|
|
243
|
+
'',
|
|
244
|
+
' // Summary Experience (edit here or in summary:start blocks below)',
|
|
245
|
+
formatConfigField('showSummary', caseConfig.showSummary),
|
|
246
|
+
formatConfigField('summary', caseConfig.summary),
|
|
247
|
+
formatConfigField('summaryProps', caseConfig.summaryProps),
|
|
248
|
+
'',
|
|
249
|
+
' // Reader Experience',
|
|
250
|
+
formatConfigField('showReader', caseConfig.showReader),
|
|
251
|
+
formatConfigField('showToc', caseConfig.showToc),
|
|
252
|
+
formatConfigField('showNavigator', caseConfig.showNavigator),
|
|
253
|
+
'',
|
|
254
|
+
' // Audio Experience',
|
|
255
|
+
formatConfigField('audioLabel', caseConfig.audioLabel),
|
|
256
|
+
formatConfigField('audioSrc', caseConfig.audioSrc),
|
|
257
|
+
formatConfigField('showPlayer', caseConfig.showPlayer),
|
|
258
|
+
'',
|
|
259
|
+
' // Visibility and Protection',
|
|
260
|
+
formatConfigField('visibility', caseConfig.visibility),
|
|
261
|
+
formatConfigField('isProtected', caseConfig.isProtected),
|
|
262
|
+
formatConfigField('isUnlocked', caseConfig.isUnlocked),
|
|
263
|
+
'',
|
|
264
|
+
' // Social',
|
|
265
|
+
formatConfigField('social', caseConfig.social),
|
|
266
|
+
'',
|
|
267
|
+
' // Actions',
|
|
268
|
+
formatConfigField('actions', caseConfig.actions),
|
|
269
|
+
'',
|
|
270
|
+
' // Custom Buttons',
|
|
271
|
+
formatConfigField('customButtons', caseConfig.customButtons),
|
|
272
|
+
'}',
|
|
273
|
+
'-->'
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
return lines.join('\n');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Build the markdown scaffold body used for new cases.
|
|
280
|
+
function buildTemplate({ name, slug, localeCodes }) {
|
|
281
|
+
// Derives normalized title for localized title fields.
|
|
282
|
+
const title = toTitle(name);
|
|
283
|
+
// Derives normalized slug for id and localized route slug fields.
|
|
284
|
+
const cleanSlug = toSlug(slug || name);
|
|
285
|
+
// Builds localized maps with locale-specific values and default fallback support.
|
|
286
|
+
const localizedValue = (defaultsByLocale = {}) => {
|
|
287
|
+
return Object.fromEntries(localeCodes.map((localeCode) => {
|
|
288
|
+
const value = defaultsByLocale[localeCode] ?? defaultsByLocale.default ?? '';
|
|
289
|
+
return [localeCode, value];
|
|
290
|
+
}));
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const caseConfig = {
|
|
294
|
+
id: cleanSlug,
|
|
295
|
+
caseOrder: DEFAULT_CASE_TEMPLATE.caseOrder,
|
|
296
|
+
slugByLocale: localizedValue({ default: cleanSlug }),
|
|
297
|
+
socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
|
|
298
|
+
title: {
|
|
299
|
+
show: DEFAULT_CASE_TEMPLATE.titleShow,
|
|
300
|
+
...localizedValue({ default: title })
|
|
301
|
+
},
|
|
302
|
+
shortDesc: {
|
|
303
|
+
show: DEFAULT_CASE_TEMPLATE.shortDescShow,
|
|
304
|
+
...localizedValue(DEFAULT_CASE_TEMPLATE.shortDescByLocale)
|
|
305
|
+
},
|
|
306
|
+
readTime: localizedValue(DEFAULT_CASE_TEMPLATE.readTimeByLocale),
|
|
307
|
+
kicker: localizedValue(DEFAULT_CASE_TEMPLATE.kickerByLocale),
|
|
308
|
+
thumbSrc: localizedValue({ default: DEFAULT_CASE_TEMPLATE.thumbSrc }),
|
|
309
|
+
thumbCategory: DEFAULT_CASE_TEMPLATE.thumbCategory,
|
|
310
|
+
thumbBrand: DEFAULT_CASE_TEMPLATE.thumbBrand,
|
|
311
|
+
thumbModel: DEFAULT_CASE_TEMPLATE.thumbModel,
|
|
312
|
+
thumbColor: DEFAULT_CASE_TEMPLATE.thumbColor,
|
|
313
|
+
showCover: DEFAULT_CASE_TEMPLATE.showCover,
|
|
314
|
+
showSummary: DEFAULT_CASE_TEMPLATE.showSummary,
|
|
315
|
+
summary: localizedValue(DEFAULT_CASE_TEMPLATE.summaryByLocale),
|
|
316
|
+
summaryProps: {
|
|
317
|
+
text: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsTextByLocale),
|
|
318
|
+
active: true,
|
|
319
|
+
labelHeader: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsLabelHeaderByLocale),
|
|
320
|
+
showMetrics: true,
|
|
321
|
+
ariaLabel: localizedValue(DEFAULT_CASE_TEMPLATE.summaryPropsAriaLabelByLocale),
|
|
322
|
+
metrics: [
|
|
323
|
+
{
|
|
324
|
+
value: '',
|
|
325
|
+
label: localizedValue({ default: '' }),
|
|
326
|
+
variant: 'default',
|
|
327
|
+
ariaLabel: localizedValue({ default: '' })
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
},
|
|
331
|
+
showReader: DEFAULT_CASE_TEMPLATE.showReader,
|
|
332
|
+
showToc: DEFAULT_CASE_TEMPLATE.showToc,
|
|
333
|
+
showNavigator: DEFAULT_CASE_TEMPLATE.showNavigator,
|
|
334
|
+
audioLabel: localizedValue(DEFAULT_CASE_TEMPLATE.audioLabelByLocale),
|
|
335
|
+
audioSrc: localizedValue({ default: '' }),
|
|
336
|
+
showPlayer: DEFAULT_CASE_TEMPLATE.showPlayer,
|
|
337
|
+
visibility: { ...DEFAULT_CASE_TEMPLATE.visibility },
|
|
338
|
+
isProtected: DEFAULT_CASE_TEMPLATE.isProtected,
|
|
339
|
+
isUnlocked: DEFAULT_CASE_TEMPLATE.isUnlocked,
|
|
340
|
+
social: {
|
|
341
|
+
share: {
|
|
342
|
+
...DEFAULT_CASE_TEMPLATE.social.share,
|
|
343
|
+
tooltip: localizedValue({ default: '' })
|
|
344
|
+
},
|
|
345
|
+
linkedin: {
|
|
346
|
+
...DEFAULT_CASE_TEMPLATE.social.linkedin,
|
|
347
|
+
tooltip: localizedValue({ default: '' })
|
|
348
|
+
},
|
|
349
|
+
x: {
|
|
350
|
+
...DEFAULT_CASE_TEMPLATE.social.x,
|
|
351
|
+
tooltip: localizedValue({ default: '' })
|
|
352
|
+
},
|
|
353
|
+
facebook: {
|
|
354
|
+
...DEFAULT_CASE_TEMPLATE.social.facebook,
|
|
355
|
+
tooltip: localizedValue({ default: '' })
|
|
356
|
+
},
|
|
357
|
+
links: {
|
|
358
|
+
linkedin: '',
|
|
359
|
+
x: '',
|
|
360
|
+
facebook: ''
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
actions: {
|
|
364
|
+
primary: {
|
|
365
|
+
...DEFAULT_CASE_TEMPLATE.actions.primary,
|
|
366
|
+
label: localizedValue({ en: 'Watch Launch Video', pt: 'Assistir Video do Lancamento', default: 'Watch Launch Video' }),
|
|
367
|
+
tooltip: localizedValue({ en: 'Open launch walkthrough video', pt: 'Abrir video de apresentacao do lancamento', default: 'Open launch walkthrough video' }),
|
|
368
|
+
url: localizedValue({ default: '' }),
|
|
369
|
+
videoSrc: localizedValue({ default: '' }),
|
|
370
|
+
vttSrc: localizedValue({ default: '' }),
|
|
371
|
+
imageAlt: localizedValue({ default: '' }),
|
|
372
|
+
ariaLabel: localizedValue({ default: '' })
|
|
373
|
+
},
|
|
374
|
+
secondary: {
|
|
375
|
+
...DEFAULT_CASE_TEMPLATE.actions.secondary,
|
|
376
|
+
label: localizedValue({ en: 'Repository', pt: 'Repositorio', default: 'Repository' }),
|
|
377
|
+
tooltip: localizedValue({ en: 'Open implementation repository', pt: 'Abrir repositorio de implementacao', default: 'Open implementation repository' }),
|
|
378
|
+
url: localizedValue({ default: '' }),
|
|
379
|
+
imageAlt: localizedValue({ default: '' }),
|
|
380
|
+
ariaLabel: localizedValue({ default: '' })
|
|
381
|
+
},
|
|
382
|
+
tertiary: {
|
|
383
|
+
...DEFAULT_CASE_TEMPLATE.actions.tertiary,
|
|
384
|
+
label: localizedValue({ en: 'Live Demo', pt: 'Demo Ao Vivo', default: 'Live Demo' }),
|
|
385
|
+
tooltip: localizedValue({ en: 'Open interactive demo', pt: 'Abrir demo interativa', default: 'Open interactive demo' }),
|
|
386
|
+
url: localizedValue({ default: '' }),
|
|
387
|
+
'has-image': false,
|
|
388
|
+
'image-src': '',
|
|
389
|
+
'image-alt': '',
|
|
390
|
+
imageAlt: localizedValue({ default: '' }),
|
|
391
|
+
ariaLabel: localizedValue({ default: '' })
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
customButtons: [
|
|
395
|
+
{
|
|
396
|
+
label: localizedValue(DEFAULT_CASE_TEMPLATE.customButtonPrimaryLabelByLocale),
|
|
397
|
+
tooltip: localizedValue(DEFAULT_CASE_TEMPLATE.customButtonPrimaryTooltipByLocale),
|
|
398
|
+
url: DEFAULT_CASE_TEMPLATE.customButtonPrimaryUrl,
|
|
399
|
+
variant: DEFAULT_CASE_TEMPLATE.customButtonPrimaryVariant,
|
|
400
|
+
icon: DEFAULT_CASE_TEMPLATE.customButtonPrimaryIcon,
|
|
401
|
+
'icon-variant': DEFAULT_CASE_TEMPLATE.customButtonPrimaryIconVariant,
|
|
402
|
+
enabled: DEFAULT_CASE_TEMPLATE.customButtonPrimaryEnabled,
|
|
403
|
+
imageAlt: localizedValue({ default: '' }),
|
|
404
|
+
ariaLabel: localizedValue({ default: '' })
|
|
405
|
+
}
|
|
406
|
+
]
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const sectionBlock = localeCodes.map((localeCode) => {
|
|
410
|
+
if (localeCode === 'pt') {
|
|
411
|
+
return `<!-- lang:${localeCode} -->\n## Problema\nDescreva o problema em portugues.\n\n## Solucao\nDescreva a solucao em portugues.`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return `<!-- lang:${localeCode} -->\n## Problem\nDescribe the problem in ${localeCode}.\n\n## Solution\nDescribe the solution in ${localeCode}.`;
|
|
415
|
+
}).join('\n\n');
|
|
416
|
+
|
|
417
|
+
return `${formatCaseConfigBlock(caseConfig)}\n\n${sectionBlock}\n`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Create the scaffold case file unless the destination already exists.
|
|
421
|
+
export function runCaseScaffold(options = {}) {
|
|
422
|
+
// Resolves working directory used for output file path calculation.
|
|
423
|
+
const cwd = options.cwd || process.cwd();
|
|
424
|
+
// Resolves output filename using option or context-aware default fallback.
|
|
425
|
+
const outFile = options.outFile || resolveDefaultOutFile(cwd, options.name || DEFAULT_NAME);
|
|
426
|
+
// Resolves case display name using option or default fallback.
|
|
427
|
+
const name = options.name || DEFAULT_NAME;
|
|
428
|
+
const caseSlug = resolveCaseSlugFromOutput(outFile, name);
|
|
429
|
+
// Coerces force flag to explicit boolean.
|
|
430
|
+
const force = Boolean(options.force);
|
|
431
|
+
// Resolves absolute output path for scaffold file.
|
|
432
|
+
const outputPath = path.resolve(cwd, outFile);
|
|
433
|
+
const i18nConfigPath = resolveI18nConfigPath(cwd);
|
|
434
|
+
const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
|
|
435
|
+
const templateLocaleFallback = Object.keys(DEFAULT_CASE_TEMPLATE.shortDescByLocale || {})
|
|
436
|
+
.filter((localeCode) => localeCode !== 'default');
|
|
437
|
+
const localeCodes = Array.isArray(localeConfig.supportedLocales) && localeConfig.supportedLocales.length > 0
|
|
438
|
+
? localeConfig.supportedLocales
|
|
439
|
+
: templateLocaleFallback;
|
|
440
|
+
|
|
441
|
+
if (fs.existsSync(outputPath) && !force) {
|
|
442
|
+
console.log(`[scaffold-case] Nothing to create. Case already exists: ${outputPath}`);
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
447
|
+
fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, localeCodes }), 'utf8');
|
|
448
|
+
console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// MARK: SCRIPT ENTRYPOINT
|
|
453
|
+
// Executes argument parsing and scaffold run when script is invoked directly.
|
|
454
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
455
|
+
// Parses CLI options from current process arguments.
|
|
456
|
+
const options = parseArgs(process.argv);
|
|
457
|
+
// Runs scaffolder and captures process exit code.
|
|
458
|
+
const exitCode = runCaseScaffold(options);
|
|
459
|
+
process.exit(exitCode);
|
|
460
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// File: scripts/scaffold-consumer.mjs
|
|
3
|
+
// Purpose: Generate a starter consumer cases file for Portfoliable projects.
|
|
4
|
+
// Author: Lio Schimanko
|
|
5
|
+
|
|
6
|
+
// MARK: IMPORTS
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
// MARK: DEFAULTS
|
|
11
|
+
// Defines default output filename for scaffolded consumer cases module.
|
|
12
|
+
const DEFAULT_OUTPUT = 'portfolio-cases.template.js';
|
|
13
|
+
|
|
14
|
+
// Defines full starter template content for scaffolded consumer cases module.
|
|
15
|
+
const TEMPLATE_CONTENT = `export const portfolioCases = [
|
|
16
|
+
{
|
|
17
|
+
id: 'my-first-case',
|
|
18
|
+
slugByLocale: { en: 'my-first-case', pt: 'meu-primeiro-case' },
|
|
19
|
+
title: { en: 'My First Case', pt: 'Meu Primeiro Case' },
|
|
20
|
+
shortDesc: {
|
|
21
|
+
en: 'Describe the project in one sentence.',
|
|
22
|
+
pt: 'Descreva o projeto em uma frase.'
|
|
23
|
+
},
|
|
24
|
+
readTime: { en: '3 min', pt: '3 min' },
|
|
25
|
+
year: { en: '2026 - Personal Project', pt: '2026 - Projeto Pessoal' },
|
|
26
|
+
thumbSrc: {
|
|
27
|
+
en: 'assets/thumbs/en/my-first-case.avif',
|
|
28
|
+
pt: 'assets/thumbs/pt/my-first-case.avif'
|
|
29
|
+
},
|
|
30
|
+
repositoryUrl: 'https://github.com/your-user/your-repo',
|
|
31
|
+
liveUrl: 'https://example.com',
|
|
32
|
+
videoSrc: {
|
|
33
|
+
en: 'assets/videos/en/my-first-case.mp4',
|
|
34
|
+
pt: 'assets/videos/pt/my-first-case.mp4'
|
|
35
|
+
},
|
|
36
|
+
vttSrc: {
|
|
37
|
+
en: 'assets/subtitles/en/my-first-case.vtt',
|
|
38
|
+
pt: 'assets/subtitles/pt/my-first-case.vtt'
|
|
39
|
+
},
|
|
40
|
+
desc: {
|
|
41
|
+
en: '<p class="p1">Write your story here in English.</p>',
|
|
42
|
+
pt: '<p class="p1">Escreva sua historia aqui em portugues.</p>'
|
|
43
|
+
},
|
|
44
|
+
descRecruiter: {
|
|
45
|
+
en: '<p class="p1">Add your technical recruiter summary here.</p>',
|
|
46
|
+
pt: '<p class="p1">Adicione seu resumo tecnico para recrutadores aqui.</p>'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
// Write the scaffold file, refusing to overwrite unless explicitly requested.
|
|
53
|
+
export function runScaffold(options = {}) {
|
|
54
|
+
// Resolves working directory for output path resolution.
|
|
55
|
+
const cwd = options.cwd || process.cwd();
|
|
56
|
+
// Resolves output filename from options or default fallback.
|
|
57
|
+
const outFile = options.outFile || DEFAULT_OUTPUT;
|
|
58
|
+
// Coerces overwrite flag to explicit boolean.
|
|
59
|
+
const force = Boolean(options.force);
|
|
60
|
+
// Resolves absolute output file path.
|
|
61
|
+
const outputPath = path.resolve(cwd, outFile);
|
|
62
|
+
|
|
63
|
+
if (fs.existsSync(outputPath) && !force) {
|
|
64
|
+
console.error(`[scaffold] Refusing to overwrite existing file: ${outputPath}`);
|
|
65
|
+
console.error('[scaffold] Use --force to overwrite.');
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(outputPath, TEMPLATE_CONTENT, 'utf8');
|
|
71
|
+
console.log(`[scaffold] Created starter cases file: ${outputPath}`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: SCRIPT ENTRYPOINT
|
|
76
|
+
// Executes scaffold flow when script is invoked directly.
|
|
77
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
78
|
+
// Resolves index of --out option token in argv.
|
|
79
|
+
const outIndex = process.argv.findIndex((arg) => arg === '--out');
|
|
80
|
+
// Resolves force flag from argv.
|
|
81
|
+
const force = process.argv.includes('--force');
|
|
82
|
+
// Resolves outFile argument value with default fallback.
|
|
83
|
+
const outFile = outIndex >= 0 ? process.argv[outIndex + 1] : DEFAULT_OUTPUT;
|
|
84
|
+
// Executes scaffold and captures resulting exit code.
|
|
85
|
+
const exitCode = runScaffold({ outFile, force });
|
|
86
|
+
process.exit(exitCode);
|
|
87
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// File: create-portfoliable/scripts/smoke-homeview.mjs
|
|
2
|
+
// Purpose: Smoke-test build output to confirm HomeView, sample content, and device assets are emitted.
|
|
3
|
+
// Author: Lio Schimanko
|
|
4
|
+
|
|
5
|
+
// MARK: IMPORTS
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
// MARK: PATH CONSTANTS
|
|
12
|
+
// Resolves the script absolute filename for deterministic project-root path computation.
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
// Resolves the directory containing this script.
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
// Resolves the package root used as the working directory for smoke commands.
|
|
17
|
+
const projectRoot = path.resolve(__dirname, '..');
|
|
18
|
+
// Resolves the build output directory validated by this smoke test.
|
|
19
|
+
const distDir = path.join(projectRoot, 'dist');
|
|
20
|
+
|
|
21
|
+
// MARK: ERROR HANDLING
|
|
22
|
+
// Terminates the smoke test with a consistent prefixed error message.
|
|
23
|
+
function fail(message) {
|
|
24
|
+
console.error(`[smoke] ${message}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// MARK: BUILD STEP
|
|
29
|
+
// Executes a production build before assertions so smoke checks inspect fresh artifacts.
|
|
30
|
+
function runBuild() {
|
|
31
|
+
console.log('[smoke] Running build...');
|
|
32
|
+
// Runs the package build command and forwards process output directly to the terminal.
|
|
33
|
+
const build = spawnSync('npm', ['run', 'build'], {
|
|
34
|
+
cwd: projectRoot,
|
|
35
|
+
stdio: 'inherit',
|
|
36
|
+
shell: process.platform === 'win32'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (build.status !== 0) {
|
|
40
|
+
fail('Build failed.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// MARK: BUNDLE DISCOVERY
|
|
45
|
+
// Locates the newest index bundle produced by Vite and returns its source text for assertions.
|
|
46
|
+
function readMainBundle() {
|
|
47
|
+
// Resolves the generated assets directory that contains hashed JS bundles.
|
|
48
|
+
const assetsDir = path.join(distDir, 'assets');
|
|
49
|
+
if (!fs.existsSync(assetsDir)) {
|
|
50
|
+
fail('Missing dist/assets after build.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Reads all generated asset filenames.
|
|
54
|
+
const files = fs.readdirSync(assetsDir);
|
|
55
|
+
// Filters for the main app bundle naming convention emitted by Vite.
|
|
56
|
+
const jsBundles = files.filter((file) => file.startsWith('index-') && file.endsWith('.js'));
|
|
57
|
+
|
|
58
|
+
if (jsBundles.length === 0) {
|
|
59
|
+
fail('Could not find main JS bundle in dist/assets.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Selects the most recently modified main bundle in case multiple artifacts are present.
|
|
63
|
+
const newestBundle = jsBundles
|
|
64
|
+
.map((file) => ({
|
|
65
|
+
file,
|
|
66
|
+
mtimeMs: fs.statSync(path.join(assetsDir, file)).mtimeMs
|
|
67
|
+
}))
|
|
68
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].file;
|
|
69
|
+
|
|
70
|
+
// Loads bundle text so feature markers can be validated with string assertions.
|
|
71
|
+
const bundlePath = path.join(assetsDir, newestBundle);
|
|
72
|
+
return fs.readFileSync(bundlePath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: ASSERTION HELPER
|
|
76
|
+
// Verifies a marker string exists in the tested output and aborts with a clear reason if missing.
|
|
77
|
+
function assertIncludes(haystack, needle, message) {
|
|
78
|
+
if (!haystack.includes(needle)) {
|
|
79
|
+
fail(message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: SMOKE ORCHESTRATION
|
|
84
|
+
// Runs build, inspects emitted bundle/assets, and validates HomeView/CaseView route expectations.
|
|
85
|
+
function main() {
|
|
86
|
+
runBuild();
|
|
87
|
+
|
|
88
|
+
if (!fs.existsSync(path.join(distDir, 'index.html'))) {
|
|
89
|
+
fail('dist/index.html not found.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Loads emitted main bundle text for marker assertions.
|
|
93
|
+
const bundleText = readMainBundle();
|
|
94
|
+
|
|
95
|
+
// HomeView template is mounted through ds-home-view.
|
|
96
|
+
assertIncludes(bundleText, 'ds-home-view', 'HomeView marker not found in bundle.');
|
|
97
|
+
|
|
98
|
+
// CaseView template must exist for in-app Home -> Case transitions.
|
|
99
|
+
assertIncludes(bundleText, 'ds-case-view', 'CaseView marker not found in bundle.');
|
|
100
|
+
|
|
101
|
+
// Route transition helpers should be present in the runtime shell bundle.
|
|
102
|
+
assertIncludes(bundleText, '_transitionToView', 'Route transition helper not found in bundle.');
|
|
103
|
+
assertIncludes(bundleText, 'data-route-direction', 'Route direction marker not found in bundle.');
|
|
104
|
+
assertIncludes(bundleText, '_isNativeMorphPreferredPair', 'Native morph policy helper not found in bundle.');
|
|
105
|
+
assertIncludes(bundleText, 'native-running', 'Native transition debug marker not found in bundle.');
|
|
106
|
+
assertIncludes(bundleText, 'manual-fallback', 'Manual fallback debug marker not found in bundle.');
|
|
107
|
+
assertIncludes(bundleText, 'home->player', 'Direct Home -> Player transition marker not found in bundle.');
|
|
108
|
+
assertIncludes(bundleText, 'player->home', 'Direct Player -> Home transition marker not found in bundle.');
|
|
109
|
+
|
|
110
|
+
// Template case metadata markers should survive bundling regardless of sample title wording.
|
|
111
|
+
assertIncludes(bundleText, 'thumbCategory', 'Template case metadata marker not found in bundle.');
|
|
112
|
+
assertIncludes(bundleText, 'thumbBrand', 'Template case metadata brand marker not found in bundle.');
|
|
113
|
+
|
|
114
|
+
// Thumbnail frame fallback asset should be part of build outputs.
|
|
115
|
+
const hasDeviceFrameAsset = fs
|
|
116
|
+
.readdirSync(path.join(distDir, 'assets'))
|
|
117
|
+
.some((file) => {
|
|
118
|
+
if (!file.endsWith('.avif')) return false;
|
|
119
|
+
const normalized = file.toLowerCase();
|
|
120
|
+
return (
|
|
121
|
+
normalized.startsWith('iphone-12-black-') ||
|
|
122
|
+
(normalized.includes('iphone') && normalized.includes('12') && normalized.includes('black'))
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!hasDeviceFrameAsset) {
|
|
127
|
+
fail('Expected thumbnail device frame asset was not generated.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('[smoke] HomeView and gallery smoke checks passed.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main();
|