claudecode-omc 5.6.6 → 5.6.7
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/.local/skills/h5-to-swiftui/SKILL.md +201 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/README.md +176 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/index.html +52 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/style.css +133 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Package.swift +26 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Sources/CalibrationScreen/CalibrationScreen.swift +142 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Package.swift +32 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Sources/CalibrationScreenDivergent/CalibrationScreenDivergent.swift +122 -0
- package/.local/skills/h5-to-swiftui/assets/calibration/tokens.json +42 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/index.html +14 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/package.json +20 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/001.json +96 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/index.json +89 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.jsx +22 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.module.css +11 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.jsx +53 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.module.css +139 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.jsx +37 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.module.css +72 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.jsx +30 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.module.css +50 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.jsx +159 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.module.css +21 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/main.jsx +12 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.jsx +182 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.module.css +294 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.jsx +147 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.module.css +161 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/global.css +50 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/tokens.css +103 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-react/vite.config.js +6 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/data/tasks.js +67 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/index.html +26 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/router.js +73 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/detail.js +164 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/home.js +53 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/list.js +87 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/app.css +342 -0
- package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/tokens.css +68 -0
- package/.local/skills/h5-to-swiftui/references/css-to-swiftui-map.md +205 -0
- package/.local/skills/h5-to-swiftui/references/design-token-extraction.md +209 -0
- package/.local/skills/h5-to-swiftui/references/high-risk-triage.md +209 -0
- package/.local/skills/h5-to-swiftui/references/render-equivalence-calibration.md +193 -0
- package/.local/skills/h5-to-swiftui/references/stack-detection.md +160 -0
- package/.local/skills/h5-to-swiftui/references/visual-diff-loop-protocol.md +365 -0
- package/.local/skills/h5-to-swiftui/scripts/_calib-consts.mjs +150 -0
- package/.local/skills/h5-to-swiftui/scripts/_imglib.mjs +547 -0
- package/.local/skills/h5-to-swiftui/scripts/_provenance.mjs +123 -0
- package/.local/skills/h5-to-swiftui/scripts/calibrate-render.mjs +625 -0
- package/.local/skills/h5-to-swiftui/scripts/capture-reference.mjs +386 -0
- package/.local/skills/h5-to-swiftui/scripts/detect-stack.mjs +305 -0
- package/.local/skills/h5-to-swiftui/scripts/evaluate-convergence.mjs +1093 -0
- package/.local/skills/h5-to-swiftui/scripts/extract-tokens.mjs +600 -0
- package/.local/skills/h5-to-swiftui/scripts/mark-overlay.mjs +379 -0
- package/.local/skills/h5-to-swiftui/scripts/pixel-diff.mjs +530 -0
- package/.local/skills/h5-to-swiftui/scripts/sim-screenshot.sh +544 -0
- package/bundled/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* extract-tokens.mjs — Stage 1: DTCG design token extraction
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node extract-tokens.mjs <h5-src> [--url <running-url>] [--out <dir>]
|
|
7
|
+
*
|
|
8
|
+
* Outputs:
|
|
9
|
+
* tokens.json — W3C DTCG: { name: { $value, $type } }
|
|
10
|
+
* token-gaps.json — values seen but not resolvable to a named token
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 — tokens.json + token-gaps.json written
|
|
14
|
+
* 1 — fatal error (bad args, unreadable source)
|
|
15
|
+
* 2 — playwright requested but not installed (actionable hint printed)
|
|
16
|
+
*
|
|
17
|
+
* Examples:
|
|
18
|
+
* node extract-tokens.mjs ./my-app
|
|
19
|
+
* node extract-tokens.mjs ./my-app --url http://localhost:5173
|
|
20
|
+
* node extract-tokens.mjs ./my-app --url http://localhost:5173 --out ./artifacts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
readFileSync, writeFileSync, existsSync,
|
|
25
|
+
mkdirSync, readdirSync, statSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { resolve, join, extname, basename } from 'node:path';
|
|
28
|
+
|
|
29
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
|
|
33
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
34
|
+
console.log(`extract-tokens.mjs — H5 design token extraction (DTCG)
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
node extract-tokens.mjs <h5-src> [--url <running-url>] [--out <dir>]
|
|
38
|
+
|
|
39
|
+
Arguments:
|
|
40
|
+
<h5-src> Path to the H5 project root (required)
|
|
41
|
+
--url <url> Running dev-server URL; enables runtime Playwright pass
|
|
42
|
+
--out <dir> Directory for output files (default: <h5-src>)
|
|
43
|
+
|
|
44
|
+
Outputs:
|
|
45
|
+
tokens.json W3C DTCG — each token: { "$value": ..., "$type": ... }
|
|
46
|
+
token-gaps.json Values seen in source that couldn't be resolved to a token
|
|
47
|
+
|
|
48
|
+
Token types emitted:
|
|
49
|
+
color | dimension | typography
|
|
50
|
+
|
|
51
|
+
Sources (in priority order):
|
|
52
|
+
1. CSS custom properties (--* from .css / .scss)
|
|
53
|
+
2. tailwind.config.* theme.colors / spacing / fontSize / borderRadius / fontFamily
|
|
54
|
+
3. Runtime getComputedStyle via Playwright (only when --url given)
|
|
55
|
+
|
|
56
|
+
Note: Sass variables in .scss that haven't been precompiled to CSS are noted
|
|
57
|
+
in token-gaps.json. Playwright is an optional dependency — install it with:
|
|
58
|
+
npm install --save-dev playwright && npx playwright install chromium
|
|
59
|
+
|
|
60
|
+
Exit codes:
|
|
61
|
+
0 tokens.json + token-gaps.json written
|
|
62
|
+
1 fatal error
|
|
63
|
+
2 playwright missing (actionable hint printed, static tokens still written)
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
node extract-tokens.mjs ./my-vanilla-app
|
|
67
|
+
node extract-tokens.mjs ./my-react-app --url http://localhost:5173
|
|
68
|
+
node extract-tokens.mjs ./my-react-app --url http://localhost:5173 --out ./artifacts
|
|
69
|
+
`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.length === 0 || args[0].startsWith('--')) {
|
|
74
|
+
console.error('Error: <h5-src> is required.\nRun with --help for usage.');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const h5Src = resolve(args[0]);
|
|
79
|
+
|
|
80
|
+
let runUrl = null;
|
|
81
|
+
const urlIdx = args.indexOf('--url');
|
|
82
|
+
if (urlIdx !== -1) {
|
|
83
|
+
if (!args[urlIdx + 1] || args[urlIdx + 1].startsWith('--')) {
|
|
84
|
+
console.error('Error: --url requires a URL argument.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
runUrl = args[urlIdx + 1];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let outDir = h5Src;
|
|
91
|
+
const outIdx = args.indexOf('--out');
|
|
92
|
+
if (outIdx !== -1) {
|
|
93
|
+
if (!args[outIdx + 1] || args[outIdx + 1].startsWith('--')) {
|
|
94
|
+
console.error('Error: --out requires a directory argument.');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
outDir = resolve(args[outIdx + 1]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!existsSync(h5Src)) {
|
|
101
|
+
console.error(`Error: h5-src does not exist: ${h5Src}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (!statSync(h5Src).isDirectory()) {
|
|
105
|
+
console.error(`Error: h5-src must be a directory: ${h5Src}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function readText(p) {
|
|
112
|
+
try { return readFileSync(p, 'utf8'); } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readJson(p) {
|
|
116
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function collectFiles(dir, exts, maxDepth = 5, _depth = 0) {
|
|
120
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'out', 'coverage', '.cache']);
|
|
121
|
+
if (_depth > maxDepth) return [];
|
|
122
|
+
const results = [];
|
|
123
|
+
let entries;
|
|
124
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (skip.has(e.name)) continue;
|
|
127
|
+
const full = join(dir, e.name);
|
|
128
|
+
if (e.isDirectory()) {
|
|
129
|
+
results.push(...collectFiles(full, exts, maxDepth, _depth + 1));
|
|
130
|
+
} else if (exts.has(extname(e.name).toLowerCase())) {
|
|
131
|
+
results.push(full);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Color utilities ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function parseColor(val) {
|
|
140
|
+
if (typeof val !== 'string') return null;
|
|
141
|
+
val = val.trim();
|
|
142
|
+
let m;
|
|
143
|
+
|
|
144
|
+
// #rrggbbaa or #rrggbb
|
|
145
|
+
m = val.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i);
|
|
146
|
+
if (m) return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
|
147
|
+
|
|
148
|
+
// #rgba or #rgb
|
|
149
|
+
m = val.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/i);
|
|
150
|
+
if (m) return { r: parseInt(m[1]+m[1], 16), g: parseInt(m[2]+m[2], 16), b: parseInt(m[3]+m[3], 16) };
|
|
151
|
+
|
|
152
|
+
// rgb(r, g, b) or rgba(r, g, b, a)
|
|
153
|
+
m = val.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/);
|
|
154
|
+
if (m) return { r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]) };
|
|
155
|
+
|
|
156
|
+
// rgb(r g b) — modern syntax
|
|
157
|
+
m = val.match(/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
|
158
|
+
if (m) return { r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]) };
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toLinear(c) {
|
|
164
|
+
c = c / 255;
|
|
165
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rgbToXyz({ r, g, b }) {
|
|
169
|
+
const rl = toLinear(r), gl = toLinear(g), bl = toLinear(b);
|
|
170
|
+
return {
|
|
171
|
+
x: rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375,
|
|
172
|
+
y: rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750,
|
|
173
|
+
z: rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function xyzToLab({ x, y, z }) {
|
|
178
|
+
const fn = t => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
179
|
+
const fx = fn(x / 0.95047), fy = fn(y / 1.00000), fz = fn(z / 1.08883);
|
|
180
|
+
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deltaE(c1, c2) {
|
|
184
|
+
const lab1 = xyzToLab(rgbToXyz(c1)), lab2 = xyzToLab(rgbToXyz(c2));
|
|
185
|
+
return Math.sqrt(
|
|
186
|
+
Math.pow(lab1.L - lab2.L, 2) +
|
|
187
|
+
Math.pow(lab1.a - lab2.a, 2) +
|
|
188
|
+
Math.pow(lab1.b - lab2.b, 2)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeColor(val) {
|
|
193
|
+
const rgb = parseColor(val);
|
|
194
|
+
if (!rgb) return val.toLowerCase().trim();
|
|
195
|
+
const hex = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0');
|
|
196
|
+
return `#${hex(rgb.r)}${hex(rgb.g)}${hex(rgb.b)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function looksLikeColor(val) {
|
|
200
|
+
if (typeof val !== 'string') return false;
|
|
201
|
+
return /^#[0-9a-f]{3,8}$/i.test(val) ||
|
|
202
|
+
/^rgba?\(/.test(val) ||
|
|
203
|
+
/^hsla?\(/.test(val) ||
|
|
204
|
+
/^(transparent|currentcolor|inherit)$/i.test(val);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function looksLikeDimension(val) {
|
|
208
|
+
if (typeof val !== 'string') return false;
|
|
209
|
+
return /^-?[\d.]+\s*(px|rem|em|vw|vh|dvh|dvw|pt|ch|ex|%)$/.test(val.trim());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Deduplicate colors: for pairs with ΔE < 2, retain first, alias the rest
|
|
213
|
+
function dedupeColors(colorMap) {
|
|
214
|
+
const result = {};
|
|
215
|
+
const seen = [];
|
|
216
|
+
for (const [name, val] of Object.entries(colorMap)) {
|
|
217
|
+
const rgb = parseColor(val);
|
|
218
|
+
if (!rgb) { result[name] = val; continue; }
|
|
219
|
+
const dup = seen.find(s => s.rgb && deltaE(s.rgb, rgb) < 2);
|
|
220
|
+
if (dup) {
|
|
221
|
+
result[name] = { value: val, alias_of: dup.name };
|
|
222
|
+
} else {
|
|
223
|
+
result[name] = val;
|
|
224
|
+
seen.push({ name, rgb });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Static pass: CSS custom properties ───────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const CSS_CUSTOM_PROP = /--([a-zA-Z0-9_-]+)\s*:\s*([^;}\n]+)/g;
|
|
233
|
+
|
|
234
|
+
function extractCssCustomProps(cssText) {
|
|
235
|
+
const props = {};
|
|
236
|
+
let m;
|
|
237
|
+
CSS_CUSTOM_PROP.lastIndex = 0;
|
|
238
|
+
while ((m = CSS_CUSTOM_PROP.exec(cssText)) !== null) {
|
|
239
|
+
const name = `--${m[1].trim()}`;
|
|
240
|
+
const val = m[2].trim();
|
|
241
|
+
if (val && !val.startsWith('/*')) props[name] = val;
|
|
242
|
+
}
|
|
243
|
+
return props;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isVarRef(val) { return /^var\(--/.test(val); }
|
|
247
|
+
|
|
248
|
+
// ── Static pass: Sass variables ──────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
const SASS_VAR = /^\s*\$([a-zA-Z0-9_-]+)\s*:\s*(.+?)\s*(!default)?;/gm;
|
|
251
|
+
|
|
252
|
+
function extractSassVars(scssText) {
|
|
253
|
+
const vars = {};
|
|
254
|
+
let m;
|
|
255
|
+
SASS_VAR.lastIndex = 0;
|
|
256
|
+
while ((m = SASS_VAR.exec(scssText)) !== null) {
|
|
257
|
+
vars[`$${m[1]}`] = m[2].trim();
|
|
258
|
+
}
|
|
259
|
+
return vars;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Static pass: Tailwind config ─────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const TW_THEME_KEYS = ['colors', 'spacing', 'fontSize', 'borderRadius', 'fontFamily'];
|
|
265
|
+
|
|
266
|
+
function flattenObject(obj, prefix = '', out = {}) {
|
|
267
|
+
if (typeof obj === 'string' || typeof obj === 'number') {
|
|
268
|
+
out[prefix] = String(obj); return out;
|
|
269
|
+
}
|
|
270
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
271
|
+
if (Array.isArray(obj)) out[prefix] = obj.join(', ');
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
275
|
+
flattenObject(v, prefix ? `${prefix}.${k}` : k, out);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function flattenTailwindTheme(theme) {
|
|
281
|
+
const tokens = {};
|
|
282
|
+
for (const key of TW_THEME_KEYS) {
|
|
283
|
+
if (theme[key]) Object.assign(tokens, flattenObject(theme[key], `tw.${key}`));
|
|
284
|
+
if (theme.extend?.[key]) Object.assign(tokens, flattenObject(theme.extend[key], `tw.${key}`));
|
|
285
|
+
}
|
|
286
|
+
return tokens;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractTailwindTokens(configText, configPath) {
|
|
290
|
+
if (configPath.endsWith('.json')) {
|
|
291
|
+
const obj = readJson(configPath);
|
|
292
|
+
return obj?.theme ? flattenTailwindTheme(obj.theme) : {};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const themeStart = configText.indexOf('theme:');
|
|
296
|
+
if (themeStart === -1) return {};
|
|
297
|
+
const objStart = configText.indexOf('{', themeStart);
|
|
298
|
+
if (objStart === -1) return {};
|
|
299
|
+
|
|
300
|
+
let depth = 0, end = objStart;
|
|
301
|
+
for (let i = objStart; i < configText.length; i++) {
|
|
302
|
+
if (configText[i] === '{') depth++;
|
|
303
|
+
else if (configText[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
|
304
|
+
}
|
|
305
|
+
const themeBlock = configText.slice(objStart, end + 1);
|
|
306
|
+
|
|
307
|
+
const tokens = {};
|
|
308
|
+
for (const key of TW_THEME_KEYS) {
|
|
309
|
+
const keyRe = new RegExp(`['"]?${key}['"]?\\s*:\\s*\\{`);
|
|
310
|
+
const km = keyRe.exec(themeBlock);
|
|
311
|
+
if (!km) continue;
|
|
312
|
+
|
|
313
|
+
const keyStart = km.index + km[0].length - 1;
|
|
314
|
+
let d = 0, ke = keyStart;
|
|
315
|
+
for (let i = keyStart; i < themeBlock.length; i++) {
|
|
316
|
+
if (themeBlock[i] === '{') d++;
|
|
317
|
+
else if (themeBlock[i] === '}') { d--; if (d === 0) { ke = i; break; } }
|
|
318
|
+
}
|
|
319
|
+
const block = themeBlock.slice(keyStart, ke + 1);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const jsonLike = block
|
|
323
|
+
.replace(/(['"])?([a-zA-Z0-9_-]+)(['"])?\s*:/g, '"$2":')
|
|
324
|
+
.replace(/:\s*'([^']+)'/g, ': "$1"')
|
|
325
|
+
.replace(/,\s*}/g, '}')
|
|
326
|
+
.replace(/,\s*]/g, ']');
|
|
327
|
+
const parsed = JSON.parse(jsonLike);
|
|
328
|
+
Object.assign(tokens, flattenObject(parsed, `tw.${key}`));
|
|
329
|
+
} catch {
|
|
330
|
+
const kvRe = /['"]?([a-zA-Z0-9_.-]+)['"]?\s*:\s*['"]([^'"]+)['"]/g;
|
|
331
|
+
let kvm;
|
|
332
|
+
while ((kvm = kvRe.exec(block)) !== null) {
|
|
333
|
+
tokens[`tw.${key}.${kvm[1]}`] = kvm[2];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return tokens;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Infer spacing scale ───────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
function inferSpacingScale(tokens) {
|
|
343
|
+
const dims = Object.entries(tokens)
|
|
344
|
+
.filter(([, t]) => t.$type === 'dimension')
|
|
345
|
+
.map(([, t]) => t.$value)
|
|
346
|
+
.filter(v => /^[\d.]+px$/.test(v))
|
|
347
|
+
.map(v => parseFloat(v))
|
|
348
|
+
.filter(v => v > 0)
|
|
349
|
+
.sort((a, b) => a - b);
|
|
350
|
+
|
|
351
|
+
if (dims.length < 2) return null;
|
|
352
|
+
const gcd = (a, b) => b < 0.5 ? a : gcd(b, a % b);
|
|
353
|
+
const base = Math.round(dims.reduce(gcd) * 10) / 10;
|
|
354
|
+
return base >= 1 ? { base_px: base, unit: 'px', note: 'inferred from token set' } : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Light/dark pairing ────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function pairLightDark(tokens) {
|
|
360
|
+
const pairs = {};
|
|
361
|
+
for (const name of Object.keys(tokens)) {
|
|
362
|
+
const darkVariant = name
|
|
363
|
+
.replace(/(light|day)$/i, 'dark')
|
|
364
|
+
.replace(/^(light|day)-/i, 'dark-');
|
|
365
|
+
if (darkVariant !== name && tokens[darkVariant]) {
|
|
366
|
+
pairs[name] = { light: tokens[name].$value, dark: tokens[darkVariant].$value };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return pairs;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Token type inference ──────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
function toTokenType(name, val) {
|
|
375
|
+
if (looksLikeColor(val)) return 'color';
|
|
376
|
+
if (looksLikeDimension(val)) return 'dimension';
|
|
377
|
+
const lname = name.toLowerCase();
|
|
378
|
+
if (lname.includes('font-family') || lname.includes('fontfamily')) return 'typography';
|
|
379
|
+
if (lname.includes('font-size') || lname.includes('fontsize')) return 'typography';
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Main static extraction ────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
console.log(`Scanning: ${h5Src}`);
|
|
386
|
+
|
|
387
|
+
const cssFiles = collectFiles(h5Src, new Set(['.css']));
|
|
388
|
+
const scssFiles = collectFiles(h5Src, new Set(['.scss', '.sass']));
|
|
389
|
+
const twConfigFiles = collectFiles(h5Src, new Set(['.js', '.ts', '.mjs', '.cjs', '.json']))
|
|
390
|
+
.filter(f => /tailwind\.config\./.test(basename(f)));
|
|
391
|
+
|
|
392
|
+
const rawTokens = {};
|
|
393
|
+
const gaps = [];
|
|
394
|
+
|
|
395
|
+
// 1. CSS custom properties
|
|
396
|
+
for (const f of cssFiles) {
|
|
397
|
+
const text = readText(f);
|
|
398
|
+
if (!text) continue;
|
|
399
|
+
for (const [name, val] of Object.entries(extractCssCustomProps(text))) {
|
|
400
|
+
if (isVarRef(val)) {
|
|
401
|
+
gaps.push({ source: f, name, value: val, reason: 'unresolved var() reference at static analysis time' });
|
|
402
|
+
} else {
|
|
403
|
+
rawTokens[name] = val;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 2. Sass files — extract custom props from compiled portions; note vars as gaps
|
|
409
|
+
for (const f of scssFiles) {
|
|
410
|
+
const text = readText(f);
|
|
411
|
+
if (!text) continue;
|
|
412
|
+
for (const [name, val] of Object.entries(extractCssCustomProps(text))) {
|
|
413
|
+
if (!isVarRef(val)) rawTokens[name] = val;
|
|
414
|
+
}
|
|
415
|
+
for (const [name, val] of Object.entries(extractSassVars(text))) {
|
|
416
|
+
gaps.push({
|
|
417
|
+
source: f, name, value: val,
|
|
418
|
+
reason: 'Sass variable — precompile CSS to resolve; value noted but must not be silently inlined',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 3. Tailwind config
|
|
424
|
+
for (const f of twConfigFiles) {
|
|
425
|
+
const text = readText(f);
|
|
426
|
+
if (!text) continue;
|
|
427
|
+
for (const [name, val] of Object.entries(extractTailwindTokens(text, f))) {
|
|
428
|
+
if (typeof val === 'string') rawTokens[name] = val;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Build DTCG tokens ─────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
const dtcgTokens = {};
|
|
435
|
+
const colorRaw = {};
|
|
436
|
+
|
|
437
|
+
for (const [name, val] of Object.entries(rawTokens)) {
|
|
438
|
+
const type = toTokenType(name, val);
|
|
439
|
+
if (!type) {
|
|
440
|
+
gaps.push({ source: 'static', name, value: val, reason: 'type could not be inferred — not a recognized color/dimension/typography value' });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (type === 'color') {
|
|
444
|
+
colorRaw[name] = normalizeColor(val);
|
|
445
|
+
} else {
|
|
446
|
+
dtcgTokens[name] = { $value: val, $type: type };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const [name, entry] of Object.entries(dedupeColors(colorRaw))) {
|
|
451
|
+
if (typeof entry === 'object' && entry.alias_of) {
|
|
452
|
+
dtcgTokens[name] = { $value: entry.value, $type: 'color', $alias_of: entry.alias_of };
|
|
453
|
+
} else {
|
|
454
|
+
dtcgTokens[name] = { $value: entry, $type: 'color' };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const spacingScale = inferSpacingScale(dtcgTokens);
|
|
459
|
+
const lightDarkPairs = pairLightDark(dtcgTokens);
|
|
460
|
+
|
|
461
|
+
// ── Runtime pass via Playwright ───────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
const LANDMARK_SELECTORS = [
|
|
464
|
+
'body', 'h1', 'h2', 'h3', 'p', 'a', 'button',
|
|
465
|
+
'input', 'label', 'nav', 'header', 'footer', 'main',
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
if (runUrl) {
|
|
469
|
+
console.log(`\nRuntime pass: ${runUrl}`);
|
|
470
|
+
|
|
471
|
+
let playwright;
|
|
472
|
+
try {
|
|
473
|
+
playwright = await import('playwright');
|
|
474
|
+
} catch {
|
|
475
|
+
console.error(
|
|
476
|
+
'\nError: playwright is not installed. Runtime token extraction requires it.\n' +
|
|
477
|
+
'Install with:\n' +
|
|
478
|
+
' npm install --save-dev playwright && npx playwright install chromium\n' +
|
|
479
|
+
'Then re-run with --url to enable the runtime pass.\n' +
|
|
480
|
+
'\nWriting static tokens collected so far.'
|
|
481
|
+
);
|
|
482
|
+
writeOutputFiles();
|
|
483
|
+
process.exit(2);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const { chromium } = playwright;
|
|
487
|
+
|
|
488
|
+
async function getRuntimeTokens(colorScheme) {
|
|
489
|
+
const browser = await chromium.launch();
|
|
490
|
+
const ctx = await browser.newContext({
|
|
491
|
+
colorScheme,
|
|
492
|
+
viewport: { width: 393, height: 852 },
|
|
493
|
+
});
|
|
494
|
+
const page = await ctx.newPage();
|
|
495
|
+
try {
|
|
496
|
+
await page.goto(runUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
497
|
+
await page.waitForFunction(() => document.fonts.ready, { timeout: 10_000 });
|
|
498
|
+
|
|
499
|
+
const result = await page.evaluate((sels) => {
|
|
500
|
+
const out = {};
|
|
501
|
+
// Collect custom props declared in stylesheets
|
|
502
|
+
const allProps = Array.from(document.styleSheets)
|
|
503
|
+
.flatMap(ss => { try { return Array.from(ss.cssRules); } catch { return []; } })
|
|
504
|
+
.flatMap(r => r.style ? Array.from(r.style) : [])
|
|
505
|
+
.filter(p => p.startsWith('--'));
|
|
506
|
+
|
|
507
|
+
const bodyStyle = window.getComputedStyle(document.body);
|
|
508
|
+
for (const prop of allProps) {
|
|
509
|
+
const v = bodyStyle.getPropertyValue(prop).trim();
|
|
510
|
+
if (v) out[prop] = v;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
for (const sel of sels) {
|
|
514
|
+
const el = document.querySelector(sel);
|
|
515
|
+
if (!el) continue;
|
|
516
|
+
const cs = window.getComputedStyle(el);
|
|
517
|
+
out[`_computed.${sel}.color`] = cs.color;
|
|
518
|
+
out[`_computed.${sel}.backgroundColor`] = cs.backgroundColor;
|
|
519
|
+
out[`_computed.${sel}.fontSize`] = cs.fontSize;
|
|
520
|
+
out[`_computed.${sel}.fontFamily`] = cs.fontFamily;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}, sels);
|
|
524
|
+
return result;
|
|
525
|
+
} finally {
|
|
526
|
+
await browser.close();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const [lightProps, darkProps] = await Promise.all([
|
|
532
|
+
getRuntimeTokens('light'),
|
|
533
|
+
getRuntimeTokens('dark'),
|
|
534
|
+
]);
|
|
535
|
+
|
|
536
|
+
for (const [name, val] of Object.entries(lightProps)) {
|
|
537
|
+
if (name.startsWith('--') && !dtcgTokens[name]) {
|
|
538
|
+
const type = toTokenType(name, val);
|
|
539
|
+
if (type) {
|
|
540
|
+
const token = { $value: type === 'color' ? normalizeColor(val) : val, $type: type, $source: 'runtime' };
|
|
541
|
+
if (darkProps[name] && darkProps[name] !== val) {
|
|
542
|
+
token.$dark = type === 'color' ? normalizeColor(darkProps[name]) : darkProps[name];
|
|
543
|
+
}
|
|
544
|
+
dtcgTokens[name] = token;
|
|
545
|
+
} else if (val) {
|
|
546
|
+
gaps.push({ source: 'runtime', name, value: val, reason: 'runtime value type could not be inferred' });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (name.startsWith('_computed.') && val && val !== 'rgba(0, 0, 0, 0)' && val !== '') {
|
|
551
|
+
const resolved = Object.entries(dtcgTokens).find(([, t]) => {
|
|
552
|
+
return t.$value === val || t.$value === normalizeColor(val);
|
|
553
|
+
});
|
|
554
|
+
if (!resolved) {
|
|
555
|
+
gaps.push({
|
|
556
|
+
source: 'runtime', name, value: val,
|
|
557
|
+
reason: 'computed value not resolvable to a named token — must not be silently inlined',
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
console.log('Runtime pass complete. Light+dark computed styles merged.');
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.error(`Warning: runtime Playwright pass failed: ${e.message}`);
|
|
566
|
+
console.error('Continuing with static tokens only.');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Write outputs ─────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
function writeOutputFiles() {
|
|
573
|
+
mkdirSync(outDir, { recursive: true });
|
|
574
|
+
|
|
575
|
+
const tokensOut = {
|
|
576
|
+
_meta: {
|
|
577
|
+
schema: 'h5-to-swiftui/tokens@1',
|
|
578
|
+
source: h5Src,
|
|
579
|
+
generated_at: new Date().toISOString(),
|
|
580
|
+
...(spacingScale && { spacing_scale: spacingScale }),
|
|
581
|
+
...(Object.keys(lightDarkPairs).length > 0 && { light_dark_pairs: lightDarkPairs }),
|
|
582
|
+
},
|
|
583
|
+
...dtcgTokens,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const tokensPath = join(outDir, 'tokens.json');
|
|
587
|
+
const gapsPath = join(outDir, 'token-gaps.json');
|
|
588
|
+
|
|
589
|
+
writeFileSync(tokensPath, JSON.stringify(tokensOut, null, 2) + '\n', 'utf8');
|
|
590
|
+
writeFileSync(gapsPath, JSON.stringify({ schema: 'h5-to-swiftui/token-gaps@1', gaps }, null, 2) + '\n', 'utf8');
|
|
591
|
+
|
|
592
|
+
console.log(`\nTokens: ${tokensPath} (${Object.keys(dtcgTokens).length} tokens)`);
|
|
593
|
+
console.log(`Gaps: ${gapsPath} (${gaps.length} unresolvable values)`);
|
|
594
|
+
if (gaps.length > 0) {
|
|
595
|
+
console.log(`\nIMPORTANT: ${gaps.length} values in token-gaps.json MUST NOT be silently inlined during conversion.`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
writeOutputFiles();
|
|
600
|
+
process.exit(0);
|