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,836 @@
1
+ #!/usr/bin/env node
2
+ // File: cli/portfoliable.mjs
3
+ // Purpose: Main Portfoliable CLI for development, build, preview, validation, and scaffolding.
4
+ // Author: Lio Schimanko
5
+
6
+ // MARK: IMPORTS
7
+ import { createServer, build as viteBuild, preview as vitePreview } from 'vite';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import fs from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { runValidation } from '../templates/scripts/validate-content.mjs';
14
+ import { runCaseScaffold } from '../templates/scripts/scaffold-case.mjs';
15
+ import { runDeleteCase } from '../templates/scripts/delete-case.mjs';
16
+ import { ensureValenceCompatibility } from '../scripts/ensure-valence-index-css.mjs';
17
+ import { runLocaleSync, watchLocaleSync } from '../templates/scripts/sync-locales.mjs';
18
+ import { runAddLanguage } from '../templates/scripts/add-language.mjs';
19
+ import { runDeleteLanguage } from '../templates/scripts/delete-language.mjs';
20
+ import {
21
+ ui,
22
+ printRailSegment,
23
+ printRailAttachedBox,
24
+ printRailSpacer,
25
+ promptYesNoDots,
26
+ isInteractiveTerminal,
27
+ readProjectUiPreferences
28
+ } from '../scripts/terminal-ui.mjs';
29
+
30
+ // MARK: TERMINAL STYLES
31
+ // ANSI color code used for success-status text.
32
+ const green = '\x1b[32m';
33
+ // ANSI color code used for warnings and update notices.
34
+ const yellow = '\x1b[33m';
35
+ // ANSI color code used for neutral informational output.
36
+ const cyan = '\x1b[36m';
37
+ // ANSI color code used for bold text emphasis.
38
+ const bold = '\x1b[1m';
39
+ // ANSI color code used for dimmed hint text.
40
+ const dim = '\x1b[2m';
41
+ // ANSI color code used for errors and failures.
42
+ const red = '\x1b[31m';
43
+ // ANSI code that resets terminal formatting.
44
+ const reset = '\x1b[0m';
45
+
46
+ // MARK: VERSION AND NETWORK HELPERS
47
+ // Resolves the current package version from package.json.
48
+ function resolvePackageVersion() {
49
+ // Resolves this file path for package.json lookup.
50
+ const currentFile = fileURLToPath(import.meta.url);
51
+ // Resolves package.json path relative to CLI location.
52
+ const packagePath = path.resolve(path.dirname(currentFile), '..', 'package.json');
53
+ // Reads and parses package metadata.
54
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
55
+ return packageJson.version || '0.0.0';
56
+ }
57
+
58
+ // Finds first non-internal IPv4 address for network URL display.
59
+ function firstNetworkAddress() {
60
+ // Reads system network interfaces.
61
+ const interfaces = os.networkInterfaces();
62
+
63
+ // Iterates interface groups.
64
+ for (const entries of Object.values(interfaces)) {
65
+ if (!entries) continue;
66
+ // Iterates individual addresses in each interface group.
67
+ for (const entry of entries) {
68
+ if (entry.family === 'IPv4' && !entry.internal) {
69
+ return entry.address;
70
+ }
71
+ }
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ // Fetches latest published create-portfoliable version from npm registry.
78
+ async function fetchLatestVersion() {
79
+ try {
80
+ // Calls npm metadata endpoint for latest dist-tag payload.
81
+ const response = await fetch('https://registry.npmjs.org/create-portfoliable/latest');
82
+ if (!response.ok) return null;
83
+
84
+ // Parses registry response payload.
85
+ const data = await response.json();
86
+ return data.version || null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // MARK: CLI ARGUMENT PARSING
93
+ // Parses command and flags from argv into normalized runtime options.
94
+ function parseCliArgs(argv) {
95
+ // Initializes parser defaults for command and supported flags.
96
+ const parsed = {
97
+ command: 'dev',
98
+ flags: {
99
+ host: '0.0.0.0',
100
+ port: null,
101
+ out: null,
102
+ force: false,
103
+ name: null,
104
+ id: null,
105
+ code: null,
106
+ htmlLang: null,
107
+ direction: null,
108
+ open: true,
109
+ json: false,
110
+ full: false,
111
+ watch: false,
112
+ deleteForce: false,
113
+ showCommands: null
114
+ }
115
+ };
116
+
117
+ // Reads possible command token from argv position 2.
118
+ const commandCandidate = argv[2];
119
+ // Defines supported CLI commands.
120
+ const knownCommands = new Set([
121
+ 'dev',
122
+ 'build',
123
+ 'preview',
124
+ 'validate',
125
+ 'sync-locales',
126
+ 'add-language',
127
+ 'delete-language',
128
+ 'delete-case',
129
+ 'create-case',
130
+ 'thumbnail-options'
131
+ ]);
132
+ // Chooses argument start index based on whether explicit command was provided.
133
+ const startIndex = knownCommands.has(commandCandidate) ? 3 : 2;
134
+
135
+ if (knownCommands.has(commandCandidate)) {
136
+ parsed.command = commandCandidate;
137
+ }
138
+
139
+ // Parses all trailing argv tokens for supported flags.
140
+ for (let i = startIndex; i < argv.length; i += 1) {
141
+ // Reads current token under evaluation.
142
+ const arg = argv[i];
143
+ if (arg === '--host') {
144
+ // Reads optional host value token.
145
+ const next = argv[i + 1];
146
+ if (next && !next.startsWith('--')) {
147
+ parsed.flags.host = next;
148
+ i += 1;
149
+ } else {
150
+ parsed.flags.host = '0.0.0.0';
151
+ }
152
+ continue;
153
+ }
154
+
155
+ if (arg === '--port') {
156
+ // Reads optional port value token.
157
+ const next = argv[i + 1];
158
+ if (next) {
159
+ // Converts port token to number for validation.
160
+ const asNumber = Number(next);
161
+ if (Number.isFinite(asNumber)) {
162
+ parsed.flags.port = asNumber;
163
+ i += 1;
164
+ }
165
+ }
166
+ continue;
167
+ }
168
+
169
+ if (arg === '--out') {
170
+ // Reads output-file value used by scaffold commands.
171
+ const next = argv[i + 1];
172
+ if (next) {
173
+ parsed.flags.out = next;
174
+ i += 1;
175
+ }
176
+ continue;
177
+ }
178
+
179
+ if (arg === '--force') {
180
+ parsed.flags.force = true;
181
+ parsed.flags.deleteForce = true;
182
+ continue;
183
+ }
184
+
185
+ if (arg === '--name') {
186
+ // Reads case name value used by scaffold commands.
187
+ const next = argv[i + 1];
188
+ if (next) {
189
+ parsed.flags.name = next;
190
+ i += 1;
191
+ }
192
+ continue;
193
+ }
194
+
195
+ if (arg === '--id') {
196
+ const next = argv[i + 1];
197
+ if (next) {
198
+ parsed.flags.id = next;
199
+ i += 1;
200
+ }
201
+ continue;
202
+ }
203
+
204
+ if (arg === '--code') {
205
+ const next = argv[i + 1];
206
+ if (next) {
207
+ parsed.flags.code = next;
208
+ i += 1;
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (arg === '--html-lang' || arg === '--htmlLang') {
214
+ const next = argv[i + 1];
215
+ if (next) {
216
+ parsed.flags.htmlLang = next;
217
+ i += 1;
218
+ }
219
+ continue;
220
+ }
221
+
222
+ if (arg === '--direction') {
223
+ const next = argv[i + 1];
224
+ if (next) {
225
+ parsed.flags.direction = next;
226
+ i += 1;
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (arg === '--rtl') {
232
+ parsed.flags.direction = 'rtl';
233
+ continue;
234
+ }
235
+
236
+ if (arg === '--ltr') {
237
+ parsed.flags.direction = 'ltr';
238
+ continue;
239
+ }
240
+
241
+ if (arg === '--open') {
242
+ parsed.flags.open = true;
243
+ continue;
244
+ }
245
+
246
+ if (arg === '--no-open') {
247
+ parsed.flags.open = false;
248
+ continue;
249
+ }
250
+
251
+ if (arg === '--commands') {
252
+ parsed.flags.showCommands = true;
253
+ continue;
254
+ }
255
+
256
+ if (arg === '--no-commands') {
257
+ parsed.flags.showCommands = false;
258
+ continue;
259
+ }
260
+
261
+ if (arg === '--json') {
262
+ parsed.flags.json = true;
263
+ continue;
264
+ }
265
+
266
+ if (arg === '--full') {
267
+ parsed.flags.full = true;
268
+ continue;
269
+ }
270
+
271
+ if (arg === '--watch') {
272
+ parsed.flags.watch = true;
273
+ continue;
274
+ }
275
+
276
+ if (!arg.startsWith('--') && parsed.flags.port === null) {
277
+ // Supports positional numeric port shorthand.
278
+ const asNumber = Number(arg);
279
+ if (Number.isFinite(asNumber)) {
280
+ parsed.flags.port = asNumber;
281
+ }
282
+ }
283
+ }
284
+
285
+ return parsed;
286
+ }
287
+
288
+ // MARK: FILESYSTEM HELPERS
289
+ // Recursively walks a directory and returns absolute file paths.
290
+ function walkFilesRecursive(rootDir) {
291
+ // Accumulates discovered files.
292
+ const files = [];
293
+
294
+ // Recursively visits one directory path.
295
+ const visit = (currentDir) => {
296
+ // Reads directory entries with file-type metadata.
297
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
298
+ // Iterates each entry for recursion or file collection.
299
+ for (const entry of entries) {
300
+ // Resolves entry absolute path.
301
+ const absolutePath = path.join(currentDir, entry.name);
302
+ if (entry.isDirectory()) {
303
+ visit(absolutePath);
304
+ continue;
305
+ }
306
+ files.push(absolutePath);
307
+ }
308
+ };
309
+
310
+ visit(rootDir);
311
+ return files;
312
+ }
313
+
314
+ // Escapes regex metacharacters in freeform strings.
315
+ function escapeRegex(input) {
316
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
317
+ }
318
+
319
+ // Derives display color value from filename by stripping known model prefix.
320
+ function deriveColorName(fileNameNoExt, modelName) {
321
+ // Normalizes model text before regex construction.
322
+ const normalizedModel = modelName.trim();
323
+ if (!normalizedModel) return fileNameNoExt;
324
+
325
+ // Builds model-prefix stripping regex for color extraction.
326
+ const modelPattern = new RegExp(`^${escapeRegex(normalizedModel)}[\s_\-—–]*`, 'i');
327
+ // Removes model prefix and trims resulting color suffix.
328
+ const stripped = fileNameNoExt.replace(modelPattern, '').trim();
329
+ return stripped || 'Default';
330
+ }
331
+
332
+ // Builds nested thumbnail catalog from valence mockup asset tree.
333
+ function buildThumbnailCatalog(mockupsRoot) {
334
+ // Initializes catalog object in category->brand->model->colors shape.
335
+ const catalog = {};
336
+ // Matches generic folder names that should be ignored as model names.
337
+ const genericDirPattern = /^(device|device with pencil|device without pencil|device with shadow|device open|device closed|with bands|without bands|open|closed)$/i;
338
+ // Loads all .avif assets from mockup tree.
339
+ const files = walkFilesRecursive(mockupsRoot).filter((filePath) => filePath.toLowerCase().endsWith('.avif'));
340
+
341
+ // Iterates each mockup asset and classifies metadata from path shape.
342
+ for (const filePath of files) {
343
+ // Resolves file path relative to mockup root.
344
+ const relativePath = path.relative(mockupsRoot, filePath);
345
+ // Splits relative path segments for category/brand/model extraction.
346
+ const parts = relativePath.split(path.sep);
347
+ if (parts.length < 3) continue;
348
+
349
+ // Extracts category segment.
350
+ const category = parts[0];
351
+ // Extracts brand segment.
352
+ const brand = parts[1];
353
+ // Extracts filename stem as fallback metadata source.
354
+ const fileNameNoExt = path.basename(parts[parts.length - 1], '.avif');
355
+ // Captures middle folders that may represent model name.
356
+ const middleDirs = parts.slice(2, parts.length - 1);
357
+ // Filters non-generic folder names to construct explicit model name.
358
+ const modelParts = middleDirs.filter((segment) => !genericDirPattern.test(segment));
359
+ // Chooses best-effort model name from folder data or filename fallback.
360
+ const modelName = modelParts.length > 0 ? modelParts.join(' - ') : (middleDirs[0] || fileNameNoExt);
361
+ // Derives color name from filename suffix.
362
+ const colorName = deriveColorName(fileNameNoExt, modelName);
363
+
364
+ if (!catalog[category]) catalog[category] = {};
365
+ if (!catalog[category][brand]) catalog[category][brand] = {};
366
+ if (!catalog[category][brand][modelName]) {
367
+ catalog[category][brand][modelName] = new Set();
368
+ }
369
+
370
+ catalog[category][brand][modelName].add(colorName);
371
+ }
372
+
373
+ return catalog;
374
+ }
375
+
376
+ // Resolves available local mockup catalog directory.
377
+ function resolveMockupsRoot() {
378
+ // Defines candidate catalog paths in app source and installed dependency locations.
379
+ const candidates = [
380
+ path.resolve(process.cwd(), 'src', 'stories', 'assets', 'mockups'),
381
+ path.resolve(process.cwd(), 'node_modules', '@portfoliable', 'valence', 'src', 'stories', 'assets', 'mockups')
382
+ ];
383
+
384
+ return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) || null;
385
+ }
386
+
387
+ // Prints thumbnail catalog in human-readable or JSON format.
388
+ function printThumbnailCatalog(catalog, flags, mockupsRoot) {
389
+ // Computes sorted category list for deterministic output.
390
+ const categoryNames = Object.keys(catalog).sort((a, b) => a.localeCompare(b));
391
+ // Counts total brand nodes for summary metadata.
392
+ const brandCount = categoryNames.reduce((acc, category) => acc + Object.keys(catalog[category]).length, 0);
393
+ // Counts total model nodes for summary metadata.
394
+ const modelCount = categoryNames.reduce((acc, category) => {
395
+ return acc + Object.values(catalog[category]).reduce((modelAcc, modelsByBrand) => modelAcc + Object.keys(modelsByBrand).length, 0);
396
+ }, 0);
397
+ // Counts total variant rows for summary metadata.
398
+ const variantCount = categoryNames.reduce((acc, category) => {
399
+ return acc + Object.values(catalog[category]).reduce((brandAcc, modelsByBrand) => {
400
+ return brandAcc + Object.values(modelsByBrand).reduce((modelAcc, colors) => modelAcc + colors.size, 0);
401
+ }, 0);
402
+ }, 0);
403
+
404
+ const outputPath = writeThumbnailOptionsCatalog(catalog, mockupsRoot);
405
+ console.log(`${green}Wrote thumbnail options catalog${reset}: ${outputPath}`);
406
+ console.log(`${dim}Categories: ${categoryNames.length} | Brands: ${brandCount} | Models: ${modelCount} | Variants: ${variantCount}${reset}`);
407
+ }
408
+
409
+ // Writes a generated thumbnail options file inside the template source tree.
410
+ function writeThumbnailOptionsCatalog(catalog, mockupsRoot) {
411
+ const outputPath = path.resolve(process.cwd(), 'templates', 'src', 'content', 'thumbnail-options.generated.json');
412
+ const items = [];
413
+
414
+ Object.keys(catalog).sort((a, b) => a.localeCompare(b)).forEach((category) => {
415
+ Object.keys(catalog[category]).sort((a, b) => a.localeCompare(b)).forEach((brand) => {
416
+ Object.keys(catalog[category][brand]).sort((a, b) => a.localeCompare(b)).forEach((model) => {
417
+ [...catalog[category][brand][model]].sort((a, b) => a.localeCompare(b)).forEach((color) => {
418
+ items.push({
419
+ thumbCategory: category,
420
+ thumbBrand: brand,
421
+ thumbModel: model,
422
+ thumbColor: color
423
+ });
424
+ });
425
+ });
426
+ });
427
+ });
428
+
429
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
430
+ fs.writeFileSync(outputPath, `${JSON.stringify({ source: mockupsRoot, generatedAt: new Date().toISOString(), items }, null, 2)}\n`, 'utf8');
431
+ return outputPath;
432
+ }
433
+
434
+ // Executes thumbnail-options command flow.
435
+ function runThumbnailOptions(flags) {
436
+ // Ensures valence compatibility assets are ready before catalog discovery.
437
+ ensureValenceCompatibility();
438
+ // Resolves available mockup root path.
439
+ const mockupsRoot = resolveMockupsRoot();
440
+
441
+ if (!mockupsRoot) {
442
+ console.error(`${red}${bold}Could not locate Valence mockup catalog.${reset}`);
443
+ console.error('Expected either src/stories/assets/mockups or node_modules/@portfoliablejs/valence/src/stories/assets/mockups');
444
+ return 1;
445
+ }
446
+
447
+ // Builds device catalog from resolved mockup tree.
448
+ const catalog = buildThumbnailCatalog(mockupsRoot);
449
+ if (Object.keys(catalog).length === 0) {
450
+ console.error(`${red}${bold}No mockup assets were found in catalog.${reset}`);
451
+ return 1;
452
+ }
453
+
454
+ printThumbnailCatalog(catalog, flags, mockupsRoot);
455
+ return 0;
456
+ }
457
+
458
+ // Prints startup summary box for dev server sessions.
459
+ function printStartupBox({ localUrl, networkUrl, localVersion, latestVersion }) {
460
+ const lines = [
461
+ `${green}${bold}Portfoliable ready!${reset}`,
462
+ `${dim}Dev server is live and ready for editing.${reset}`,
463
+ '',
464
+ `${bold}Local:${reset} ${localUrl}`,
465
+ `${bold}On your network:${reset} ${networkUrl}`,
466
+ '',
467
+ `${bold}Next:${reset} open the local URL in your browser.`
468
+ ];
469
+
470
+ if (latestVersion && latestVersion !== localVersion && !latestVersion.includes('alpha')) {
471
+ lines.push('');
472
+ lines.push(`${yellow}A new version (${latestVersion}) is available!${reset}`);
473
+ lines.push(`Upgrade now: ${green}npm update create-portfoliable${reset}`);
474
+ lines.push(`If it still persists, run: ${green}npm install create-portfoliable@latest${reset}`);
475
+ lines.push('Read changelog:');
476
+ lines.push(`${dim}https://github.com/portfoliablejs/portfoliable/blob/main/CHANGELOG.md${reset}`);
477
+ }
478
+
479
+ printRailAttachedBox({ width: 96, lines });
480
+ console.log(`${dim}│${reset}`);
481
+ console.log(`${dim}│${reset} ${dim}Press Ctrl+C to stop the server${reset}\n`);
482
+ }
483
+
484
+ // Prints a categorized command guide for runtime features.
485
+ function printCommandGuide({ fancyDots }) {
486
+ const marker = fancyDots ? `${ui.blue}●${ui.reset}` : '-';
487
+ const lines = [
488
+ `${ui.bold}Portfoliable command guide${ui.reset}`,
489
+ `${ui.dim}Everything is grouped so you can move faster.${ui.reset}`,
490
+ '',
491
+ `${marker} ${ui.bold}Case management${ui.reset}`,
492
+ ' npm run portfoliable-create-case -- --name "Checkout Revamp"',
493
+ ' npm run portfoliable-delete-case -- --id checkout-revamp',
494
+ '',
495
+ `${marker} ${ui.bold}Language management${ui.reset}`,
496
+ ' npx portfoliable add-language --code es --name "Spanish" --html-lang es',
497
+ ' npx portfoliable delete-language --code es --force',
498
+ ' npm run portfoliable-sync-locales',
499
+ '',
500
+ `${marker} ${ui.bold}Thumbnail options${ui.reset}`,
501
+ ' npm run portfoliable-thumbnail-options',
502
+ ' writes templates/src/content/thumbnail-options.generated.json',
503
+ ' each item includes thumbCategory, thumbBrand, thumbModel, thumbColor',
504
+ '',
505
+ `${marker} ${ui.bold}Quality checks${ui.reset}`,
506
+ ' npx portfoliable validate',
507
+ '',
508
+ `${marker} ${ui.bold}Build and preview${ui.reset}`,
509
+ ' npm run portfoliable-build',
510
+ ' npm run portfoliable-preview'
511
+ ];
512
+
513
+ printRailAttachedBox({ width: 96, lines });
514
+ }
515
+
516
+ // MARK: LOCAL PROTECTION API HELPERS
517
+ function resolvePhpDocRoot(cwd) {
518
+ const candidates = [
519
+ path.resolve(cwd, 'public'),
520
+ path.resolve(cwd, 'templates', 'public')
521
+ ];
522
+
523
+ return candidates.find((candidate) => {
524
+ const endpointPath = path.resolve(candidate, 'api', 'unlock-case.php');
525
+ return fs.existsSync(endpointPath);
526
+ }) || null;
527
+ }
528
+
529
+ function startLocalPhpApiServer(cwd) {
530
+ const phpDocRoot = resolvePhpDocRoot(cwd);
531
+ if (!phpDocRoot) {
532
+ return { processRef: null, proxyTarget: '' };
533
+ }
534
+
535
+ const phpPort = Number(process.env.PORTFOLIABLE_PHP_API_PORT || 8787);
536
+ const processRef = spawn('php', ['-S', `127.0.0.1:${phpPort}`, '-t', phpDocRoot], {
537
+ stdio: 'ignore'
538
+ });
539
+
540
+ if (processRef?.on) {
541
+ processRef.on('error', (error) => {
542
+ console.warn(`${yellow}[protection]${reset} Could not start PHP API server (${error.message}).`);
543
+ console.warn(`${yellow}[protection]${reset} Protected-case unlock will fail until /api/unlock-case.php is reachable.`);
544
+ });
545
+ }
546
+
547
+ return {
548
+ processRef,
549
+ proxyTarget: `http://127.0.0.1:${phpPort}`
550
+ };
551
+ }
552
+
553
+ // Starts Vite dev server and prints startup context.
554
+ async function runDevServer(flags, options = {}) {
555
+ const onShutdown = typeof options.onShutdown === 'function' ? options.onShutdown : () => {};
556
+ const fancyDots = options.fancyDots !== false;
557
+ // Resolves requested port or uses default dev port.
558
+ const port = Number.isFinite(flags.port) ? flags.port : 5173;
559
+
560
+ const phpRuntime = startLocalPhpApiServer(process.cwd());
561
+ if (phpRuntime.proxyTarget) {
562
+ process.env.PORTFOLIABLE_PHP_API_PROXY = phpRuntime.proxyTarget;
563
+ }
564
+
565
+ // Creates Vite development server instance.
566
+ const server = await createServer({
567
+ optimizeDeps: {
568
+ exclude: ['create-portfoliable']
569
+ },
570
+ server: {
571
+ host: flags.host,
572
+ port,
573
+ open: Boolean(flags.open)
574
+ }
575
+ });
576
+
577
+ await server.listen();
578
+
579
+ // Uses HTTP protocol for displayed URLs.
580
+ const protocol = 'http';
581
+ // Resolves actual bound port from Vite config fallback.
582
+ const resolvedPort = server.config.server.port || port;
583
+ // Builds localhost URL for quick open.
584
+ const localUrl = `${protocol}://localhost:${resolvedPort}/`;
585
+ // Resolves first non-local network address.
586
+ const networkAddress = firstNetworkAddress();
587
+ // Builds network URL for external device testing.
588
+ const networkUrl = networkAddress ? `${protocol}://${networkAddress}:${resolvedPort}/` : 'Not available';
589
+
590
+ // Resolves currently running CLI package version.
591
+ const localVersion = resolvePackageVersion();
592
+ // Fetches latest published package version.
593
+ const latestVersion = await fetchLatestVersion();
594
+
595
+ if (fancyDots) {
596
+ printRailSegment({ dot: true, label: `${cyan}${bold}Starting...${reset}` });
597
+ printRailSpacer();
598
+ }
599
+
600
+ printStartupBox({ localUrl, networkUrl, localVersion, latestVersion });
601
+
602
+ if (isInteractiveTerminal() && flags.open !== false) {
603
+ let showCommands = false;
604
+ if (flags.showCommands === true) {
605
+ showCommands = true;
606
+ } else if (flags.showCommands === false) {
607
+ showCommands = false;
608
+ } else {
609
+ printRailSegment({ dot: true, label: `${cyan}${bold}Quick setup${reset}` });
610
+ showCommands = await promptYesNoDots({
611
+ question: 'Do you want to see all available commands now?',
612
+ yesLabel: 'Yes, show command categories',
613
+ noLabel: 'No, keep startup minimal',
614
+ defaultValue: true,
615
+ width: 96,
616
+ attachedToRail: true
617
+ });
618
+ }
619
+
620
+ if (showCommands) {
621
+ printRailSegment({ dot: true, label: `${green}${bold}Opening command guide${reset}` });
622
+ printRailSpacer();
623
+ printCommandGuide({ fancyDots });
624
+ printRailSpacer();
625
+ printRailSegment({ dot: true, label: `${green}${bold}Guide ready${reset}` });
626
+ }
627
+ }
628
+
629
+ // Handles Ctrl+C for graceful server shutdown.
630
+ process.on('SIGINT', async () => {
631
+ if (phpRuntime.processRef) {
632
+ phpRuntime.processRef.kill('SIGTERM');
633
+ }
634
+ onShutdown();
635
+ await server.close();
636
+ process.exit(0);
637
+ });
638
+ }
639
+
640
+ // Runs Vite production build and logs concise status output.
641
+ async function runBuild() {
642
+ printRailSegment();
643
+ printRailSegment({ dot: true, label: `${cyan}${bold}Starting build...${reset}` });
644
+ console.log(`${cyan}${bold}Building Portfoliable...${reset}`);
645
+ await viteBuild();
646
+ console.log(`${green}Build complete.${reset}`);
647
+ printRailSegment({ dot: true, label: `${green}${bold}Build complete.${reset}` });
648
+ }
649
+
650
+ // Starts Vite preview server for production build verification.
651
+ async function runPreview(flags, options = {}) {
652
+ const fancyDots = options.fancyDots !== false;
653
+ // Resolves requested preview port or uses default.
654
+ const previewPort = Number.isFinite(flags.port) ? flags.port : 4173;
655
+ // Starts preview server bound to requested host/port.
656
+ const previewServer = await vitePreview({ preview: { host: flags.host, port: previewPort } });
657
+ // Uses HTTP protocol for displayed URLs.
658
+ const protocol = 'http';
659
+ // Builds localhost preview URL.
660
+ const localUrl = `${protocol}://localhost:${previewPort}/`;
661
+ // Resolves first non-local network address.
662
+ const networkAddress = firstNetworkAddress();
663
+ // Builds network preview URL for external devices.
664
+ const networkUrl = networkAddress ? `${protocol}://${networkAddress}:${previewPort}/` : 'Not available';
665
+
666
+ if (fancyDots) {
667
+ printRailSegment();
668
+ printRailSegment({ dot: true, label: `${cyan}${bold}Starting preview...${reset}` });
669
+ }
670
+
671
+ printRailAttachedBox({
672
+ width: 72,
673
+ lines: [
674
+ `${cyan}${bold}Portfoliable preview ready${reset}`,
675
+ `${dim}Serving the production build locally.${reset}`,
676
+ '',
677
+ `${bold}Local:${reset} ${localUrl}`,
678
+ `${bold}On your network:${reset} ${networkUrl}`
679
+ ]
680
+ });
681
+ console.log(`${dim}│${reset}`);
682
+ console.log(`${dim}│${reset} ${dim}Press Ctrl+C to stop the server${reset}\n`);
683
+
684
+ // Handles Ctrl+C for graceful preview server shutdown.
685
+ process.on('SIGINT', async () => {
686
+ await previewServer.httpServer.close();
687
+ process.exit(0);
688
+ });
689
+ }
690
+
691
+ // MARK: COMMAND DISPATCH
692
+ // Routes parsed command to matching execution path.
693
+ async function main() {
694
+ // Parses command and flags from process arguments.
695
+ const { command, flags } = parseCliArgs(process.argv);
696
+ const projectPreferences = readProjectUiPreferences(process.cwd());
697
+ const fancyDots = projectPreferences?.fancyDots ?? true;
698
+
699
+ if (command === 'dev') {
700
+ ensureValenceCompatibility();
701
+ const stopLocaleWatcher = watchLocaleSync({ cwd: process.cwd(), logger: console });
702
+ await runDevServer(flags, {
703
+ onShutdown: stopLocaleWatcher,
704
+ fancyDots
705
+ });
706
+ return;
707
+ }
708
+
709
+ if (command === 'build') {
710
+ ensureValenceCompatibility();
711
+ await runBuild();
712
+ return;
713
+ }
714
+
715
+ if (command === 'preview') {
716
+ ensureValenceCompatibility();
717
+ await runPreview(flags, { fancyDots });
718
+ return;
719
+ }
720
+
721
+ if (command === 'validate') {
722
+ // Runs markdown content validation command.
723
+ const exitCode = runValidation();
724
+ process.exit(exitCode);
725
+ return;
726
+ }
727
+
728
+ if (command === 'sync-locales') {
729
+ if (flags.watch) {
730
+ const dispose = watchLocaleSync({ cwd: process.cwd(), logger: console });
731
+ process.on('SIGINT', () => {
732
+ dispose();
733
+ process.exit(0);
734
+ });
735
+ process.on('SIGTERM', () => {
736
+ dispose();
737
+ process.exit(0);
738
+ });
739
+ return;
740
+ }
741
+
742
+ const result = await runLocaleSync({ cwd: process.cwd() });
743
+ console.log(`${green}[sync-locales]${reset} Synced ${result.updatedFiles} file(s) for locales: ${result.localeCodes.join(', ')}`);
744
+ return;
745
+ }
746
+
747
+ if (command === 'add-language') {
748
+ const result = await runAddLanguage({
749
+ cwd: process.cwd(),
750
+ code: flags.code || '',
751
+ name: flags.name || '',
752
+ htmlLang: flags.htmlLang || '',
753
+ direction: flags.direction || ''
754
+ });
755
+
756
+ if (result.unchanged) {
757
+ console.log(`${yellow}[add-language]${reset} Nothing to change. ${result.localeCode} is already configured as ${result.displayName} (htmlLang=${result.htmlLang}).`);
758
+ console.log(`${green}[add-language]${reset} Locales: ${result.localeCodes.join(', ')}`);
759
+ return;
760
+ }
761
+
762
+ const operation = result.existed ? 'Updated' : 'Added';
763
+ console.log(`${green}[add-language]${reset} ${operation} ${result.localeCode} (${result.displayName}) htmlLang=${result.htmlLang}`);
764
+ console.log(`${green}[add-language]${reset} Locales: ${result.localeCodes.join(', ')}`);
765
+ console.log(`${green}[add-language]${reset} Synced ${result.syncResult.updatedFiles} file(s).`);
766
+ return;
767
+ }
768
+
769
+ if (command === 'delete-language') {
770
+ const result = await runDeleteLanguage({
771
+ cwd: process.cwd(),
772
+ code: flags.code || '',
773
+ force: flags.deleteForce === true
774
+ });
775
+
776
+ if (result.alreadyMissing) {
777
+ console.log(`${yellow}[delete-language]${reset} Nothing to delete. Locale ${result.removedLocale} is already missing.`);
778
+ console.log(`${green}[delete-language]${reset} Locales: ${result.localeCodes.join(', ')}`);
779
+ return;
780
+ }
781
+
782
+ console.log(`${green}[delete-language]${reset} Removed ${result.removedLocale}.`);
783
+ console.log(`${green}[delete-language]${reset} Default locale: ${result.defaultLocaleBefore} -> ${result.defaultLocaleAfter}`);
784
+ console.log(`${green}[delete-language]${reset} Locales: ${result.localeCodes.join(', ')}`);
785
+ console.log(`${green}[delete-language]${reset} Synced ${result.syncResult.updatedFiles} file(s).`);
786
+ return;
787
+ }
788
+
789
+ if (command === 'create-case') {
790
+ // Runs case scaffolder for create-case alias.
791
+ const exitCode = runCaseScaffold({ outFile: flags.out || undefined, name: flags.name || undefined, force: flags.force });
792
+ process.exit(exitCode);
793
+ return;
794
+ }
795
+
796
+ if (command === 'delete-case') {
797
+ const result = runDeleteCase({
798
+ cwd: process.cwd(),
799
+ caseId: flags.id || undefined,
800
+ outFile: flags.out || undefined,
801
+ force: flags.force
802
+ });
803
+
804
+ if (result.alreadyMissing) {
805
+ console.log(`${yellow}[delete-case]${reset} Nothing to delete. Case is already missing.`);
806
+ if (result.suggestedIds?.length > 0) {
807
+ console.log(`${green}[delete-case]${reset} Similar existing case ids: ${result.suggestedIds.join(', ')}`);
808
+ }
809
+ return;
810
+ }
811
+
812
+ console.log(`${green}[delete-case]${reset} Removed ${result.removedPath}.`);
813
+ if (result.removedDirectory) {
814
+ console.log(`${green}[delete-case]${reset} Removed case directory ${result.removedDirectoryPath}.`);
815
+ }
816
+ return;
817
+ }
818
+
819
+ if (command === 'thumbnail-options') {
820
+ // Runs thumbnail catalog inspection command.
821
+ const exitCode = runThumbnailOptions(flags);
822
+ process.exit(exitCode);
823
+ return;
824
+ }
825
+
826
+ console.error(`${red}${bold}Unknown command:${reset} ${command}`);
827
+ console.log('Use one of: dev, build, preview, validate, sync-locales, add-language, delete-language, create-case, delete-case, thumbnail-options');
828
+ process.exit(1);
829
+ }
830
+
831
+ // MARK: SCRIPT ENTRYPOINT
832
+ // Executes command dispatcher and converts uncaught failures into non-zero exit.
833
+ main().catch((error) => {
834
+ console.error(`${bold}${red}✕ Failed to run Portfoliable:${reset}`, error);
835
+ process.exit(1);
836
+ });