@techninja/clearstack 0.4.3 → 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.
@@ -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 — detects patterns, locale files, hardcoded prose density.
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, readdirSync, existsSync } from 'node:fs';
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
 
@@ -17,9 +18,6 @@ const LOC_RE = /\blocalize\s*\(/;
17
18
  const INIT_RE = /loadLocale|localize\s*\(/;
18
19
  const TPL_RE = /html`[\s\S]*?`/g;
19
20
 
20
- /** Strip leading > and trailing <$ from a prose match, trim. */
21
- const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
22
-
23
21
  /**
24
22
  * Check i18n readiness — informational, always passes.
25
23
  * @param {string} root
@@ -30,6 +28,7 @@ const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
30
28
  */
31
29
  export function checkI18n(root, ignoreDirs, label, opts) {
32
30
  const start = performance.now();
31
+
33
32
  const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
34
33
  (f) =>
35
34
  f.startsWith('src/') &&
@@ -38,39 +37,13 @@ export function checkI18n(root, ignoreDirs, label, opts) {
38
37
  !f.endsWith('.test.js'),
39
38
  );
40
39
 
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
-
40
+ let usesT = 0, usesMsg = 0, usesLocalize = 0, hasInit = false;
50
41
  for (const file of srcFiles) {
51
42
  const src = readFileSync(resolve(root, file), 'utf-8');
52
43
  if (T_RE.test(src)) usesT++;
53
44
  if (MSG_RE.test(src)) usesMsg++;
54
45
  if (LOC_RE.test(src)) usesLocalize++;
55
46
  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
47
  }
75
48
 
76
49
  const localesDir = existsSync(resolve(root, 'src/locales'))
@@ -79,11 +52,11 @@ export function checkI18n(root, ignoreDirs, label, opts) {
79
52
  ? resolve(root, 'locales')
80
53
  : null;
81
54
 
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
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);
87
60
 
88
61
  const patternParts = [
89
62
  usesT ? `t() \u00d7${usesT}` : '',
@@ -92,24 +65,57 @@ export function checkI18n(root, ignoreDirs, label, opts) {
92
65
  ].filter(Boolean);
93
66
 
94
67
  const time = elapsed(start);
68
+
95
69
  if (!opts?.quiet) {
96
70
  const icon = patternParts.length ? '\u2705' : '\u26a0\ufe0f ';
97
71
  const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
98
72
  const langs = languages.length ? languages.join(', ') : 'en only';
99
73
  const init = `${hasInit ? 'yes' : 'not detected'} \u00b7 Overrides: ${hasOverrides ? 'yes' : 'no'} \u00b7 Languages: ${langs}`;
100
- const unwrapped = hardcodedHits > 0 ? `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files` : '';
101
- console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${unwrapped}`);
102
- if (opts?.verbose && verboseHits.length) {
103
- console.log('');
104
- const byFile = {};
105
- for (const v of verboseHits) {
106
- (byFile[v.file] ||= []).push(v);
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'}`;
107
83
  }
108
- for (const [file, entries] of Object.entries(byFile)) {
109
- console.log(` ${file}`);
110
- for (const e of entries) {
111
- const truncated = e.text.length > 60 ? e.text.slice(0, 60) + '\u2026' : e.text;
112
- console.log(` L${e.line || '?'} ${truncated}`);
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`);
113
119
  }
114
120
  }
115
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.4.3",
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"