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,27 @@
1
+ # Protection Unlock Endpoint Examples
2
+
3
+ Default production flow is now PHP-first for self-hosted deployments.
4
+
5
+ Use these files:
6
+
7
+ - `../public/api/unlock-case.php`
8
+ - `../public/api/password.config.example.json`
9
+ - `../public/api/.htaccess`
10
+ - `../scripts/generate-password-hash.mjs`
11
+
12
+ 1. Copy `public/api/password.config.example.json` to `public/api/password.config.json`.
13
+ 2. Generate one hash record per protected case id:
14
+
15
+ ```bash
16
+ npm run password:hash -- --case-id mobile-product-launch --password "your-secret"
17
+ ```
18
+
19
+ 3. Paste the generated `hash` under `cases.<caseId>.hash`.
20
+ 4. Keep `unlockEndpoint: '/api/unlock-case.php'` in `configs/portfoliable.design.config.js`.
21
+
22
+ Security notes:
23
+
24
+ - Never store plaintext passwords in markdown case config.
25
+ - Keep `public/api/password.config.json` out of git.
26
+ - Keep direct access blocked via `public/api/.htaccess`.
27
+ - The unlock endpoint should disable caching (`Cache-Control: no-store`).
@@ -0,0 +1,16 @@
1
+ {
2
+ "_comment": "Copy to public/api/password.config.json for self-hosted PHP unlock verification. Keep each case password unique.",
3
+ "_format": {
4
+ "description": "Map case id to one password_hash() record.",
5
+ "algorithm": "password_hash",
6
+ "recommended": "PASSWORD_ARGON2ID"
7
+ },
8
+ "cases": {
9
+ "mobile-product-launch": {
10
+ "hash": "$argon2id$v=19$m=65536,t=4,p=1$replace$with-generated-hash"
11
+ },
12
+ "mobile-checkout-flow": {
13
+ "hash": "$argon2id$v=19$m=65536,t=4,p=1$replace$with-generated-hash"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "_comment": "Development-only sample. Replace hashes before publishing.",
3
+ "_format": {
4
+ "description": "Map case id to one password_hash() record.",
5
+ "algorithm": "password_hash",
6
+ "recommended": "PASSWORD_ARGON2ID"
7
+ },
8
+ "cases": {
9
+ "mobile-product-launch": {
10
+ "hash": "$argon2id$v=19$m=65536,t=4,p=1$Qnl0Y2xRSDVlR0dnWEdjUw$4/FMC0f+ETbZif7hXY5lTvf5qDRMRJCcNHnqvOP86ac"
11
+ },
12
+ "mobile-checkout-flow": {
13
+ "hash": "$2y$12$vlGuw0/Pw75l0JDzSvNhoe4/E2CdByjSunLpz.krX15eqd7y4G316"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,56 @@
1
+ <?php
2
+ // File: templates/public/api/unlock-case.php
3
+ // Purpose: Validate per-case passwords server-side for PHP-compatible self-hosting.
4
+
5
+ declare(strict_types=1);
6
+
7
+ header('Content-Type: application/json');
8
+ header('Cache-Control: no-store');
9
+
10
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
11
+ http_response_code(405);
12
+ echo json_encode(['ok' => false, 'error' => 'method-not-allowed']);
13
+ exit;
14
+ }
15
+
16
+ $rawBody = file_get_contents('php://input');
17
+ $payload = json_decode($rawBody ?: '{}', true);
18
+
19
+ if (!is_array($payload)) {
20
+ http_response_code(400);
21
+ echo json_encode(['ok' => false, 'error' => 'invalid-json']);
22
+ exit;
23
+ }
24
+
25
+ $caseId = trim((string)($payload['caseId'] ?? ''));
26
+ $password = (string)($payload['password'] ?? '');
27
+
28
+ if ($caseId === '' || $password === '') {
29
+ http_response_code(400);
30
+ echo json_encode(['ok' => false, 'error' => 'missing-input']);
31
+ exit;
32
+ }
33
+
34
+ $configPath = __DIR__ . '/password.config.json';
35
+ if (!is_file($configPath)) {
36
+ http_response_code(401);
37
+ echo json_encode(['ok' => false]);
38
+ exit;
39
+ }
40
+
41
+ $configRaw = file_get_contents($configPath);
42
+ $config = json_decode($configRaw ?: '{}', true);
43
+ $record = is_array($config) && isset($config['cases'][$caseId]) && is_array($config['cases'][$caseId])
44
+ ? $config['cases'][$caseId]
45
+ : null;
46
+ $storedHash = is_array($record) ? (string)($record['hash'] ?? '') : '';
47
+
48
+ if ($storedHash === '') {
49
+ http_response_code(401);
50
+ echo json_encode(['ok' => false]);
51
+ exit;
52
+ }
53
+
54
+ $ok = password_verify($password, $storedHash);
55
+ http_response_code($ok ? 200 : 401);
56
+ echo json_encode(['ok' => $ok]);
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ <svg width="792" height="792" viewBox="0 0 792 792" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M644.404 0C725.919 0 792 66.0809 792 147.596V644.404C792 725.919 725.919 792 644.404 792H374.924C363.878 792 354.924 783.046 354.924 772V590.825C354.924 579.78 363.878 570.825 374.924 570.825H480.214C530.713 570.825 573.223 561.556 607.742 543.019C642.261 524.268 668.363 498.591 686.049 465.99C703.734 433.389 712.576 396.1 712.576 354.124C712.576 312.574 703.734 275.498 686.049 242.897C668.576 210.083 642.686 184.301 608.381 165.55C574.288 146.799 532.098 137.423 481.812 137.423H284.58C257.941 137.423 236.346 159.018 236.346 185.657V772C236.346 783.046 227.391 792 216.346 792H147.596C66.0809 792 0 725.919 0 644.404V147.596C0 66.0809 66.0809 0 147.596 0H644.404ZM463.595 236.505C493.212 236.505 517.397 241.512 536.147 251.526C555.111 261.328 569.068 275.072 578.018 292.758C587.18 310.443 591.761 330.899 591.761 354.124C591.761 377.35 587.18 397.912 578.018 415.811C569.068 433.709 555.218 447.772 536.467 458C517.716 468.228 493.638 473.342 464.233 473.342H374.924C363.878 473.342 354.924 464.387 354.924 453.342V265.445C354.924 249.462 367.881 236.505 383.864 236.505H463.595Z" fill="#FDFDFC"/>
3
+ </svg>
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // File: scripts/add-language.mjs
3
+ // Purpose: Add/update a locale in i18n config and propagate localized stubs across labels/about/cases.
4
+ // Author: Lio Schimanko
5
+
6
+ // MARK: IMPORTS
7
+ import fs from 'node:fs';
8
+ import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from './i18n-config-utils.mjs';
9
+ import { runLocaleSync } from './sync-locales.mjs';
10
+
11
+ // MARK: CONFIG PATTERNS
12
+ const LANGUAGE_CONFIG_REGEX = /export\s+const\s+LANGUAGE_CONFIG\s*=\s*Object\.freeze\(\s*(\{[\s\S]*?\})\s*\);/;
13
+ const RTL_LOCALE_CODES_REGEX = /export\s+const\s+RTL_LOCALE_CODES\s*=\s*Object\.freeze\(\s*(\[[\s\S]*?\])\s*\);/;
14
+
15
+ // MARK: ARGUMENT AND LITERAL PARSING
16
+ // Parses object-like CLI literals as JSON first, then falls back to JS object syntax.
17
+ function parseConfigLiteral(literalText, fallbackValue = null) {
18
+ const raw = String(literalText || '').trim();
19
+ if (!raw) return fallbackValue;
20
+
21
+ try {
22
+ return JSON.parse(raw);
23
+ } catch {
24
+ try {
25
+ const evaluateLiteral = new Function(`return (${raw});`);
26
+ return evaluateLiteral();
27
+ } catch {
28
+ return fallbackValue;
29
+ }
30
+ }
31
+ }
32
+
33
+ // Parses supported CLI flags for locale code, display name, html lang, and direction.
34
+ function parseArgs(argv) {
35
+ const parsed = {
36
+ code: '',
37
+ name: '',
38
+ htmlLang: '',
39
+ direction: ''
40
+ };
41
+
42
+ for (let i = 0; i < argv.length; i += 1) {
43
+ const token = argv[i];
44
+
45
+ if (token === '--code') {
46
+ parsed.code = String(argv[i + 1] || '').trim().toLowerCase();
47
+ i += 1;
48
+ continue;
49
+ }
50
+
51
+ if (token === '--name') {
52
+ parsed.name = String(argv[i + 1] || '').trim();
53
+ i += 1;
54
+ continue;
55
+ }
56
+
57
+ if (token === '--html-lang' || token === '--htmlLang') {
58
+ parsed.htmlLang = String(argv[i + 1] || '').trim();
59
+ i += 1;
60
+ continue;
61
+ }
62
+
63
+ if (token === '--direction') {
64
+ parsed.direction = String(argv[i + 1] || '').trim().toLowerCase();
65
+ i += 1;
66
+ continue;
67
+ }
68
+
69
+ if (token === '--rtl') {
70
+ parsed.direction = 'rtl';
71
+ continue;
72
+ }
73
+
74
+ if (token === '--ltr') {
75
+ parsed.direction = 'ltr';
76
+ continue;
77
+ }
78
+ }
79
+
80
+ return parsed;
81
+ }
82
+
83
+ // Derives a default html lang value from a locale code when one is not supplied.
84
+ function toDefaultHtmlLang(localeCode) {
85
+ const parts = String(localeCode || '').split('-').filter(Boolean);
86
+ const base = String(parts[0] || '').toLowerCase();
87
+ if (!base) return 'en-US';
88
+
89
+ if (parts.length > 1) {
90
+ return `${base}-${parts[1].toUpperCase()}`;
91
+ }
92
+
93
+ return `${base}-${base.toUpperCase()}`;
94
+ }
95
+
96
+ // Validates locale code syntax used by i18n config keys.
97
+ function validateLocaleCode(localeCode) {
98
+ return /^[a-z]{2,3}(?:-[a-z0-9]+)*$/i.test(String(localeCode || '').trim());
99
+ }
100
+
101
+ // Validates writing direction values accepted by runtime locale metadata.
102
+ function validateDirection(direction) {
103
+ return direction === 'rtl' || direction === 'ltr';
104
+ }
105
+
106
+ // Serializes LANGUAGE_CONFIG back into the expected frozen export format.
107
+ function formatLanguageConfigBlock(nextLanguageConfig) {
108
+ return `export const LANGUAGE_CONFIG = Object.freeze(${JSON.stringify(nextLanguageConfig, null, 2)});`;
109
+ }
110
+
111
+ // Serializes RTL locale list into the expected frozen export format.
112
+ function formatRtlLocaleCodesBlock(nextRtlLocaleCodes) {
113
+ return `export const RTL_LOCALE_CODES = Object.freeze(${JSON.stringify(nextRtlLocaleCodes, null, 2)});`;
114
+ }
115
+
116
+ // MARK: LANGUAGE MUTATION WORKFLOW
117
+ // Adds or updates one locale entry and then syncs locale-dependent content files.
118
+ export async function runAddLanguage(options = {}) {
119
+ const cwd = options.cwd || process.cwd();
120
+ const localeCode = String(options.code || '').trim().toLowerCase();
121
+ const displayName = String(options.name || '').trim();
122
+ const htmlLang = String(options.htmlLang || '').trim() || toDefaultHtmlLang(localeCode);
123
+ const requestedDirection = String(options.direction || '').trim().toLowerCase();
124
+ const direction = requestedDirection || 'ltr';
125
+
126
+ if (!validateLocaleCode(localeCode)) {
127
+ throw new Error('Invalid locale code. Use values like "it", "es", or "pt-BR".');
128
+ }
129
+
130
+ if (!displayName) {
131
+ throw new Error('Missing language display name. Use --name "Italiano".');
132
+ }
133
+
134
+ if (!validateDirection(direction)) {
135
+ throw new Error('Invalid direction. Use --direction ltr or --direction rtl (or --rtl/--ltr).');
136
+ }
137
+
138
+ const i18nConfigPath = resolveI18nConfigPath(cwd);
139
+ if (!fs.existsSync(i18nConfigPath)) {
140
+ throw new Error(`Could not find i18n config file at ${i18nConfigPath}`);
141
+ }
142
+
143
+ const source = fs.readFileSync(i18nConfigPath, 'utf8');
144
+ const languageConfigMatch = source.match(LANGUAGE_CONFIG_REGEX);
145
+ if (!languageConfigMatch?.[1]) {
146
+ throw new Error(`LANGUAGE_CONFIG block was not found in ${i18nConfigPath}`);
147
+ }
148
+
149
+ let languageConfig = {};
150
+ let rtlLocaleCodes = [];
151
+ languageConfig = parseConfigLiteral(languageConfigMatch[1], null);
152
+ if (!languageConfig || typeof languageConfig !== 'object' || Array.isArray(languageConfig)) {
153
+ throw new Error(`LANGUAGE_CONFIG is not valid JSON in ${i18nConfigPath}`);
154
+ }
155
+
156
+ const rtlLocaleCodesMatch = source.match(RTL_LOCALE_CODES_REGEX);
157
+ if (rtlLocaleCodesMatch?.[1]) {
158
+ const parsed = parseConfigLiteral(rtlLocaleCodesMatch[1], null);
159
+ if (Array.isArray(parsed)) {
160
+ rtlLocaleCodes = parsed;
161
+ } else {
162
+ throw new Error(`RTL_LOCALE_CODES is not valid JSON in ${i18nConfigPath}`);
163
+ }
164
+ }
165
+
166
+ rtlLocaleCodes = rtlLocaleCodes
167
+ .map((value) => String(value || '').trim().toLowerCase())
168
+ .filter((value, index, list) => value.length > 0 && list.indexOf(value) === index);
169
+
170
+ const existed = Boolean(languageConfig[localeCode]);
171
+ const currentEntry = existed ? languageConfig[localeCode] : null;
172
+ const currentName = String(currentEntry?.name || '').trim();
173
+ const currentHtmlLang = String(currentEntry?.htmlLang || '').trim();
174
+ const currentlyRtl = rtlLocaleCodes.includes(localeCode);
175
+ const wantsRtl = direction === 'rtl';
176
+ const unchanged = existed && currentName === displayName && currentHtmlLang === htmlLang && currentlyRtl === wantsRtl;
177
+
178
+ if (unchanged) {
179
+ const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
180
+ return {
181
+ existed,
182
+ unchanged: true,
183
+ localeCode,
184
+ displayName,
185
+ htmlLang,
186
+ direction,
187
+ i18nConfigPath,
188
+ localeCodes: localeConfig.supportedLocales,
189
+ syncResult: { updatedFiles: 0, localeCodes: localeConfig.supportedLocales }
190
+ };
191
+ }
192
+
193
+ languageConfig[localeCode] = {
194
+ name: displayName,
195
+ htmlLang
196
+ };
197
+
198
+ if (wantsRtl) {
199
+ if (!rtlLocaleCodes.includes(localeCode)) {
200
+ rtlLocaleCodes.push(localeCode);
201
+ }
202
+ } else {
203
+ rtlLocaleCodes = rtlLocaleCodes.filter((code) => code !== localeCode);
204
+ }
205
+
206
+ rtlLocaleCodes = rtlLocaleCodes
207
+ .map((value) => String(value || '').trim().toLowerCase())
208
+ .filter((value, index, list) => value.length > 0 && list.indexOf(value) === index)
209
+ .sort((a, b) => a.localeCompare(b));
210
+
211
+ const nextBlock = formatLanguageConfigBlock(languageConfig);
212
+ let nextSource = source.replace(LANGUAGE_CONFIG_REGEX, nextBlock);
213
+ if (RTL_LOCALE_CODES_REGEX.test(nextSource)) {
214
+ nextSource = nextSource.replace(RTL_LOCALE_CODES_REGEX, formatRtlLocaleCodesBlock(rtlLocaleCodes));
215
+ }
216
+
217
+ if (nextSource !== source) {
218
+ fs.writeFileSync(i18nConfigPath, nextSource, 'utf8');
219
+ }
220
+
221
+ const syncResult = await runLocaleSync({ cwd });
222
+ const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
223
+
224
+ return {
225
+ existed,
226
+ unchanged: false,
227
+ localeCode,
228
+ displayName,
229
+ htmlLang,
230
+ direction,
231
+ i18nConfigPath,
232
+ localeCodes: localeConfig.supportedLocales,
233
+ syncResult
234
+ };
235
+ }
236
+
237
+ // MARK: SCRIPT ENTRYPOINT
238
+ // CLI entrypoint for add-language script usage.
239
+ if (import.meta.url === `file://${process.argv[1]}`) {
240
+ const args = parseArgs(process.argv.slice(2));
241
+
242
+ runAddLanguage(args)
243
+ .then((result) => {
244
+ const operation = result.existed ? 'Updated' : 'Added';
245
+ console.log(`[add-language] ${operation} ${result.localeCode} (${result.displayName}) with htmlLang=${result.htmlLang} and direction=${result.direction}.`);
246
+ console.log(`[add-language] Locales: ${result.localeCodes.join(', ')}`);
247
+ console.log(`[add-language] Synced ${result.syncResult.updatedFiles} file(s).`);
248
+ })
249
+ .catch((error) => {
250
+ console.error('[add-language] Failed:', error.message || error);
251
+ process.exit(1);
252
+ });
253
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // File: templates/scripts/convert-video-to-mp4.mjs
3
+ // Purpose: Convert video files to MP4 and write outputs beside original files.
4
+ // Author: Lio Schimanko
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+
10
+ const SKIPPED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist']);
11
+ const VIDEO_EXTENSIONS = new Set([
12
+ '.avi',
13
+ '.flv',
14
+ '.m2ts',
15
+ '.m4v',
16
+ '.mkv',
17
+ '.mov',
18
+ '.mpeg',
19
+ '.mpg',
20
+ '.mts',
21
+ '.webm',
22
+ '.wmv',
23
+ '.3gp'
24
+ ]);
25
+
26
+ function parseArgs(argv) {
27
+ const args = argv.slice(2);
28
+ const options = {
29
+ dir: process.cwd(),
30
+ force: false,
31
+ help: false
32
+ };
33
+
34
+ for (let i = 0; i < args.length; i += 1) {
35
+ const token = args[i];
36
+
37
+ if ((token === '--dir' || token === '--input') && args[i + 1]) {
38
+ options.dir = path.resolve(String(args[i + 1]));
39
+ i += 1;
40
+ continue;
41
+ }
42
+
43
+ if (token === '--force') {
44
+ options.force = true;
45
+ continue;
46
+ }
47
+
48
+ if (token === '--help' || token === '-h') {
49
+ options.help = true;
50
+ }
51
+ }
52
+
53
+ return options;
54
+ }
55
+
56
+ function printHelp() {
57
+ console.log('Usage: npm run convert:video -- [--dir <path>] [--force]');
58
+ console.log('');
59
+ console.log('Options:');
60
+ console.log(' --dir, --input Root directory to scan recursively for video files');
61
+ console.log(' --force Overwrite existing .mp4 targets');
62
+ console.log(' --help, -h Show help');
63
+ }
64
+
65
+ function verifyFfmpeg() {
66
+ const result = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
67
+ if (result.error || result.status !== 0) {
68
+ throw new Error('ffmpeg is required. Install it first (for macOS: brew install ffmpeg).');
69
+ }
70
+ }
71
+
72
+ function collectVideoFiles(rootDir, output = []) {
73
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
74
+
75
+ for (const entry of entries) {
76
+ const absolutePath = path.join(rootDir, entry.name);
77
+
78
+ if (entry.isDirectory()) {
79
+ if (SKIPPED_DIRECTORIES.has(entry.name)) {
80
+ continue;
81
+ }
82
+ collectVideoFiles(absolutePath, output);
83
+ continue;
84
+ }
85
+
86
+ const extension = path.extname(entry.name).toLowerCase();
87
+ if (entry.isFile() && VIDEO_EXTENSIONS.has(extension)) {
88
+ output.push(absolutePath);
89
+ }
90
+ }
91
+
92
+ return output;
93
+ }
94
+
95
+ function convertOneFile(inputPath, force = false) {
96
+ const directory = path.dirname(inputPath);
97
+ const baseName = path.basename(inputPath, path.extname(inputPath));
98
+ const outputPath = path.join(directory, `${baseName}.mp4`);
99
+
100
+ if (fs.existsSync(outputPath) && !force) {
101
+ return {
102
+ status: 'skipped',
103
+ inputPath,
104
+ outputPath,
105
+ reason: 'target exists'
106
+ };
107
+ }
108
+
109
+ const ffmpegArgs = [
110
+ '-hide_banner',
111
+ '-loglevel',
112
+ 'error',
113
+ force ? '-y' : '-n',
114
+ '-i',
115
+ inputPath,
116
+ '-c:v',
117
+ 'libx264',
118
+ '-preset',
119
+ 'medium',
120
+ '-crf',
121
+ '23',
122
+ '-c:a',
123
+ 'aac',
124
+ '-b:a',
125
+ '192k',
126
+ '-movflags',
127
+ '+faststart',
128
+ outputPath
129
+ ];
130
+
131
+ const result = spawnSync('ffmpeg', ffmpegArgs, {
132
+ encoding: 'utf8'
133
+ });
134
+
135
+ if (result.error || result.status !== 0) {
136
+ return {
137
+ status: 'failed',
138
+ inputPath,
139
+ outputPath,
140
+ reason: (result.stderr || result.error?.message || 'conversion failed').trim()
141
+ };
142
+ }
143
+
144
+ return {
145
+ status: 'converted',
146
+ inputPath,
147
+ outputPath
148
+ };
149
+ }
150
+
151
+ function main() {
152
+ const options = parseArgs(process.argv);
153
+
154
+ if (options.help) {
155
+ printHelp();
156
+ process.exit(0);
157
+ }
158
+
159
+ if (!fs.existsSync(options.dir) || !fs.statSync(options.dir).isDirectory()) {
160
+ console.error(`[convert:video] Invalid directory: ${options.dir}`);
161
+ process.exit(1);
162
+ }
163
+
164
+ verifyFfmpeg();
165
+
166
+ const videoFiles = collectVideoFiles(options.dir);
167
+ if (videoFiles.length === 0) {
168
+ console.log(`[convert:video] No video files found under ${options.dir}`);
169
+ process.exit(0);
170
+ }
171
+
172
+ let converted = 0;
173
+ let skipped = 0;
174
+ let failed = 0;
175
+
176
+ for (const inputPath of videoFiles) {
177
+ const result = convertOneFile(inputPath, options.force);
178
+
179
+ if (result.status === 'converted') {
180
+ converted += 1;
181
+ console.log(`[convert:video] Converted: ${result.outputPath}`);
182
+ continue;
183
+ }
184
+
185
+ if (result.status === 'skipped') {
186
+ skipped += 1;
187
+ console.log(`[convert:video] Skipped: ${result.outputPath} (${result.reason})`);
188
+ continue;
189
+ }
190
+
191
+ failed += 1;
192
+ console.error(`[convert:video] Failed: ${result.inputPath}`);
193
+ console.error(`[convert:video] Reason: ${result.reason}`);
194
+ }
195
+
196
+ console.log(`[convert:video] Done. converted=${converted} skipped=${skipped} failed=${failed}`);
197
+ if (failed > 0) {
198
+ process.exit(1);
199
+ }
200
+ }
201
+
202
+ try {
203
+ main();
204
+ } catch (error) {
205
+ console.error(`[convert:video] ${error?.message || String(error)}`);
206
+ process.exit(1);
207
+ }