@techninja/clearstack 0.4.3 → 0.4.5
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/README.md +14 -3
- package/bin/cli.js +8 -3
- package/docs/I18N.md +372 -111
- package/lib/check.js +54 -21
- package/lib/server-proc.js +150 -0
- package/lib/spec-config.js +48 -4
- package/lib/spec-i18n-locales.js +86 -0
- package/lib/spec-i18n.js +60 -62
- package/lib/spec-scan.js +101 -0
- package/lib/spec-utils.js +12 -107
- package/lib/watch-lifecycle.js +47 -0
- package/lib/watch-ui.js +148 -0
- package/lib/watch-widgets.js +82 -0
- package/lib/watch.js +131 -0
- package/package.json +7 -2
- package/templates/shared/docs/clearstack/I18N.md +372 -111
package/lib/check.js
CHANGED
|
@@ -6,15 +6,41 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync } from 'node:fs';
|
|
8
8
|
import { resolve } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { runCmd } from './spec-utils.js';
|
|
10
|
+
import { countFiles, checkFileLines, checkImports } from './spec-scan.js';
|
|
10
11
|
import { checkI18n } from './spec-i18n.js';
|
|
11
12
|
import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
12
13
|
|
|
13
|
-
export {
|
|
14
|
+
export { runCmd, elapsed } from './spec-utils.js';
|
|
15
|
+
export { findFiles, countFiles, checkFileLines, checkImports } from './spec-scan.js';
|
|
14
16
|
export { checkI18n } from './spec-i18n.js';
|
|
15
17
|
export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Load project-level spec extensions from `clearstack.spec.js` in the project root.
|
|
21
|
+
* The file may export a default array of Check objects. Any check whose key matches
|
|
22
|
+
* a built-in check replaces it; new keys are appended.
|
|
23
|
+
* @param {string} projectDir
|
|
24
|
+
* @returns {Promise<Check[]>}
|
|
25
|
+
*/
|
|
26
|
+
async function loadExtensions(projectDir) {
|
|
27
|
+
const extPath = resolve(projectDir, 'clearstack.spec.js');
|
|
28
|
+
if (!existsSync(extPath)) return [];
|
|
29
|
+
try {
|
|
30
|
+
const mod = await import(extPath);
|
|
31
|
+
const exts = mod.default ?? [];
|
|
32
|
+
if (!Array.isArray(exts)) {
|
|
33
|
+
console.warn('⚠ clearstack.spec.js default export must be an array — extensions ignored');
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return exts;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.warn(`⚠ Failed to load clearstack.spec.js: ${e.message}`);
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @typedef {{ key: string, name: string, parent?: string, watchExts?: string[], run: (opts?: object) => (boolean | import('./spec-utils.js').CheckResult | Promise<import('./spec-utils.js').CheckResult>) }} Check */
|
|
18
44
|
|
|
19
45
|
/** Find all jsconfig.json files — main config + subdirectories. */
|
|
20
46
|
function findTypeConfigs(dir, runner) {
|
|
@@ -28,42 +54,47 @@ function findTypeConfigs(dir, runner) {
|
|
|
28
54
|
}
|
|
29
55
|
return configs.map((c) => ({
|
|
30
56
|
key: c.key, name: `JSDoc types — ${c.label}`, parent: 'types',
|
|
31
|
-
run: () => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path}`, dir)
|
|
57
|
+
run: (o) => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path} --noEmit`, dir, undefined, o),
|
|
32
58
|
}));
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
/**
|
|
36
62
|
* Build the unified checks array. Children have a `parent` key.
|
|
37
|
-
*
|
|
63
|
+
* Extensions from clearstack.spec.js are merged in: same key = replace, new key = append.
|
|
64
|
+
* @returns {Promise<Check[]>}
|
|
38
65
|
*/
|
|
39
|
-
export function buildChecks(dir, cfg, cmds) {
|
|
66
|
+
export async function buildChecks(dir, cfg, cmds) {
|
|
40
67
|
const js = () => countFiles(dir, ['.js'], cfg.ignore);
|
|
41
68
|
const css = () => countFiles(dir, ['.css'], cfg.ignore);
|
|
42
69
|
const md = () => countFiles(dir, ['.md'], cfg.ignore);
|
|
43
70
|
const runner = detectRunner(dir);
|
|
44
|
-
|
|
71
|
+
const builtin = [
|
|
45
72
|
{ key: 'es', name: 'ESLint', parent: 'lint',
|
|
46
|
-
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files
|
|
73
|
+
run: (o) => runCmd('ESLint', cmds.lint, dir, `${js()} files`, o) },
|
|
47
74
|
{ key: 'css', name: 'Stylelint', parent: 'lint',
|
|
48
|
-
run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files
|
|
75
|
+
run: (o) => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`, o) },
|
|
49
76
|
{ key: 'md', name: 'Markdown lint', parent: 'lint',
|
|
50
|
-
run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files
|
|
77
|
+
run: (o) => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`, o) },
|
|
51
78
|
{ key: 'prettier', name: 'Prettier', parent: 'format',
|
|
52
|
-
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files
|
|
79
|
+
run: (o) => runCmd('Prettier', cmds.prettier, dir, `${js()} files`, o) },
|
|
53
80
|
{ key: 'lines', name: `Code (max ${cfg.codeMax} lines)`, parent: 'code',
|
|
54
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern })
|
|
81
|
+
run: (o) => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern, ...o }) },
|
|
55
82
|
{ key: 'i18n', name: 'i18n readiness', parent: 'code',
|
|
56
|
-
run: (
|
|
83
|
+
run: (o) => checkI18n(dir, cfg.ignore, 'i18n readiness', o) },
|
|
57
84
|
{ key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
|
|
58
|
-
run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern })
|
|
85
|
+
run: (o) => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern, ...o }) },
|
|
59
86
|
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
60
|
-
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)
|
|
87
|
+
run: (o) => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`, o) },
|
|
61
88
|
{ key: 'imports', name: 'Import map aliases (no ../ imports)',
|
|
62
|
-
run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)')
|
|
89
|
+
run: (o) => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)', o) },
|
|
63
90
|
...findTypeConfigs(dir, runner),
|
|
64
91
|
{ key: 'audit', name: 'Security audit',
|
|
65
|
-
run: () => runCmd('Security audit', cmds.audit, dir)
|
|
92
|
+
run: (o) => runCmd('Security audit', cmds.audit, dir, undefined, o) },
|
|
66
93
|
];
|
|
94
|
+
const extensions = await loadExtensions(dir);
|
|
95
|
+
if (!extensions.length) return builtin;
|
|
96
|
+
const extKeys = new Set(extensions.map((e) => e.key));
|
|
97
|
+
return [...builtin.filter((c) => !extKeys.has(c.key)), ...extensions];
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
/** Resolve a scope like 'lint', 'lint es', or 'code' to runnable check(s). */
|
|
@@ -88,7 +119,7 @@ export function parentKeys(checks) {
|
|
|
88
119
|
export async function check(projectDir, scope, opts) {
|
|
89
120
|
const cfg = loadConfig(projectDir);
|
|
90
121
|
const cmds = buildCmds(projectDir);
|
|
91
|
-
const checks = buildChecks(projectDir, cfg, cmds);
|
|
122
|
+
const checks = await buildChecks(projectDir, cfg, cmds);
|
|
92
123
|
|
|
93
124
|
if (scope && scope !== 'all') {
|
|
94
125
|
const matched = resolveChecks(checks, scope);
|
|
@@ -98,14 +129,16 @@ export async function check(projectDir, scope, opts) {
|
|
|
98
129
|
console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
|
|
99
130
|
process.exit(1);
|
|
100
131
|
}
|
|
101
|
-
const
|
|
132
|
+
const results = await Promise.all(matched.map((c) => c.run(opts)));
|
|
133
|
+
const ok = results.every((r) => typeof r === 'boolean' ? r : r.pass);
|
|
102
134
|
if (!ok) process.exit(1);
|
|
103
135
|
return;
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
console.log('🔍 Clearstack compliance checking now... 💙\n');
|
|
107
|
-
const results = checks.map((c) => c.run(opts));
|
|
108
|
-
const
|
|
139
|
+
const results = await Promise.all(checks.map((c) => c.run(opts)));
|
|
140
|
+
const passes = results.map((r) => typeof r === 'boolean' ? r : r.pass);
|
|
141
|
+
const passed = passes.filter(Boolean).length;
|
|
109
142
|
console.log(`\n${'='.repeat(40)}`);
|
|
110
143
|
console.log(`${passed}/${results.length} checks passed.`);
|
|
111
144
|
if (passed < results.length) process.exit(1);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev server child process manager for spec watch dashboard.
|
|
3
|
+
* Spawns the server, captures output, and maintains a status row
|
|
4
|
+
* that the dashboard can render without any extra logic.
|
|
5
|
+
* @module lib/server-proc
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, execSync } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect the dev server command. Prefers SPEC_SERVER_CMD from env,
|
|
14
|
+
* then probes common entry points. Returns null for static projects.
|
|
15
|
+
* @param {string} dir @param {object} cfg @returns {string|null}
|
|
16
|
+
*/
|
|
17
|
+
export function detectServerCmd(dir, cfg) {
|
|
18
|
+
if (cfg.serverCmd) return cfg.serverCmd;
|
|
19
|
+
for (const entry of ['src/server.js', 'server.js', 'index.js']) {
|
|
20
|
+
if (existsSync(resolve(dir, entry))) return `node --watch ${entry}`;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kill whatever process is listening on a given port.
|
|
27
|
+
* No-ops silently if nothing is found.
|
|
28
|
+
* @param {number} port
|
|
29
|
+
*/
|
|
30
|
+
export function killPort(port) {
|
|
31
|
+
try {
|
|
32
|
+
const pid = execSync(`lsof -ti tcp:${port}`, { encoding: 'utf-8' }).trim();
|
|
33
|
+
if (pid) execSync(`kill ${pid}`);
|
|
34
|
+
} catch { /* nothing listening, or kill already gone */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @typedef {'starting'|'running'|'restarting'|'crashed'|'disabled'} ServerStatus */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} ServerRow
|
|
41
|
+
* @property {'server'} key
|
|
42
|
+
* @property {string} label
|
|
43
|
+
* @property {ServerStatus} status
|
|
44
|
+
* @property {boolean|null} pass
|
|
45
|
+
* @property {string} detail
|
|
46
|
+
* @property {string} [_errLine]
|
|
47
|
+
* @property {() => void} kill
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** Lines from Node --watch we don't want to surface as the detail. */
|
|
51
|
+
const NOISE = /^Debugger|^Waiting for|^\s*$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spawn the dev server and return a live ServerRow.
|
|
55
|
+
* The row object is mutated in place as output arrives — the dashboard
|
|
56
|
+
* just reads it on each render cycle.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} cmd e.g. 'node --watch src/server.js'
|
|
59
|
+
* @param {string} cwd project root
|
|
60
|
+
* @param {object} env merged into process.env for the child
|
|
61
|
+
* @param {() => void} onUpdate called whenever status/detail changes
|
|
62
|
+
* @returns {ServerRow}
|
|
63
|
+
*/
|
|
64
|
+
export function spawnServer(cmd, cwd, env, onUpdate) {
|
|
65
|
+
/** @type {ServerRow} */
|
|
66
|
+
const row = {
|
|
67
|
+
key: 'server',
|
|
68
|
+
label: 'server',
|
|
69
|
+
status: 'starting',
|
|
70
|
+
pass: null,
|
|
71
|
+
detail: 'starting…',
|
|
72
|
+
kill: () => {},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const [bin, ...args] = cmd.split(/\s+/);
|
|
76
|
+
const child = spawn(bin, args, {
|
|
77
|
+
cwd,
|
|
78
|
+
env: { ...process.env, ...env, FORCE_COLOR: '0' },
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
row.kill = () => child.kill('SIGTERM');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
*
|
|
86
|
+
*/
|
|
87
|
+
function onLine(line, isStderr) {
|
|
88
|
+
line = line.trim();
|
|
89
|
+
if (!line || NOISE.test(line)) return;
|
|
90
|
+
|
|
91
|
+
if (/restarting/i.test(line)) {
|
|
92
|
+
row.status = 'restarting';
|
|
93
|
+
row.pass = null;
|
|
94
|
+
row.detail = 'restarting…';
|
|
95
|
+
onUpdate();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Errors on stderr always override — don't let a stale URL persist
|
|
99
|
+
if (isStderr || /error|EADDR|EACCES|ENOENT/i.test(line)) {
|
|
100
|
+
row.status = 'crashed';
|
|
101
|
+
row.pass = false;
|
|
102
|
+
// Prefer 'Error: ...' lines; only upgrade, never downgrade
|
|
103
|
+
const isRich = /^Error:/i.test(line);
|
|
104
|
+
if (!row._errLine || (isRich && !/^Error:/i.test(row._errLine))) {
|
|
105
|
+
row._errLine = line.slice(0, 60);
|
|
106
|
+
row.detail = row._errLine;
|
|
107
|
+
onUpdate();
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (/https?:\/\//i.test(line) || /listening|started|ready/i.test(line)) {
|
|
112
|
+
row.status = 'running';
|
|
113
|
+
row.pass = true;
|
|
114
|
+
row.detail = line;
|
|
115
|
+
onUpdate();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (row.status !== 'running') {
|
|
119
|
+
row.detail = line.slice(0, 60);
|
|
120
|
+
onUpdate();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
child.stdout?.setEncoding('utf-8');
|
|
125
|
+
child.stderr?.setEncoding('utf-8');
|
|
126
|
+
/** @type {Array<[import('stream').Readable|null, boolean]>} */
|
|
127
|
+
const streams = [[child.stdout, false], [child.stderr, true]];
|
|
128
|
+
for (const [stream, isStderr] of streams) {
|
|
129
|
+
if (!stream) continue;
|
|
130
|
+
let buf = '';
|
|
131
|
+
stream.on('data', (chunk) => {
|
|
132
|
+
buf += chunk;
|
|
133
|
+
const lines = buf.split('\n');
|
|
134
|
+
buf = lines.pop();
|
|
135
|
+
for (const l of lines) onLine(l, isStderr);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
child.on('close', (code, signal) => {
|
|
140
|
+
if (signal === 'SIGTERM') return;
|
|
141
|
+
row.status = 'crashed';
|
|
142
|
+
row.pass = false;
|
|
143
|
+
if (!row.detail || row.detail === 'starting…' || row.detail === 'restarting…') {
|
|
144
|
+
row.detail = `exited (code ${code ?? signal})`;
|
|
145
|
+
}
|
|
146
|
+
onUpdate();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return row;
|
|
150
|
+
}
|
package/lib/spec-config.js
CHANGED
|
@@ -35,6 +35,8 @@ export function loadConfig(projectDir) {
|
|
|
35
35
|
const local = existsSync(localPath) ? parseEnv(readFileSync(localPath, 'utf-8')) : {};
|
|
36
36
|
const env = { ...base, ...local };
|
|
37
37
|
return {
|
|
38
|
+
serverCmd: env.SPEC_SERVER_CMD || null,
|
|
39
|
+
rawEnv: env,
|
|
38
40
|
codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
|
|
39
41
|
testMax: parseInt(env.SPEC_TEST_MAX_LINES) || 300,
|
|
40
42
|
docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
|
|
@@ -45,14 +47,56 @@ export function loadConfig(projectDir) {
|
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Default ext→check-key mapping for built-ins.
|
|
52
|
+
* Extensions override this by declaring a `watchExts` array on their check object.
|
|
53
|
+
* @type {Record<string, string[]>}
|
|
54
|
+
*/
|
|
55
|
+
const EXT_DEFAULTS = {
|
|
56
|
+
'.js': ['lines', 'es', 'prettier', 'frontend', 'imports', 'i18n'],
|
|
57
|
+
'.mjs': ['lines', 'es', 'frontend', 'imports'],
|
|
58
|
+
'.css': ['lines', 'css'],
|
|
59
|
+
'.md': ['docs', 'md'],
|
|
60
|
+
'.json': ['i18n'],
|
|
61
|
+
'.php': [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Checks that run on pure file reads — no subprocess, sub-millisecond. */
|
|
65
|
+
export const FAST_CHECKS = new Set(['lines', 'docs', 'imports', 'i18n', 'tests']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build ext→keys map, merging EXT_DEFAULTS with `watchExts` declared on extension checks.
|
|
69
|
+
* @param {object[]} checks
|
|
70
|
+
* @returns {(ext: string) => string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function makeExtMap(checks) {
|
|
73
|
+
const map = new Map(Object.entries(EXT_DEFAULTS).map(([k, v]) => [k, new Set(v)]));
|
|
74
|
+
for (const c of checks) {
|
|
75
|
+
if (!c.watchExts) continue;
|
|
76
|
+
for (const ext of c.watchExts) {
|
|
77
|
+
if (!map.has(ext)) map.set(ext, new Set());
|
|
78
|
+
map.get(ext).add(c.key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return (ext) => [...(map.get(ext) ?? [])];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} projectDir
|
|
86
|
+
* @param {{ watch?: boolean }} [opts]
|
|
87
|
+
*/
|
|
88
|
+
export function buildCmds(projectDir, opts) {
|
|
50
89
|
const runner = detectRunner(projectDir);
|
|
51
|
-
// pnpm 9.x audit hits a retired npm endpoint (410). Use --ignore-registry-errors
|
|
52
|
-
// to avoid failing on registry issues outside the user's control.
|
|
53
90
|
const audit = runner === 'pnpm exec'
|
|
54
91
|
? 'pnpm audit --prod --ignore-registry-errors'
|
|
55
92
|
: 'npm audit --omit=dev';
|
|
93
|
+
if (opts?.watch) return {
|
|
94
|
+
lint: `${runner} eslint --config .configs/eslint.config.js .`,
|
|
95
|
+
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css"`,
|
|
96
|
+
prettier: `${runner} prettier --config .configs/.prettierrc --check src scripts`,
|
|
97
|
+
mdlint: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc "docs/**/*.md" "*.md"`,
|
|
98
|
+
audit,
|
|
99
|
+
};
|
|
56
100
|
return {
|
|
57
101
|
lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
|
|
58
102
|
stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale file parsing helpers for i18n spec check.
|
|
3
|
+
* @module lib/spec-i18n-locales
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Try to run `npx hybrids extract ./src` and parse the JSON output.
|
|
15
|
+
* @param {string} root
|
|
16
|
+
* @returns {Promise<Record<string, object>|null>}
|
|
17
|
+
*/
|
|
18
|
+
export async function tryHybridsExtract(root) {
|
|
19
|
+
const srcDir = resolve(root, 'src');
|
|
20
|
+
if (!existsSync(srcDir)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('npx', ['hybrids', 'extract', './src'], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
encoding: 'utf-8',
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
});
|
|
27
|
+
return JSON.parse(stdout);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse locale keys from a .js file that calls localize(lang, {...}).
|
|
35
|
+
* Extracts top-level string keys from the object literal.
|
|
36
|
+
* @param {string} filePath
|
|
37
|
+
* @returns {Set<string>}
|
|
38
|
+
*/
|
|
39
|
+
export function parseLocaleJs(filePath) {
|
|
40
|
+
const src = readFileSync(filePath, 'utf-8');
|
|
41
|
+
const keys = new Set();
|
|
42
|
+
const re = /^\s*(['"])(.*?)\1\s*:/gm;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(src)) !== null) {
|
|
45
|
+
const key = m[2].replace(/\\'/g, "'").replace(/\\"/g, '"');
|
|
46
|
+
keys.add(key);
|
|
47
|
+
}
|
|
48
|
+
return keys;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse locale keys from a .json file.
|
|
53
|
+
* @param {string} filePath
|
|
54
|
+
* @returns {Set<string>}
|
|
55
|
+
*/
|
|
56
|
+
export function parseLocaleJson(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
59
|
+
return new Set(Object.keys(data));
|
|
60
|
+
} catch {
|
|
61
|
+
return new Set();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find locale files and return { lang, keys } for each.
|
|
67
|
+
* @param {string} localesDir
|
|
68
|
+
* @returns {Array<{lang: string, keys: Set<string>, file: string}>}
|
|
69
|
+
*/
|
|
70
|
+
export function loadLocales(localesDir) {
|
|
71
|
+
if (!localesDir) return [];
|
|
72
|
+
const files = readdirSync(localesDir);
|
|
73
|
+
const locales = [];
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
if (f === 'overrides.json' || f === 'en.json') continue;
|
|
76
|
+
const full = resolve(localesDir, f);
|
|
77
|
+
if (f.endsWith('.json')) {
|
|
78
|
+
const lang = f.replace(/\.json$/, '');
|
|
79
|
+
locales.push({ lang, keys: parseLocaleJson(full), file: f });
|
|
80
|
+
} else if (f.endsWith('.js') && !f.includes('init')) {
|
|
81
|
+
const lang = f.replace(/\.js$/, '');
|
|
82
|
+
locales.push({ lang, keys: parseLocaleJs(full), file: f });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return locales;
|
|
86
|
+
}
|
package/lib/spec-i18n.js
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* i18n readiness check —
|
|
2
|
+
* i18n readiness check — uses Hybrids extract when available, falls back to regex.
|
|
3
3
|
* Always passes (informational). Surfaces coverage gaps without blocking the build.
|
|
4
4
|
* @module lib/spec-i18n
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readFileSync,
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
8
|
import { resolve } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { elapsed } from './spec-utils.js';
|
|
10
|
+
import { findFiles } from './spec-scan.js';
|
|
11
|
+
import { tryHybridsExtract, loadLocales, parseLocaleJson } from './spec-i18n-locales.js';
|
|
10
12
|
|
|
11
13
|
/** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
|
|
12
14
|
|
|
13
|
-
const PROSE_RE = />\s*[A-Z][a-z]{2,}[^<$`\\]{3,}\s*[<$]/g;
|
|
14
15
|
const T_RE = /\bt\s*\(/;
|
|
15
16
|
const MSG_RE = /\bmsg`/;
|
|
16
17
|
const LOC_RE = /\blocalize\s*\(/;
|
|
17
|
-
const INIT_RE = /loadLocale|localize\s*\(/;
|
|
18
|
-
const TPL_RE = /html`[\s\S]*?`/g;
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Extract keys from the DEFAULTS object in a t()-based i18n.js file.
|
|
21
|
+
* @param {string} root
|
|
22
|
+
* @returns {string[]|null}
|
|
23
|
+
*/
|
|
24
|
+
function extractTDefaults(root) {
|
|
25
|
+
const candidates = ['src/utils/i18n.js', 'src/i18n.js', 'utils/i18n.js'];
|
|
26
|
+
for (const rel of candidates) {
|
|
27
|
+
const p = resolve(root, rel);
|
|
28
|
+
if (!existsSync(p)) continue;
|
|
29
|
+
const src = readFileSync(p, 'utf-8');
|
|
30
|
+
const block = src.match(/const DEFAULTS\s*=\s*\{([\s\S]*?)\};/);
|
|
31
|
+
if (!block) continue;
|
|
32
|
+
const keys = [];
|
|
33
|
+
const re = /['"]([\.\w]+)['"]\s*:/g;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = re.exec(block[1])) !== null) keys.push(m[1]);
|
|
36
|
+
if (keys.length) return keys;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
22
40
|
|
|
23
41
|
/**
|
|
24
42
|
* Check i18n readiness — informational, always passes.
|
|
@@ -26,10 +44,11 @@ const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
|
|
|
26
44
|
* @param {string[]} ignoreDirs
|
|
27
45
|
* @param {string} label
|
|
28
46
|
* @param {{ quiet?: boolean, verbose?: boolean }} [opts]
|
|
29
|
-
* @returns {CheckResult}
|
|
47
|
+
* @returns {Promise<CheckResult>}
|
|
30
48
|
*/
|
|
31
|
-
export function checkI18n(root, ignoreDirs, label, opts) {
|
|
49
|
+
export async function checkI18n(root, ignoreDirs, label, opts) {
|
|
32
50
|
const start = performance.now();
|
|
51
|
+
|
|
33
52
|
const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
|
|
34
53
|
(f) =>
|
|
35
54
|
f.startsWith('src/') &&
|
|
@@ -38,39 +57,12 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
38
57
|
!f.endsWith('.test.js'),
|
|
39
58
|
);
|
|
40
59
|
|
|
41
|
-
let usesT = 0,
|
|
42
|
-
usesMsg = 0,
|
|
43
|
-
usesLocalize = 0,
|
|
44
|
-
hasInit = false,
|
|
45
|
-
hardcodedHits = 0,
|
|
46
|
-
templateFiles = 0;
|
|
47
|
-
/** @type {Array<{file: string, text: string, line: number}>} */
|
|
48
|
-
const verboseHits = [];
|
|
49
|
-
|
|
60
|
+
let usesT = 0, usesMsg = 0, usesLocalize = 0;
|
|
50
61
|
for (const file of srcFiles) {
|
|
51
62
|
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
52
63
|
if (T_RE.test(src)) usesT++;
|
|
53
64
|
if (MSG_RE.test(src)) usesMsg++;
|
|
54
65
|
if (LOC_RE.test(src)) usesLocalize++;
|
|
55
|
-
if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
|
|
56
|
-
const templates = src.match(TPL_RE);
|
|
57
|
-
if (templates) {
|
|
58
|
-
templateFiles++;
|
|
59
|
-
for (const tpl of templates) {
|
|
60
|
-
const hits = tpl.match(PROSE_RE);
|
|
61
|
-
if (hits) {
|
|
62
|
-
hardcodedHits += hits.length;
|
|
63
|
-
if (opts?.verbose) {
|
|
64
|
-
for (const h of hits) {
|
|
65
|
-
const text = cleanHit(h);
|
|
66
|
-
const idx = src.indexOf(text);
|
|
67
|
-
const line = idx >= 0 ? src.slice(0, idx).split('\n').length : 0;
|
|
68
|
-
verboseHits.push({ file, text, line });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
66
|
}
|
|
75
67
|
|
|
76
68
|
const localesDir = existsSync(resolve(root, 'src/locales'))
|
|
@@ -79,11 +71,10 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
79
71
|
? resolve(root, 'locales')
|
|
80
72
|
: null;
|
|
81
73
|
|
|
82
|
-
const localeFiles = localesDir
|
|
83
|
-
? readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'overrides.json')
|
|
84
|
-
: [];
|
|
85
|
-
const languages = localeFiles.map((f) => f.replace(/\.json$/, '').replace(/^overrides\./, ''));
|
|
86
74
|
const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
|
|
75
|
+
const extracted = await tryHybridsExtract(root);
|
|
76
|
+
const extractedKeys = extracted ? Object.keys(extracted) : null;
|
|
77
|
+
const locales = loadLocales(localesDir);
|
|
87
78
|
|
|
88
79
|
const patternParts = [
|
|
89
80
|
usesT ? `t() \u00d7${usesT}` : '',
|
|
@@ -92,28 +83,35 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
92
83
|
].filter(Boolean);
|
|
93
84
|
|
|
94
85
|
const time = elapsed(start);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const truncated = e.text.length > 60 ? e.text.slice(0, 60) + '\u2026' : e.text;
|
|
112
|
-
console.log(` L${e.line || '?'} ${truncated}`);
|
|
113
|
-
}
|
|
86
|
+
const coverage = [];
|
|
87
|
+
if (localesDir) {
|
|
88
|
+
// Key source priority: t() DEFAULTS + overrides → hybrids extract → overrides.json alone
|
|
89
|
+
const tKeys = extractTDefaults(root);
|
|
90
|
+
const overrideKeys = hasOverrides
|
|
91
|
+
? [...parseLocaleJson(resolve(localesDir, 'overrides.json'))]
|
|
92
|
+
: [];
|
|
93
|
+
const masterKeys = tKeys
|
|
94
|
+
? [...new Set([...tKeys, ...overrideKeys])]
|
|
95
|
+
: (extractedKeys ?? (overrideKeys.length ? overrideKeys : null));
|
|
96
|
+
if (masterKeys?.length && locales.length) {
|
|
97
|
+
for (const loc of locales) {
|
|
98
|
+
const translated = masterKeys.filter((k) => loc.keys.has(k)).length;
|
|
99
|
+
const pct = Math.round((translated / masterKeys.length) * 100);
|
|
100
|
+
const missing = masterKeys.length - translated;
|
|
101
|
+
coverage.push({ lang: loc.lang, pct, missing, total: masterKeys.length });
|
|
114
102
|
}
|
|
115
103
|
}
|
|
116
104
|
}
|
|
117
105
|
|
|
118
|
-
|
|
106
|
+
const coverageDetail = coverage.length
|
|
107
|
+
? coverage.map((c) => `${c.lang} ${c.pct}%${c.missing ? ` (${c.missing} missing)` : ' ✓'}`).join(' · ')
|
|
108
|
+
: (locales.length === 0 ? 'en only' : '');
|
|
109
|
+
|
|
110
|
+
if (!opts?.quiet) {
|
|
111
|
+
const icon = patternParts.length ? '✅' : '⚠️ ';
|
|
112
|
+
const detail = coverageDetail || (patternParts.length ? patternParts.join(', ') : 'no i18n patterns detected');
|
|
113
|
+
console.log(` ${icon} ${label} — ${detail} (${time})`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { pass: true, label, time, detail: coverageDetail };
|
|
119
117
|
}
|