@techninja/clearstack 0.4.1 → 0.4.4
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/cli.js +1 -1
- package/docs/I18N.md +372 -111
- package/lib/check.js +38 -9
- package/lib/spec-i18n-locales.js +84 -0
- package/lib/spec-i18n.js +64 -29
- package/package.json +5 -1
- package/templates/shared/docs/clearstack/I18N.md +372 -111
package/lib/check.js
CHANGED
|
@@ -14,7 +14,31 @@ export { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports }
|
|
|
14
14
|
export { checkI18n } from './spec-i18n.js';
|
|
15
15
|
export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Load project-level spec extensions from `clearstack.spec.js` in the project root.
|
|
19
|
+
* The file may export a default array of Check objects. Any check whose key matches
|
|
20
|
+
* a built-in check replaces it; new keys are appended.
|
|
21
|
+
* @param {string} projectDir
|
|
22
|
+
* @returns {Promise<Check[]>}
|
|
23
|
+
*/
|
|
24
|
+
async function loadExtensions(projectDir) {
|
|
25
|
+
const extPath = resolve(projectDir, 'clearstack.spec.js');
|
|
26
|
+
if (!existsSync(extPath)) return [];
|
|
27
|
+
try {
|
|
28
|
+
const mod = await import(extPath);
|
|
29
|
+
const exts = mod.default ?? [];
|
|
30
|
+
if (!Array.isArray(exts)) {
|
|
31
|
+
console.warn('⚠ clearstack.spec.js default export must be an array — extensions ignored');
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return exts;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.warn(`⚠ Failed to load clearstack.spec.js: ${e.message}`);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @typedef {{ key: string, name: string, parent?: string, run: (opts?: object) => boolean }} Check */
|
|
18
42
|
|
|
19
43
|
/** Find all jsconfig.json files — main config + subdirectories. */
|
|
20
44
|
function findTypeConfigs(dir, runner) {
|
|
@@ -34,14 +58,15 @@ function findTypeConfigs(dir, runner) {
|
|
|
34
58
|
|
|
35
59
|
/**
|
|
36
60
|
* Build the unified checks array. Children have a `parent` key.
|
|
37
|
-
*
|
|
61
|
+
* Extensions from clearstack.spec.js are merged in: same key = replace, new key = append.
|
|
62
|
+
* @returns {Promise<Check[]>}
|
|
38
63
|
*/
|
|
39
|
-
export function buildChecks(dir, cfg, cmds) {
|
|
64
|
+
export async function buildChecks(dir, cfg, cmds) {
|
|
40
65
|
const js = () => countFiles(dir, ['.js'], cfg.ignore);
|
|
41
66
|
const css = () => countFiles(dir, ['.css'], cfg.ignore);
|
|
42
67
|
const md = () => countFiles(dir, ['.md'], cfg.ignore);
|
|
43
68
|
const runner = detectRunner(dir);
|
|
44
|
-
|
|
69
|
+
const builtin = [
|
|
45
70
|
{ key: 'es', name: 'ESLint', parent: 'lint',
|
|
46
71
|
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`).pass },
|
|
47
72
|
{ key: 'css', name: 'Stylelint', parent: 'lint',
|
|
@@ -53,7 +78,7 @@ export function buildChecks(dir, cfg, cmds) {
|
|
|
53
78
|
{ key: 'lines', name: `Code (max ${cfg.codeMax} lines)`, parent: 'code',
|
|
54
79
|
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }).pass },
|
|
55
80
|
{ key: 'i18n', name: 'i18n readiness', parent: 'code',
|
|
56
|
-
run: () => checkI18n(dir, cfg.ignore, 'i18n readiness').pass },
|
|
81
|
+
run: (runOpts) => checkI18n(dir, cfg.ignore, 'i18n readiness', runOpts).pass },
|
|
57
82
|
{ key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
|
|
58
83
|
run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern }).pass },
|
|
59
84
|
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
@@ -64,6 +89,10 @@ export function buildChecks(dir, cfg, cmds) {
|
|
|
64
89
|
{ key: 'audit', name: 'Security audit',
|
|
65
90
|
run: () => runCmd('Security audit', cmds.audit, dir).pass },
|
|
66
91
|
];
|
|
92
|
+
const extensions = await loadExtensions(dir);
|
|
93
|
+
if (!extensions.length) return builtin;
|
|
94
|
+
const extKeys = new Set(extensions.map((e) => e.key));
|
|
95
|
+
return [...builtin.filter((c) => !extKeys.has(c.key)), ...extensions];
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
/** Resolve a scope like 'lint', 'lint es', or 'code' to runnable check(s). */
|
|
@@ -85,10 +114,10 @@ export function parentKeys(checks) {
|
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
/** Run the full spec compliance check (used by clearstack CLI and scripts/spec.js). */
|
|
88
|
-
export async function check(projectDir, scope) {
|
|
117
|
+
export async function check(projectDir, scope, opts) {
|
|
89
118
|
const cfg = loadConfig(projectDir);
|
|
90
119
|
const cmds = buildCmds(projectDir);
|
|
91
|
-
const checks = buildChecks(projectDir, cfg, cmds);
|
|
120
|
+
const checks = await buildChecks(projectDir, cfg, cmds);
|
|
92
121
|
|
|
93
122
|
if (scope && scope !== 'all') {
|
|
94
123
|
const matched = resolveChecks(checks, scope);
|
|
@@ -98,13 +127,13 @@ export async function check(projectDir, scope) {
|
|
|
98
127
|
console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
|
|
99
128
|
process.exit(1);
|
|
100
129
|
}
|
|
101
|
-
const ok = matched.every((c) => c.run());
|
|
130
|
+
const ok = matched.every((c) => c.run(opts));
|
|
102
131
|
if (!ok) process.exit(1);
|
|
103
132
|
return;
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
console.log('🔍 Clearstack compliance checking now... 💙\n');
|
|
107
|
-
const results = checks.map((c) => c.run());
|
|
136
|
+
const results = checks.map((c) => c.run(opts));
|
|
108
137
|
const passed = results.filter(Boolean).length;
|
|
109
138
|
console.log(`\n${'='.repeat(40)}`);
|
|
110
139
|
console.log(`${passed}/${results.length} checks passed.`);
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { execSync } from 'node:child_process';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Try to run `npx hybrids extract ./src` and parse the JSON output.
|
|
12
|
+
* @param {string} root
|
|
13
|
+
* @returns {Record<string, object>|null}
|
|
14
|
+
*/
|
|
15
|
+
export function tryHybridsExtract(root) {
|
|
16
|
+
const srcDir = resolve(root, 'src');
|
|
17
|
+
if (!existsSync(srcDir)) return null;
|
|
18
|
+
try {
|
|
19
|
+
const out = execSync('npx hybrids extract ./src', {
|
|
20
|
+
cwd: root,
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
timeout: 30000,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
});
|
|
25
|
+
return JSON.parse(out);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse locale keys from a .js file that calls localize(lang, {...}).
|
|
33
|
+
* Extracts top-level string keys from the object literal.
|
|
34
|
+
* @param {string} filePath
|
|
35
|
+
* @returns {Set<string>}
|
|
36
|
+
*/
|
|
37
|
+
export function parseLocaleJs(filePath) {
|
|
38
|
+
const src = readFileSync(filePath, 'utf-8');
|
|
39
|
+
const keys = new Set();
|
|
40
|
+
const re = /^\s*(['"])(.*?)\1\s*:/gm;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = re.exec(src)) !== null) {
|
|
43
|
+
const key = m[2].replace(/\\'/g, "'").replace(/\\"/g, '"');
|
|
44
|
+
keys.add(key);
|
|
45
|
+
}
|
|
46
|
+
return keys;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse locale keys from a .json file.
|
|
51
|
+
* @param {string} filePath
|
|
52
|
+
* @returns {Set<string>}
|
|
53
|
+
*/
|
|
54
|
+
export function parseLocaleJson(filePath) {
|
|
55
|
+
try {
|
|
56
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
57
|
+
return new Set(Object.keys(data));
|
|
58
|
+
} catch {
|
|
59
|
+
return new Set();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find locale files and return { lang, keys } for each.
|
|
65
|
+
* @param {string} localesDir
|
|
66
|
+
* @returns {Array<{lang: string, keys: Set<string>, file: string}>}
|
|
67
|
+
*/
|
|
68
|
+
export function loadLocales(localesDir) {
|
|
69
|
+
if (!localesDir) return [];
|
|
70
|
+
const files = readdirSync(localesDir);
|
|
71
|
+
const locales = [];
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
if (f === 'overrides.json' || f === 'en.json') continue;
|
|
74
|
+
const full = resolve(localesDir, f);
|
|
75
|
+
if (f.endsWith('.json')) {
|
|
76
|
+
const lang = f.replace(/\.json$/, '');
|
|
77
|
+
locales.push({ lang, keys: parseLocaleJson(full), file: f });
|
|
78
|
+
} else if (f.endsWith('.js') && !f.includes('init')) {
|
|
79
|
+
const lang = f.replace(/\.js$/, '');
|
|
80
|
+
locales.push({ lang, keys: parseLocaleJs(full), file: f });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return locales;
|
|
84
|
+
}
|
package/lib/spec-i18n.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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
9
|
import { findFiles, elapsed } from './spec-utils.js';
|
|
10
|
+
import { tryHybridsExtract, loadLocales } from './spec-i18n-locales.js';
|
|
10
11
|
|
|
11
12
|
/** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
|
|
12
13
|
|
|
@@ -22,11 +23,12 @@ const TPL_RE = /html`[\s\S]*?`/g;
|
|
|
22
23
|
* @param {string} root
|
|
23
24
|
* @param {string[]} ignoreDirs
|
|
24
25
|
* @param {string} label
|
|
25
|
-
* @param {{ quiet?: boolean }} [opts]
|
|
26
|
+
* @param {{ quiet?: boolean, verbose?: boolean }} [opts]
|
|
26
27
|
* @returns {CheckResult}
|
|
27
28
|
*/
|
|
28
29
|
export function checkI18n(root, ignoreDirs, label, opts) {
|
|
29
30
|
const start = performance.now();
|
|
31
|
+
|
|
30
32
|
const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
|
|
31
33
|
(f) =>
|
|
32
34
|
f.startsWith('src/') &&
|
|
@@ -35,27 +37,13 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
35
37
|
!f.endsWith('.test.js'),
|
|
36
38
|
);
|
|
37
39
|
|
|
38
|
-
let usesT = 0,
|
|
39
|
-
usesMsg = 0,
|
|
40
|
-
usesLocalize = 0,
|
|
41
|
-
hasInit = false,
|
|
42
|
-
hardcodedHits = 0,
|
|
43
|
-
templateFiles = 0;
|
|
44
|
-
|
|
40
|
+
let usesT = 0, usesMsg = 0, usesLocalize = 0, hasInit = false;
|
|
45
41
|
for (const file of srcFiles) {
|
|
46
42
|
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
47
43
|
if (T_RE.test(src)) usesT++;
|
|
48
44
|
if (MSG_RE.test(src)) usesMsg++;
|
|
49
45
|
if (LOC_RE.test(src)) usesLocalize++;
|
|
50
46
|
if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
|
|
51
|
-
const templates = src.match(TPL_RE);
|
|
52
|
-
if (templates) {
|
|
53
|
-
templateFiles++;
|
|
54
|
-
for (const tpl of templates) {
|
|
55
|
-
const hits = tpl.match(PROSE_RE);
|
|
56
|
-
if (hits) hardcodedHits += hits.length;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
47
|
}
|
|
60
48
|
|
|
61
49
|
const localesDir = existsSync(resolve(root, 'src/locales'))
|
|
@@ -64,26 +52,73 @@ export function checkI18n(root, ignoreDirs, label, opts) {
|
|
|
64
52
|
? resolve(root, 'locales')
|
|
65
53
|
: null;
|
|
66
54
|
|
|
67
|
-
const localeFiles = localesDir
|
|
68
|
-
? readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'overrides.json')
|
|
69
|
-
: [];
|
|
70
|
-
const languages = localeFiles.map((f) => f.replace(/\.json$/, '').replace(/^overrides\./, ''));
|
|
71
55
|
const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
|
|
56
|
+
const extracted = tryHybridsExtract(root);
|
|
57
|
+
const extractedKeys = extracted ? Object.keys(extracted) : null;
|
|
58
|
+
const locales = loadLocales(localesDir);
|
|
59
|
+
const languages = locales.map((l) => l.lang);
|
|
72
60
|
|
|
73
61
|
const patternParts = [
|
|
74
|
-
usesT ? `t()
|
|
75
|
-
usesMsg ? `msg\`
|
|
76
|
-
usesLocalize ? `localize()
|
|
62
|
+
usesT ? `t() \u00d7${usesT}` : '',
|
|
63
|
+
usesMsg ? `msg\` \u00d7${usesMsg}` : '',
|
|
64
|
+
usesLocalize ? `localize() \u00d7${usesLocalize}` : '',
|
|
77
65
|
].filter(Boolean);
|
|
78
66
|
|
|
79
67
|
const time = elapsed(start);
|
|
68
|
+
|
|
80
69
|
if (!opts?.quiet) {
|
|
81
|
-
const icon = patternParts.length ? '
|
|
70
|
+
const icon = patternParts.length ? '\u2705' : '\u26a0\ufe0f ';
|
|
82
71
|
const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
|
|
83
72
|
const langs = languages.length ? languages.join(', ') : 'en only';
|
|
84
|
-
const init = `${hasInit ? 'yes' : 'not detected'}
|
|
85
|
-
|
|
86
|
-
|
|
73
|
+
const init = `${hasInit ? 'yes' : 'not detected'} \u00b7 Overrides: ${hasOverrides ? 'yes' : 'no'} \u00b7 Languages: ${langs}`;
|
|
74
|
+
|
|
75
|
+
let coverage = '';
|
|
76
|
+
if (extractedKeys && locales.length) {
|
|
77
|
+
const total = extractedKeys.length;
|
|
78
|
+
for (const loc of locales) {
|
|
79
|
+
const translated = extractedKeys.filter((k) => loc.keys.has(k)).length;
|
|
80
|
+
const pct = total > 0 ? Math.round((translated / total) * 100) : 0;
|
|
81
|
+
const missing = total - translated;
|
|
82
|
+
coverage += `\n ${loc.lang}: ${translated}/${total} (${pct}%)${missing > 0 ? ` \u2014 ${missing} missing` : ' \u2714'}`;
|
|
83
|
+
}
|
|
84
|
+
} else if (extractedKeys) {
|
|
85
|
+
coverage = `\n Extracted: ${extractedKeys.length} translatable strings`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let unwrapped = '';
|
|
89
|
+
if (!extractedKeys) {
|
|
90
|
+
let hardcodedHits = 0, templateFiles = 0;
|
|
91
|
+
for (const file of srcFiles) {
|
|
92
|
+
const src = readFileSync(resolve(root, file), 'utf-8');
|
|
93
|
+
const templates = src.match(TPL_RE);
|
|
94
|
+
if (templates) {
|
|
95
|
+
templateFiles++;
|
|
96
|
+
for (const tpl of templates) {
|
|
97
|
+
const hits = tpl.match(PROSE_RE);
|
|
98
|
+
if (hits) hardcodedHits += hits.length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (hardcodedHits > 0) {
|
|
103
|
+
unwrapped = `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${coverage}${unwrapped}`);
|
|
108
|
+
|
|
109
|
+
if (opts?.verbose && extractedKeys && locales.length) {
|
|
110
|
+
for (const loc of locales) {
|
|
111
|
+
const missing = extractedKeys.filter((k) => !loc.keys.has(k));
|
|
112
|
+
if (missing.length) {
|
|
113
|
+
console.log(`\n Missing in ${loc.lang} (${missing.length}):`);
|
|
114
|
+
for (const k of missing.slice(0, 50)) {
|
|
115
|
+
const truncated = k.length > 70 ? k.slice(0, 70) + '\u2026' : k;
|
|
116
|
+
console.log(` \u2022 ${truncated}`);
|
|
117
|
+
}
|
|
118
|
+
if (missing.length > 50) console.log(` \u2026 and ${missing.length - 50} more`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
return { pass: true, label, time };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techninja/clearstack",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"templates/",
|
|
13
13
|
"docs/"
|
|
14
14
|
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./lib/check.js",
|
|
17
|
+
"./lib/*": "./lib/*"
|
|
18
|
+
},
|
|
15
19
|
"repository": {
|
|
16
20
|
"type": "git",
|
|
17
21
|
"url": "git+https://github.com/techninja/clearstack.git"
|