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,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// File: templates/scripts/convert-wav-to-mp3.mjs
|
|
3
|
+
// Purpose: Convert WAV files to MP3 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
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = argv.slice(2);
|
|
14
|
+
const options = {
|
|
15
|
+
dir: process.cwd(),
|
|
16
|
+
force: false,
|
|
17
|
+
help: false
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
21
|
+
const token = args[i];
|
|
22
|
+
|
|
23
|
+
if ((token === '--dir' || token === '--input') && args[i + 1]) {
|
|
24
|
+
options.dir = path.resolve(String(args[i + 1]));
|
|
25
|
+
i += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (token === '--force') {
|
|
30
|
+
options.force = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (token === '--help' || token === '-h') {
|
|
35
|
+
options.help = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printHelp() {
|
|
43
|
+
console.log('Usage: npm run convert:audio -- [--dir <path>] [--force]');
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log('Options:');
|
|
46
|
+
console.log(' --dir, --input Root directory to scan recursively for .wav files');
|
|
47
|
+
console.log(' --force Overwrite existing .mp3 targets');
|
|
48
|
+
console.log(' --help, -h Show help');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function verifyFfmpeg() {
|
|
52
|
+
const result = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
53
|
+
if (result.error || result.status !== 0) {
|
|
54
|
+
throw new Error('ffmpeg is required. Install it first (for macOS: brew install ffmpeg).');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function collectWavFiles(rootDir, output = []) {
|
|
59
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const absolutePath = path.join(rootDir, entry.name);
|
|
63
|
+
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
if (SKIPPED_DIRECTORIES.has(entry.name)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
collectWavFiles(absolutePath, output);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.wav') {
|
|
73
|
+
output.push(absolutePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return output;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function convertOneFile(inputPath, force = false) {
|
|
81
|
+
const directory = path.dirname(inputPath);
|
|
82
|
+
const baseName = path.basename(inputPath, path.extname(inputPath));
|
|
83
|
+
const outputPath = path.join(directory, `${baseName}.mp3`);
|
|
84
|
+
|
|
85
|
+
if (fs.existsSync(outputPath) && !force) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'skipped',
|
|
88
|
+
inputPath,
|
|
89
|
+
outputPath,
|
|
90
|
+
reason: 'target exists'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ffmpegArgs = [
|
|
95
|
+
'-hide_banner',
|
|
96
|
+
'-loglevel',
|
|
97
|
+
'error',
|
|
98
|
+
force ? '-y' : '-n',
|
|
99
|
+
'-i',
|
|
100
|
+
inputPath,
|
|
101
|
+
'-codec:a',
|
|
102
|
+
'libmp3lame',
|
|
103
|
+
'-q:a',
|
|
104
|
+
'2',
|
|
105
|
+
outputPath
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const result = spawnSync('ffmpeg', ffmpegArgs, {
|
|
109
|
+
encoding: 'utf8'
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (result.error || result.status !== 0) {
|
|
113
|
+
return {
|
|
114
|
+
status: 'failed',
|
|
115
|
+
inputPath,
|
|
116
|
+
outputPath,
|
|
117
|
+
reason: (result.stderr || result.error?.message || 'conversion failed').trim()
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
status: 'converted',
|
|
123
|
+
inputPath,
|
|
124
|
+
outputPath
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function main() {
|
|
129
|
+
const options = parseArgs(process.argv);
|
|
130
|
+
|
|
131
|
+
if (options.help) {
|
|
132
|
+
printHelp();
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!fs.existsSync(options.dir) || !fs.statSync(options.dir).isDirectory()) {
|
|
137
|
+
console.error(`[convert:audio] Invalid directory: ${options.dir}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
verifyFfmpeg();
|
|
142
|
+
|
|
143
|
+
const wavFiles = collectWavFiles(options.dir);
|
|
144
|
+
if (wavFiles.length === 0) {
|
|
145
|
+
console.log(`[convert:audio] No WAV files found under ${options.dir}`);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let converted = 0;
|
|
150
|
+
let skipped = 0;
|
|
151
|
+
let failed = 0;
|
|
152
|
+
|
|
153
|
+
for (const inputPath of wavFiles) {
|
|
154
|
+
const result = convertOneFile(inputPath, options.force);
|
|
155
|
+
|
|
156
|
+
if (result.status === 'converted') {
|
|
157
|
+
converted += 1;
|
|
158
|
+
console.log(`[convert:audio] Converted: ${result.outputPath}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (result.status === 'skipped') {
|
|
163
|
+
skipped += 1;
|
|
164
|
+
console.log(`[convert:audio] Skipped: ${result.outputPath} (${result.reason})`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
failed += 1;
|
|
169
|
+
console.error(`[convert:audio] Failed: ${result.inputPath}`);
|
|
170
|
+
console.error(`[convert:audio] Reason: ${result.reason}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`[convert:audio] Done. converted=${converted} skipped=${skipped} failed=${failed}`);
|
|
174
|
+
if (failed > 0) {
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
main();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error(`[convert:audio] ${error?.message || String(error)}`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// File: scripts/delete-case.mjs
|
|
3
|
+
// Purpose: Remove a generated case from src/content/cases by id or explicit file path.
|
|
4
|
+
// Author: Lio Schimanko
|
|
5
|
+
|
|
6
|
+
// MARK: IMPORTS
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
// MARK: CASE ROOTS
|
|
11
|
+
const DEFAULT_CASES_ROOTS = ['src/content/cases', 'templates/src/content/cases'];
|
|
12
|
+
|
|
13
|
+
// MARK: ARGUMENT PARSING
|
|
14
|
+
// Parses CLI flags for case id, explicit output path, and force deletion mode.
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const parsed = {
|
|
17
|
+
id: '',
|
|
18
|
+
outFile: '',
|
|
19
|
+
force: false
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const token = argv[i];
|
|
24
|
+
|
|
25
|
+
if (token === '--id') {
|
|
26
|
+
parsed.id = String(argv[i + 1] || '').trim();
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (token === '--out') {
|
|
32
|
+
parsed.outFile = String(argv[i + 1] || '').trim();
|
|
33
|
+
i += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (token === '--force') {
|
|
38
|
+
parsed.force = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Normalizes case identifiers into kebab-case slugs.
|
|
46
|
+
function toSlug(value) {
|
|
47
|
+
return String(value || '')
|
|
48
|
+
.trim()
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
51
|
+
.replace(/^-+|-+$/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// MARK: CASE DISCOVERY HELPERS
|
|
55
|
+
// Recursively lists files under a root directory.
|
|
56
|
+
function walkFilesRecursive(rootDir) {
|
|
57
|
+
const files = [];
|
|
58
|
+
|
|
59
|
+
// Traverses nested directories and appends discovered files.
|
|
60
|
+
const visit = (currentDir) => {
|
|
61
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
visit(absolutePath);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
files.push(absolutePath);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
visit(rootDir);
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Deduplicates candidate file paths using absolute path normalization.
|
|
77
|
+
function uniquePaths(paths) {
|
|
78
|
+
return [...new Set(paths.map((candidate) => path.resolve(candidate)))];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolves existing case roots in both consumer and template layouts.
|
|
82
|
+
function resolveCasesRoots(cwd) {
|
|
83
|
+
return DEFAULT_CASES_ROOTS
|
|
84
|
+
.map((relativeRoot) => path.resolve(cwd, relativeRoot))
|
|
85
|
+
.filter((absoluteRoot) => fs.existsSync(absoluteRoot) && fs.statSync(absoluteRoot).isDirectory());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generates direct candidate markdown paths for a case identifier.
|
|
89
|
+
function resolveIdCandidates(cwd, caseId) {
|
|
90
|
+
const roots = resolveCasesRoots(cwd);
|
|
91
|
+
const directCandidates = [];
|
|
92
|
+
|
|
93
|
+
for (const casesRoot of roots) {
|
|
94
|
+
directCandidates.push(path.resolve(casesRoot, caseId, 'case.md'));
|
|
95
|
+
directCandidates.push(path.resolve(casesRoot, `${caseId}.md`));
|
|
96
|
+
directCandidates.push(path.resolve(casesRoot, 'summary', caseId, 'case.md'));
|
|
97
|
+
directCandidates.push(path.resolve(casesRoot, 'summary', `${caseId}.md`));
|
|
98
|
+
directCandidates.push(path.resolve(casesRoot, 'reader', caseId, 'case.md'));
|
|
99
|
+
directCandidates.push(path.resolve(casesRoot, 'reader', `${caseId}.md`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return uniquePaths(directCandidates);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Searches case roots for matching case filenames and nested case.md folders.
|
|
106
|
+
function searchCaseMatches(cwd, caseId) {
|
|
107
|
+
const roots = resolveCasesRoots(cwd);
|
|
108
|
+
const expectedMarkdownName = `${caseId}.md`;
|
|
109
|
+
const matches = [];
|
|
110
|
+
|
|
111
|
+
for (const casesRoot of roots) {
|
|
112
|
+
const files = walkFilesRecursive(casesRoot).filter((candidate) => candidate.endsWith('.md'));
|
|
113
|
+
for (const filePath of files) {
|
|
114
|
+
if (path.basename(filePath) === expectedMarkdownName) {
|
|
115
|
+
matches.push(filePath);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (path.basename(filePath) === 'case.md' && path.basename(path.dirname(filePath)) === caseId) {
|
|
120
|
+
matches.push(filePath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return uniquePaths(matches);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Collects known case ids to suggest alternatives when a delete target is missing.
|
|
129
|
+
function collectKnownCaseIds(cwd) {
|
|
130
|
+
const roots = resolveCasesRoots(cwd);
|
|
131
|
+
const ids = new Set();
|
|
132
|
+
|
|
133
|
+
for (const casesRoot of roots) {
|
|
134
|
+
const files = walkFilesRecursive(casesRoot).filter((candidate) => candidate.endsWith('.md'));
|
|
135
|
+
for (const filePath of files) {
|
|
136
|
+
const baseName = path.basename(filePath);
|
|
137
|
+
if (baseName === 'case.md') {
|
|
138
|
+
ids.add(path.basename(path.dirname(filePath)));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ids.add(path.basename(filePath, '.md'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return [...ids].sort((a, b) => a.localeCompare(b));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Suggests nearby case ids using contains/token overlap matching.
|
|
150
|
+
function suggestCaseIds(knownIds, missingId) {
|
|
151
|
+
const needle = String(missingId || '').trim().toLowerCase();
|
|
152
|
+
if (!needle) return [];
|
|
153
|
+
|
|
154
|
+
const tokens = needle.split('-').filter(Boolean);
|
|
155
|
+
const matches = knownIds.filter((candidate) => {
|
|
156
|
+
const lower = candidate.toLowerCase();
|
|
157
|
+
if (lower.includes(needle) || needle.includes(lower)) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hitCount = tokens.filter((token) => lower.includes(token)).length;
|
|
162
|
+
return hitCount >= Math.max(1, Math.min(2, tokens.length));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return matches.slice(0, 8);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Resolves deletion candidates from explicit output path or computed case id lookups.
|
|
169
|
+
function resolveCaseCandidates(cwd, options) {
|
|
170
|
+
if (options.outFile) {
|
|
171
|
+
return [path.resolve(cwd, options.outFile)];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const caseId = toSlug(options.caseId);
|
|
175
|
+
if (!caseId) {
|
|
176
|
+
throw new Error('Missing case target. Use --id <case-slug> or --out <path>.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const directCandidates = resolveIdCandidates(cwd, caseId);
|
|
180
|
+
const directExisting = directCandidates.filter((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
|
181
|
+
if (directExisting.length > 0) {
|
|
182
|
+
return directExisting;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const searched = searchCaseMatches(cwd, caseId);
|
|
186
|
+
if (searched.length > 0) {
|
|
187
|
+
return searched;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return directCandidates;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Returns the first candidate path that currently exists on disk.
|
|
194
|
+
function firstExistingPath(candidates) {
|
|
195
|
+
for (const candidate of candidates) {
|
|
196
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
197
|
+
return candidate;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Detects nested case folder format where file is case.md.
|
|
204
|
+
function isNestedCaseFile(filePath) {
|
|
205
|
+
return path.basename(filePath) === 'case.md';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Removes now-empty parent case directory when safe to do so.
|
|
209
|
+
function removeEmptyParentDirectory(filePath, rootPath) {
|
|
210
|
+
const parentDir = path.dirname(filePath);
|
|
211
|
+
if (path.resolve(parentDir) === path.resolve(rootPath)) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const entries = fs.readdirSync(parentDir);
|
|
216
|
+
if (entries.length === 0) {
|
|
217
|
+
fs.rmdirSync(parentDir);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// MARK: CASE DELETION WORKFLOW
|
|
222
|
+
// Deletes a case markdown file (or full case directory in force mode) with diagnostics.
|
|
223
|
+
export function runDeleteCase(options = {}) {
|
|
224
|
+
const cwd = options.cwd || process.cwd();
|
|
225
|
+
const force = options.force === true;
|
|
226
|
+
const requestedCaseId = toSlug(options.caseId);
|
|
227
|
+
const candidates = resolveCaseCandidates(cwd, options);
|
|
228
|
+
const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
|
229
|
+
|
|
230
|
+
if (existingCandidates.length > 1 && !options.outFile) {
|
|
231
|
+
throw new Error(`Multiple case matches found for --id ${String(options.caseId || '')}. Use --out with one of: ${existingCandidates.join(', ')}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const targetPath = firstExistingPath(candidates);
|
|
235
|
+
|
|
236
|
+
if (!targetPath) {
|
|
237
|
+
const knownIds = collectKnownCaseIds(cwd);
|
|
238
|
+
const suggestedIds = suggestCaseIds(knownIds, requestedCaseId);
|
|
239
|
+
return {
|
|
240
|
+
removedPath: '',
|
|
241
|
+
removedDirectory: false,
|
|
242
|
+
removedDirectoryPath: '',
|
|
243
|
+
alreadyMissing: true,
|
|
244
|
+
checkedCandidates: candidates,
|
|
245
|
+
requestedCaseId,
|
|
246
|
+
suggestedIds
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const casesRoots = resolveCasesRoots(cwd);
|
|
251
|
+
const absoluteCasesRoot = casesRoots.find((rootPath) => targetPath.startsWith(`${rootPath}${path.sep}`)) || path.dirname(targetPath);
|
|
252
|
+
|
|
253
|
+
if (isNestedCaseFile(targetPath)) {
|
|
254
|
+
const caseDir = path.dirname(targetPath);
|
|
255
|
+
const siblingEntries = fs.readdirSync(caseDir).filter((entry) => entry !== 'case.md');
|
|
256
|
+
|
|
257
|
+
if (siblingEntries.length > 0 && !force) {
|
|
258
|
+
throw new Error(`Case directory ${caseDir} has additional files. Re-run with --force to remove the full case directory.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (siblingEntries.length > 0 && force) {
|
|
262
|
+
fs.rmSync(caseDir, { recursive: true, force: false });
|
|
263
|
+
return {
|
|
264
|
+
removedPath: targetPath,
|
|
265
|
+
removedDirectory: true,
|
|
266
|
+
removedDirectoryPath: caseDir,
|
|
267
|
+
alreadyMissing: false,
|
|
268
|
+
checkedCandidates: [],
|
|
269
|
+
requestedCaseId,
|
|
270
|
+
suggestedIds: []
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fs.rmSync(targetPath);
|
|
275
|
+
removeEmptyParentDirectory(targetPath, absoluteCasesRoot);
|
|
276
|
+
return {
|
|
277
|
+
removedPath: targetPath,
|
|
278
|
+
removedDirectory: false,
|
|
279
|
+
removedDirectoryPath: '',
|
|
280
|
+
alreadyMissing: false,
|
|
281
|
+
checkedCandidates: [],
|
|
282
|
+
requestedCaseId,
|
|
283
|
+
suggestedIds: []
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fs.rmSync(targetPath);
|
|
288
|
+
return {
|
|
289
|
+
removedPath: targetPath,
|
|
290
|
+
removedDirectory: false,
|
|
291
|
+
removedDirectoryPath: '',
|
|
292
|
+
alreadyMissing: false,
|
|
293
|
+
checkedCandidates: [],
|
|
294
|
+
requestedCaseId,
|
|
295
|
+
suggestedIds: []
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// MARK: SCRIPT ENTRYPOINT
|
|
300
|
+
// CLI entrypoint for delete-case script usage.
|
|
301
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
302
|
+
const args = parseArgs(process.argv.slice(2));
|
|
303
|
+
try {
|
|
304
|
+
const result = runDeleteCase({ caseId: args.id, outFile: args.outFile, force: args.force });
|
|
305
|
+
if (result.alreadyMissing) {
|
|
306
|
+
console.log('[delete-case] Nothing to delete. Target is already missing.');
|
|
307
|
+
if (result.suggestedIds.length > 0) {
|
|
308
|
+
console.log(`[delete-case] Similar existing case ids: ${result.suggestedIds.join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
console.log(`[delete-case] Removed ${result.removedPath}.`);
|
|
312
|
+
if (result.removedDirectory) {
|
|
313
|
+
console.log(`[delete-case] Removed case directory ${result.removedDirectoryPath}.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.error('[delete-case] Failed:', error.message || error);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// File: scripts/delete-language.mjs
|
|
3
|
+
// Purpose: Remove a locale from i18n config and prune locale entries from labels/about/cases.
|
|
4
|
+
// Author: Lio Schimanko
|
|
5
|
+
|
|
6
|
+
// MARK: IMPORTS
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { 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 DEFAULT_LOCALE_REGEX = /export\s+const\s+DEFAULT_LOCALE\s*=\s*['\"]([^'\"]+)['\"]/;
|
|
14
|
+
const RTL_LOCALE_CODES_REGEX = /export\s+const\s+RTL_LOCALE_CODES\s*=\s*Object\.freeze\(\s*(\[[\s\S]*?\])\s*\);/;
|
|
15
|
+
|
|
16
|
+
// MARK: ARGUMENT AND LITERAL PARSING
|
|
17
|
+
// Parses object-like config literals using JSON first with JS-literal fallback.
|
|
18
|
+
function parseConfigLiteral(literalText, fallbackValue = null) {
|
|
19
|
+
const raw = String(literalText || '').trim();
|
|
20
|
+
if (!raw) return fallbackValue;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
const evaluateLiteral = new Function(`return (${raw});`);
|
|
27
|
+
return evaluateLiteral();
|
|
28
|
+
} catch {
|
|
29
|
+
return fallbackValue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parses delete-language CLI arguments and optional force mode.
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const parsed = {
|
|
37
|
+
code: '',
|
|
38
|
+
force: false
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
42
|
+
const token = argv[i];
|
|
43
|
+
|
|
44
|
+
if (token === '--code') {
|
|
45
|
+
parsed.code = String(argv[i + 1] || '').trim().toLowerCase();
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (token === '--force') {
|
|
51
|
+
parsed.force = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validates locale identifier format before deleting entries.
|
|
60
|
+
function validateLocaleCode(localeCode) {
|
|
61
|
+
return /^[a-z]{2,3}(?:-[a-z0-9]+)*$/i.test(String(localeCode || '').trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Rewrites DEFAULT_LOCALE export when deleting the active default locale.
|
|
65
|
+
function replaceDefaultLocale(source, nextDefaultLocale) {
|
|
66
|
+
if (!DEFAULT_LOCALE_REGEX.test(source)) {
|
|
67
|
+
return source;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return source.replace(DEFAULT_LOCALE_REGEX, `export const DEFAULT_LOCALE = '${nextDefaultLocale}'`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: LANGUAGE REMOVAL WORKFLOW
|
|
74
|
+
// Removes one locale from i18n config and syncs locale-bound content maps.
|
|
75
|
+
export async function runDeleteLanguage(options = {}) {
|
|
76
|
+
const cwd = options.cwd || process.cwd();
|
|
77
|
+
const localeCode = String(options.code || '').trim().toLowerCase();
|
|
78
|
+
|
|
79
|
+
if (!validateLocaleCode(localeCode)) {
|
|
80
|
+
throw new Error('Invalid locale code. Use values like "it", "es", or "pt-BR".');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const i18nConfigPath = resolveI18nConfigPath(cwd);
|
|
84
|
+
if (!fs.existsSync(i18nConfigPath)) {
|
|
85
|
+
throw new Error(`Could not find i18n config file at ${i18nConfigPath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const source = fs.readFileSync(i18nConfigPath, 'utf8');
|
|
89
|
+
const languageConfigMatch = source.match(LANGUAGE_CONFIG_REGEX);
|
|
90
|
+
if (!languageConfigMatch?.[1]) {
|
|
91
|
+
throw new Error(`LANGUAGE_CONFIG block was not found in ${i18nConfigPath}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const defaultLocaleMatch = source.match(DEFAULT_LOCALE_REGEX);
|
|
95
|
+
const currentDefaultLocale = String(defaultLocaleMatch?.[1] || 'en').trim().toLowerCase();
|
|
96
|
+
|
|
97
|
+
let languageConfig = {};
|
|
98
|
+
let rtlLocaleCodes = [];
|
|
99
|
+
languageConfig = parseConfigLiteral(languageConfigMatch[1], null);
|
|
100
|
+
if (!languageConfig || typeof languageConfig !== 'object' || Array.isArray(languageConfig)) {
|
|
101
|
+
throw new Error(`LANGUAGE_CONFIG is not valid JSON in ${i18nConfigPath}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rtlLocaleCodesMatch = source.match(RTL_LOCALE_CODES_REGEX);
|
|
105
|
+
if (rtlLocaleCodesMatch?.[1]) {
|
|
106
|
+
const parsed = parseConfigLiteral(rtlLocaleCodesMatch[1], null);
|
|
107
|
+
if (Array.isArray(parsed)) {
|
|
108
|
+
rtlLocaleCodes = parsed;
|
|
109
|
+
} else {
|
|
110
|
+
throw new Error(`RTL_LOCALE_CODES is not valid JSON in ${i18nConfigPath}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!languageConfig[localeCode]) {
|
|
115
|
+
return {
|
|
116
|
+
removedLocale: localeCode,
|
|
117
|
+
defaultLocaleBefore: currentDefaultLocale,
|
|
118
|
+
defaultLocaleAfter: currentDefaultLocale,
|
|
119
|
+
i18nConfigPath,
|
|
120
|
+
localeCodes: Object.keys(languageConfig),
|
|
121
|
+
syncResult: { updatedFiles: 0, localeCodes: Object.keys(languageConfig) },
|
|
122
|
+
alreadyMissing: true
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (localeCode === currentDefaultLocale && options.force !== true) {
|
|
127
|
+
throw new Error(`Locale ${localeCode} is the DEFAULT_LOCALE. Use --force to remove it and reassign default locale.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const nextLanguageConfig = { ...languageConfig };
|
|
131
|
+
delete nextLanguageConfig[localeCode];
|
|
132
|
+
|
|
133
|
+
const remainingLocales = Object.keys(nextLanguageConfig);
|
|
134
|
+
if (remainingLocales.length === 0) {
|
|
135
|
+
throw new Error('Cannot remove the last remaining locale.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const nextDefaultLocale = localeCode === currentDefaultLocale
|
|
139
|
+
? remainingLocales[0]
|
|
140
|
+
: currentDefaultLocale;
|
|
141
|
+
|
|
142
|
+
const nextLanguageConfigBlock = `export const LANGUAGE_CONFIG = Object.freeze(${JSON.stringify(nextLanguageConfig, null, 2)});`;
|
|
143
|
+
let nextSource = source.replace(LANGUAGE_CONFIG_REGEX, nextLanguageConfigBlock);
|
|
144
|
+
const nextRtlLocaleCodes = rtlLocaleCodes
|
|
145
|
+
.map((value) => String(value || '').trim().toLowerCase())
|
|
146
|
+
.filter((value, index, list) => value.length > 0 && list.indexOf(value) === index)
|
|
147
|
+
.filter((value) => value !== localeCode)
|
|
148
|
+
.sort((a, b) => a.localeCompare(b));
|
|
149
|
+
if (RTL_LOCALE_CODES_REGEX.test(nextSource)) {
|
|
150
|
+
const rtlBlock = `export const RTL_LOCALE_CODES = Object.freeze(${JSON.stringify(nextRtlLocaleCodes, null, 2)});`;
|
|
151
|
+
nextSource = nextSource.replace(RTL_LOCALE_CODES_REGEX, rtlBlock);
|
|
152
|
+
}
|
|
153
|
+
nextSource = replaceDefaultLocale(nextSource, nextDefaultLocale);
|
|
154
|
+
|
|
155
|
+
if (nextSource !== source) {
|
|
156
|
+
fs.writeFileSync(i18nConfigPath, nextSource, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const syncResult = await runLocaleSync({
|
|
160
|
+
cwd,
|
|
161
|
+
pruneLocales: true
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
removedLocale: localeCode,
|
|
166
|
+
defaultLocaleBefore: currentDefaultLocale,
|
|
167
|
+
defaultLocaleAfter: nextDefaultLocale,
|
|
168
|
+
i18nConfigPath,
|
|
169
|
+
localeCodes: remainingLocales,
|
|
170
|
+
syncResult,
|
|
171
|
+
alreadyMissing: false
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// MARK: SCRIPT ENTRYPOINT
|
|
176
|
+
// CLI entrypoint for delete-language script usage.
|
|
177
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
178
|
+
const args = parseArgs(process.argv.slice(2));
|
|
179
|
+
|
|
180
|
+
runDeleteLanguage(args)
|
|
181
|
+
.then((result) => {
|
|
182
|
+
if (result.alreadyMissing) {
|
|
183
|
+
console.log(`[delete-language] Nothing to delete. Locale ${result.removedLocale} is already missing.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log(`[delete-language] Removed ${result.removedLocale}.`);
|
|
187
|
+
console.log(`[delete-language] Default locale: ${result.defaultLocaleBefore} -> ${result.defaultLocaleAfter}`);
|
|
188
|
+
console.log(`[delete-language] Locales: ${result.localeCodes.join(', ')}`);
|
|
189
|
+
console.log(`[delete-language] Synced ${result.syncResult.updatedFiles} file(s).`);
|
|
190
|
+
})
|
|
191
|
+
.catch((error) => {
|
|
192
|
+
console.error('[delete-language] Failed:', error.message || error);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
195
|
+
}
|