@open-dy/vibecoding-cli 0.0.1-beta.1

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.
Files changed (54) hide show
  1. package/README.md +47 -0
  2. package/assets/schemas/custom-card-schema.json +102 -0
  3. package/bin/vibecoding +89 -0
  4. package/bin/vibecoding-dev +308 -0
  5. package/bin/vibecoding-interest-card.mjs +35 -0
  6. package/bin/vibecoding.mjs +9 -0
  7. package/package.json +58 -0
  8. package/src/adapters/interest-card/opencard/commands.mjs +62 -0
  9. package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
  10. package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
  11. package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
  12. package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
  13. package/src/adapters/interest-card/opencard/lint.sh +131 -0
  14. package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
  15. package/src/adapters/interest-card/opencard/pack.sh +27 -0
  16. package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
  17. package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
  18. package/src/adapters/interest-card/opencard/validate.mjs +11 -0
  19. package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
  20. package/src/build/opencard-environment.mjs +49 -0
  21. package/src/build/turbo-config.mjs +140 -0
  22. package/src/cli.mjs +104 -0
  23. package/src/commands/build.mjs +138 -0
  24. package/src/commands/db.mjs +14 -0
  25. package/src/commands/dev.mjs +319 -0
  26. package/src/commands/lint.mjs +176 -0
  27. package/src/commands/pack.mjs +169 -0
  28. package/src/commands/turbo.mjs +36 -0
  29. package/src/commands/validate.mjs +26 -0
  30. package/src/commands/verify.mjs +602 -0
  31. package/src/core/args.mjs +101 -0
  32. package/src/core/context.mjs +101 -0
  33. package/src/core/environment.mjs +55 -0
  34. package/src/core/errors.mjs +14 -0
  35. package/src/core/ports.mjs +76 -0
  36. package/src/core/process-output.mjs +25 -0
  37. package/src/core/runner.mjs +90 -0
  38. package/src/core/service-log-context.mjs +27 -0
  39. package/src/core/web-config.mjs +13 -0
  40. package/src/lint/lint-lynx-css.mjs +244 -0
  41. package/src/lint/lynx-css-rules.mjs +138 -0
  42. package/src/preview/dev-watch.mjs +38 -0
  43. package/src/preview/start-preview.mjs +213 -0
  44. package/src/preview/watch-card-routing.mjs +89 -0
  45. package/src/scripts/pack.mjs +15 -0
  46. package/src/validators/dependencies.mjs +34 -0
  47. package/src/validators/empty-project.mjs +29 -0
  48. package/src/validators/feed-card.cjs +156 -0
  49. package/src/validators/feed-card.mjs +32 -0
  50. package/src/validators/opencard-structure.mjs +58 -0
  51. package/src/validators/routes.cjs +470 -0
  52. package/src/validators/routes.mjs +17 -0
  53. package/src/validators/sql.mjs +57 -0
  54. package/src/validators/widget-shell.mjs +107 -0
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 兴趣卡底板校验:页面根 View 必须是盖在 9:16 背景上的半透明圆角面板。
4
+ * 规则来自 design-skill widget.md;由 pnpm lint 执行,不要手工 grep。
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import { formatFinding } from './lynx-css-rules.mjs';
11
+ import { lintCssText } from './lint-lynx-css.mjs';
12
+
13
+ const __dirname = path.join(process.env.VIBECODING_OPENCARD_ROOT || process.cwd(), 'scripts');
14
+ const ROOT = path.resolve(__dirname, '..');
15
+ const SRC = path.join(ROOT, 'src');
16
+ const PAGE_ENTRY = path.join(SRC, 'pages/index/index.tsx');
17
+ const require = createRequire(path.join(ROOT, 'scripts', 'package.json'));
18
+
19
+ const SHELL_CLASS = 'widget-root';
20
+ const REQUIRED_RADIUS_PX = 12;
21
+
22
+ const BORDER_STYLES = new Set([
23
+ 'none',
24
+ 'hidden',
25
+ 'solid',
26
+ 'dashed',
27
+ 'dotted',
28
+ 'double',
29
+ 'groove',
30
+ 'ridge',
31
+ 'inset',
32
+ 'outset',
33
+ ]);
34
+
35
+ export function splitSelectorList(selector) {
36
+ const parts = [];
37
+ let current = '';
38
+ let depth = 0;
39
+ for (let i = 0; i < selector.length; i += 1) {
40
+ const ch = selector[i];
41
+ if (ch === '(') depth += 1;
42
+ else if (ch === ')') depth = Math.max(0, depth - 1);
43
+ else if (depth === 0 && ch === ',') {
44
+ if (current.trim()) parts.push(current.trim());
45
+ current = '';
46
+ continue;
47
+ }
48
+ current += ch;
49
+ }
50
+ if (current.trim()) parts.push(current.trim());
51
+ return parts;
52
+ }
53
+
54
+ export function lastCompound(selectorPart) {
55
+ let idx = 0;
56
+ for (let i = 0; i < selectorPart.length; i += 1) {
57
+ const ch = selectorPart[i];
58
+ if (ch === '(') {
59
+ let depth = 1;
60
+ i += 1;
61
+ while (i < selectorPart.length && depth > 0) {
62
+ if (selectorPart[i] === '(') depth += 1;
63
+ else if (selectorPart[i] === ')') depth -= 1;
64
+ i += 1;
65
+ }
66
+ i -= 1;
67
+ continue;
68
+ }
69
+ if ((ch === ' ' || ch === '>' || ch === '+' || ch === '~') && i + 1 < selectorPart.length) {
70
+ idx = i + 1;
71
+ }
72
+ }
73
+ return selectorPart.slice(idx).trim();
74
+ }
75
+
76
+ export function classesInCompound(compound) {
77
+ const names = [];
78
+ const stripped = compound.replace(/:[^\s.#[]+/g, '');
79
+ const re = /\.(-?[_A-Za-z]+[_A-Za-z0-9-]*)/g;
80
+ let match;
81
+ while ((match = re.exec(stripped))) names.push(match[1]);
82
+ return names;
83
+ }
84
+
85
+ export function selectorAppliesToClasses(selector, classSet) {
86
+ return splitSelectorList(selector).some((part) => {
87
+ const classes = classesInCompound(lastCompound(part));
88
+ return classes.length > 0 && classes.every((name) => classSet.has(name));
89
+ });
90
+ }
91
+
92
+ export function isRootSelector(selector) {
93
+ return splitSelectorList(selector).some((part) => {
94
+ const compound = lastCompound(part);
95
+ return /(?:^|[^A-Za-z0-9_-]):root(?![A-Za-z0-9_-])/.test(compound);
96
+ });
97
+ }
98
+
99
+ function splitCssTokens(value) {
100
+ const parts = [];
101
+ let current = '';
102
+ let depth = 0;
103
+ for (let i = 0; i < value.length; i += 1) {
104
+ const ch = value[i];
105
+ if (ch === '(') depth += 1;
106
+ else if (ch === ')') depth = Math.max(0, depth - 1);
107
+ if (depth === 0 && /\s/.test(ch)) {
108
+ if (current) parts.push(current);
109
+ current = '';
110
+ } else {
111
+ current += ch;
112
+ }
113
+ }
114
+ if (current) parts.push(current);
115
+ return parts;
116
+ }
117
+
118
+ export function expandRadius(value) {
119
+ const tokens = splitCssTokens(value).filter((token) => token !== '/');
120
+ if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
121
+ if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
122
+ if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
123
+ if (tokens.length >= 4) return tokens.slice(0, 4);
124
+ return [null, null, null, null];
125
+ }
126
+
127
+ export function parseBorderShorthand(value) {
128
+ const result = {};
129
+ for (const token of splitCssTokens(value)) {
130
+ const lower = token.toLowerCase();
131
+ if (BORDER_STYLES.has(lower)) {
132
+ result['border-style'] = lower;
133
+ continue;
134
+ }
135
+ if (/^[\d.]+(%|px|vh|vw|rem|em)?$/.test(lower) || lower === 'thin' || lower === 'medium' || lower === 'thick') {
136
+ result['border-width'] = token;
137
+ continue;
138
+ }
139
+ result['border-color'] = token;
140
+ }
141
+ return result;
142
+ }
143
+
144
+ function extractBackgroundColor(value) {
145
+ const trimmed = value.trim();
146
+ if (!trimmed) return trimmed;
147
+ if (/^(none|transparent)$/i.test(trimmed)) return trimmed.toLowerCase();
148
+ const tokens = splitCssTokens(trimmed);
149
+ const colorLike = [...tokens].reverse().find((token) => {
150
+ const lower = token.toLowerCase();
151
+ return (
152
+ lower === 'transparent' ||
153
+ lower === 'none' ||
154
+ lower.startsWith('var(') ||
155
+ lower.startsWith('#') ||
156
+ lower.startsWith('rgb') ||
157
+ lower.startsWith('hsl') ||
158
+ /^[a-z]+$/i.test(token)
159
+ );
160
+ });
161
+ return colorLike || trimmed;
162
+ }
163
+
164
+ export function applyDecl(style, prop, value) {
165
+ const p = String(prop).trim().toLowerCase();
166
+ const v = String(value ?? '')
167
+ .replace(/\s*!important\s*$/i, '')
168
+ .trim();
169
+ if (!p || !v) return;
170
+
171
+ if (p.startsWith('--')) {
172
+ style[p] = v;
173
+ return;
174
+ }
175
+ if (p === 'background' || p === 'background-color') {
176
+ style['background-color'] = extractBackgroundColor(v);
177
+ return;
178
+ }
179
+ if (p === 'border') {
180
+ Object.assign(style, parseBorderShorthand(v));
181
+ return;
182
+ }
183
+ if (p === 'border-radius') {
184
+ const [tl, tr, br, bl] = expandRadius(v);
185
+ style['border-top-left-radius'] = tl;
186
+ style['border-top-right-radius'] = tr;
187
+ style['border-bottom-right-radius'] = br;
188
+ style['border-bottom-left-radius'] = bl;
189
+ return;
190
+ }
191
+ style[p] = v;
192
+ }
193
+
194
+ export function resolveValue(value, customProps, depth = 0) {
195
+ if (value == null) return '';
196
+ if (depth > 8) return String(value);
197
+ return String(value).replace(
198
+ /var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*((?:[^)(]+|\([^)(]*\))*))?\)/g,
199
+ (_, name, fallback) => {
200
+ if (customProps[name]) return resolveValue(customProps[name], customProps, depth + 1);
201
+ if (fallback) return resolveValue(fallback.trim(), customProps, depth + 1);
202
+ return '';
203
+ },
204
+ );
205
+ }
206
+
207
+ function parseNumber(token) {
208
+ const match = String(token)
209
+ .trim()
210
+ .match(/^(-?[\d.]+)(px|vh|vw|rem|em|%)?$/i);
211
+ if (!match) return null;
212
+ return { num: Number(match[1]), unit: (match[2] || '').toLowerCase() };
213
+ }
214
+
215
+ export function isTransparentColor(value) {
216
+ const v = String(value ?? '')
217
+ .trim()
218
+ .toLowerCase();
219
+ if (!v || v === 'transparent' || v === 'none' || v === 'unset' || v === 'initial') {
220
+ return true;
221
+ }
222
+ if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})00$/i.test(v) || /^#(?:[0-9a-f]{4})$/i.test(v) && v.endsWith('0')) {
223
+ return true;
224
+ }
225
+ const rgba = v.match(/^rgba?\(\s*([\d.]+)[%]?\s*[, ]\s*([\d.]+)[%]?\s*[, ]\s*([\d.]+)[%]?(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/);
226
+ if (rgba && rgba[4] != null) {
227
+ const raw = rgba[4];
228
+ const alpha = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw);
229
+ return Number.isFinite(alpha) && alpha === 0;
230
+ }
231
+ const hsla = v.match(/^hsla?\(/);
232
+ if (hsla) {
233
+ const alphaMatch = v.match(/[,/]\s*([\d.]+%?)\s*\)$/);
234
+ if (alphaMatch) {
235
+ const raw = alphaMatch[1];
236
+ const alpha = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw);
237
+ return Number.isFinite(alpha) && alpha === 0;
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+
243
+ export function isZeroLength(value) {
244
+ const parsed = parseNumber(value);
245
+ return parsed != null && parsed.num === 0;
246
+ }
247
+
248
+ export function isPx(value, px) {
249
+ const parsed = parseNumber(value);
250
+ return parsed != null && parsed.unit === 'px' && parsed.num === px;
251
+ }
252
+
253
+ function containsViewportHeight(value) {
254
+ return /(?:^|[^A-Za-z0-9_-])100vh(?:$|[^A-Za-z0-9_-])/i.test(String(value ?? ''));
255
+ }
256
+
257
+ function collectStaticStrings(node, result = []) {
258
+ if (!node) return result;
259
+ if (node.type === 'StringLiteral' || (node.type === 'Literal' && typeof node.value === 'string')) {
260
+ result.push(node.value);
261
+ return result;
262
+ }
263
+ if (node.type === 'TemplateLiteral') {
264
+ for (const quasi of node.quasis) {
265
+ if (quasi.value.cooked) result.push(quasi.value.cooked);
266
+ }
267
+ for (const expression of node.expressions) collectStaticStrings(expression, result);
268
+ return result;
269
+ }
270
+ if (node.type === 'JSXExpressionContainer') {
271
+ return collectStaticStrings(node.expression, result);
272
+ }
273
+ for (const key of [
274
+ 'left',
275
+ 'right',
276
+ 'argument',
277
+ 'callee',
278
+ 'test',
279
+ 'consequent',
280
+ 'alternate',
281
+ 'object',
282
+ 'property',
283
+ 'init',
284
+ 'body',
285
+ ]) {
286
+ if (node[key] && typeof node[key] === 'object') collectStaticStrings(node[key], result);
287
+ }
288
+ for (const key of ['arguments', 'elements', 'expressions', 'properties']) {
289
+ if (Array.isArray(node[key])) {
290
+ for (const child of node[key]) collectStaticStrings(child, result);
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+
296
+ function addClassTokens(classes, chunk) {
297
+ for (const token of String(chunk).split(/\s+/)) {
298
+ if (token && !token.includes('${')) classes.add(token);
299
+ }
300
+ }
301
+
302
+ function classNamesFromJSX(el) {
303
+ const classes = new Set();
304
+ const attrs = el.openingElement?.attributes || [];
305
+ for (const attr of attrs) {
306
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'className') continue;
307
+ for (const chunk of collectStaticStrings(attr.value)) addClassTokens(classes, chunk);
308
+ }
309
+ return classes;
310
+ }
311
+
312
+ function unwrapJSX(node) {
313
+ if (!node) return null;
314
+ if (node.type === 'JSXElement') return node;
315
+ if (node.type === 'JSXFragment') return node;
316
+ if (node.type === 'ParenthesizedExpression') return unwrapJSX(node.expression);
317
+ if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') {
318
+ return unwrapJSX(node.expression);
319
+ }
320
+ if (node.type === 'ConditionalExpression') {
321
+ return [unwrapJSX(node.consequent), unwrapJSX(node.alternate)].flat().filter(Boolean);
322
+ }
323
+ if (node.type === 'LogicalExpression') {
324
+ return [unwrapJSX(node.left), unwrapJSX(node.right)].flat().filter(Boolean);
325
+ }
326
+ return null;
327
+ }
328
+
329
+ function jsxName(el) {
330
+ const name = el.openingElement?.name;
331
+ if (name?.type === 'JSXIdentifier') return name.name;
332
+ return '';
333
+ }
334
+
335
+ function loadBabel() {
336
+ try {
337
+ const parser = require('@babel/parser');
338
+ const traverseMod = require('@babel/traverse');
339
+ const traverse = traverseMod.default ?? traverseMod;
340
+ return { parser, traverse };
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function resolveDefaultComponent(ast, traverse) {
347
+ let target = null;
348
+ traverse(ast, {
349
+ ExportDefaultDeclaration(pathNode) {
350
+ const decl = pathNode.node.declaration;
351
+ if (
352
+ decl.type === 'FunctionDeclaration' ||
353
+ decl.type === 'ArrowFunctionExpression' ||
354
+ decl.type === 'FunctionExpression'
355
+ ) {
356
+ target = pathNode.get('declaration');
357
+ return;
358
+ }
359
+ if (decl.type === 'Identifier') {
360
+ const binding = pathNode.scope.getBinding(decl.name);
361
+ if (!binding) return;
362
+ const bindPath = binding.path;
363
+ if (bindPath.isFunctionDeclaration()) {
364
+ target = bindPath;
365
+ return;
366
+ }
367
+ if (bindPath.isVariableDeclarator()) {
368
+ const init = bindPath.get('init');
369
+ if (init.isFunction() || init.isArrowFunctionExpression()) target = init;
370
+ }
371
+ }
372
+ },
373
+ });
374
+ return target;
375
+ }
376
+
377
+ function collectPageRootClassSetsRegex(source) {
378
+ const match = /<View\b([\s\S]*?)>/.exec(source);
379
+ if (!match) return [];
380
+ const classes = new Set();
381
+ for (const quoted of match[1].matchAll(/["'`]([^"'`]+)["'`]/g)) {
382
+ addClassTokens(classes, quoted[1]);
383
+ }
384
+ return [{ type: 'View', classes }];
385
+ }
386
+
387
+ export function collectPageRootClassSets(source) {
388
+ const babel = loadBabel();
389
+ if (!babel) return collectPageRootClassSetsRegex(source);
390
+
391
+ let ast;
392
+ try {
393
+ ast = babel.parser.parse(source, {
394
+ sourceType: 'module',
395
+ plugins: ['jsx', 'typescript'],
396
+ });
397
+ } catch {
398
+ return [];
399
+ }
400
+
401
+ const componentPath = resolveDefaultComponent(ast, babel.traverse);
402
+ if (!componentPath) return [];
403
+
404
+ const roots = [];
405
+ const takeReturned = (argument) => {
406
+ const unwrapped = unwrapJSX(argument);
407
+ const nodes = Array.isArray(unwrapped) ? unwrapped : unwrapped ? [unwrapped] : [];
408
+ for (const node of nodes) {
409
+ if (node.type === 'JSXFragment') {
410
+ roots.push({ type: 'fragment', classes: new Set() });
411
+ continue;
412
+ }
413
+ roots.push({ type: jsxName(node), classes: classNamesFromJSX(node) });
414
+ }
415
+ };
416
+
417
+ const body = componentPath.get('body');
418
+ if (body.isJSXElement() || body.isJSXFragment() || body.isParenthesizedExpression()) {
419
+ takeReturned(componentPath.node.body);
420
+ } else {
421
+ componentPath.traverse({
422
+ Function(inner) {
423
+ if (inner.node !== componentPath.node) inner.skip();
424
+ },
425
+ ReturnStatement(retPath) {
426
+ takeReturned(retPath.node.argument);
427
+ },
428
+ });
429
+ }
430
+
431
+ return uniqueRoots(roots);
432
+ }
433
+
434
+ function uniqueRoots(roots) {
435
+ const seen = new Set();
436
+ const out = [];
437
+ for (const root of roots) {
438
+ const key = `${root.type}:${[...root.classes].sort().join(',')}`;
439
+ if (seen.has(key)) continue;
440
+ seen.add(key);
441
+ out.push(root);
442
+ }
443
+ return out;
444
+ }
445
+
446
+ function uniqueFindings(findings) {
447
+ const seen = new Set();
448
+ const out = [];
449
+ for (const item of findings) {
450
+ const key = `${item.id}|${item.file}|${item.message}`;
451
+ if (seen.has(key)) continue;
452
+ seen.add(key);
453
+ out.push(item);
454
+ }
455
+ return out;
456
+ }
457
+
458
+ function listFiles(dir, test, acc = []) {
459
+ if (!fs.existsSync(dir)) return acc;
460
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
461
+ const target = path.join(dir, entry.name);
462
+ if (entry.isDirectory()) listFiles(target, test, acc);
463
+ else if (test(entry.name)) acc.push(target);
464
+ }
465
+ return acc;
466
+ }
467
+
468
+ function rel(file) {
469
+ return path.relative(ROOT, file).replace(/\\/g, '/');
470
+ }
471
+
472
+ function cssFileOrder(file) {
473
+ const name = rel(file);
474
+ if (name === 'src/app.css') return `0:${name}`;
475
+ return `1:${name}`;
476
+ }
477
+
478
+ export function collectCssRules(cssFiles) {
479
+ const rules = [];
480
+ const sorted = [...cssFiles].sort((a, b) => cssFileOrder(a).localeCompare(cssFileOrder(b)));
481
+ for (const file of sorted) {
482
+ const filename = path.isAbsolute(file) ? rel(file) : file;
483
+ const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : file.source;
484
+ const parsed = lintCssText(source, { filename });
485
+ for (const rule of parsed.rules) {
486
+ rules.push({
487
+ ...rule,
488
+ file: filename,
489
+ });
490
+ }
491
+ }
492
+ return rules;
493
+ }
494
+
495
+ export function collectCustomProps(rules) {
496
+ const props = {};
497
+ for (const rule of rules) {
498
+ if (!isRootSelector(rule.selector) && !selectorAppliesToClasses(rule.selector, new Set([SHELL_CLASS]))) {
499
+ continue;
500
+ }
501
+ for (const decl of rule.decls) {
502
+ if (decl.prop.startsWith('--')) props[decl.prop] = decl.value;
503
+ }
504
+ }
505
+ return props;
506
+ }
507
+
508
+ export function mergeStyleForClasses(rules, classSet) {
509
+ const style = {};
510
+ let loc = { file: 'src/pages/index/index.css', line: 1, column: 1 };
511
+ for (const rule of rules) {
512
+ if (!selectorAppliesToClasses(rule.selector, classSet)) continue;
513
+ loc = { file: rule.file, line: rule.line, column: rule.column };
514
+ for (const decl of rule.decls) applyDecl(style, decl.prop, decl.value);
515
+ }
516
+ return { style, loc };
517
+ }
518
+
519
+ function finding(id, message, loc) {
520
+ return {
521
+ id,
522
+ message,
523
+ file: loc?.file || 'src/pages/index/index.tsx',
524
+ line: loc?.line || 1,
525
+ column: loc?.column || 1,
526
+ };
527
+ }
528
+
529
+ export function validateMergedShell(style, customProps, loc) {
530
+ const findings = [];
531
+ const resolved = (prop) => resolveValue(style[prop], { ...customProps, ...style });
532
+
533
+ const heightValue = resolved('height');
534
+ const minHeight = resolved('min-height');
535
+ const maxHeight = resolved('max-height');
536
+ if ([heightValue, minHeight, maxHeight, resolved('width')].some(containsViewportHeight)) {
537
+ findings.push(
538
+ finding(
539
+ 'no-100vh',
540
+ '底板不要用 100vh。根铺满卡位请用 width/height: 100%。',
541
+ loc,
542
+ ),
543
+ );
544
+ }
545
+
546
+ if (resolved('width') !== '100%' || resolved('height') !== '100%') {
547
+ findings.push(
548
+ finding(
549
+ 'size-100',
550
+ '底板必须 width: 100%; height: 100%,直接承接宿主卡位。',
551
+ loc,
552
+ ),
553
+ );
554
+ }
555
+
556
+ if (resolved('box-sizing') !== 'border-box') {
557
+ findings.push(
558
+ finding('box-sizing', '底板必须 box-sizing: border-box。', loc),
559
+ );
560
+ }
561
+
562
+ if (resolved('display') !== 'flex' || resolved('flex-direction') !== 'column') {
563
+ findings.push(
564
+ finding(
565
+ 'flex-column',
566
+ '底板必须 display: flex; flex-direction: column。',
567
+ loc,
568
+ ),
569
+ );
570
+ }
571
+
572
+ const background = resolved('background-color');
573
+ if (!style['background-color'] || isTransparentColor(background)) {
574
+ findings.push(
575
+ finding(
576
+ 'opaque-bg',
577
+ '底板必须有非透明背景(background: var(--widget-bg)),禁止 transparent / none,不要让内容直接浮在 9:16 背景上。',
578
+ loc,
579
+ ),
580
+ );
581
+ }
582
+
583
+ const borderStyle = resolved('border-style');
584
+ const borderWidth = resolved('border-width');
585
+ const borderColor = resolved('border-color');
586
+ const missingBorder =
587
+ !style['border-style'] && !style['border-width'] && !style['border-color'];
588
+ if (
589
+ missingBorder ||
590
+ borderStyle === 'none' ||
591
+ borderStyle === 'hidden' ||
592
+ isZeroLength(borderWidth) ||
593
+ isTransparentColor(borderColor)
594
+ ) {
595
+ findings.push(
596
+ finding(
597
+ 'border',
598
+ '底板必须有 1px 描边(border: 1px solid var(--widget-border)),禁止 border: 0 / none。',
599
+ loc,
600
+ ),
601
+ );
602
+ }
603
+
604
+ const corners = [
605
+ resolved('border-top-left-radius'),
606
+ resolved('border-top-right-radius'),
607
+ resolved('border-bottom-right-radius'),
608
+ resolved('border-bottom-left-radius'),
609
+ ];
610
+ const missingCorner = corners.some((value) => !value || isZeroLength(value));
611
+ const notTwelve = corners.some((value) => value && !isPx(value, REQUIRED_RADIUS_PX));
612
+ if (missingCorner || notTwelve) {
613
+ findings.push(
614
+ finding(
615
+ 'radius',
616
+ `底板四角都必须是 border-radius: ${REQUIRED_RADIUS_PX}px,不要直角,也不要只圆上面或只圆下面。`,
617
+ loc,
618
+ ),
619
+ );
620
+ }
621
+
622
+ return findings;
623
+ }
624
+
625
+ export function lintWidgetShell({
626
+ pageSource,
627
+ pageFile = 'src/pages/index/index.tsx',
628
+ rules,
629
+ }) {
630
+ const findings = [];
631
+ const roots = collectPageRootClassSets(pageSource);
632
+ if (roots.length === 0) {
633
+ findings.push(
634
+ finding(
635
+ 'root-view',
636
+ '找不到页面根节点。opencard/src/pages/index/index.tsx 的默认导出必须 return 根 View。',
637
+ { file: pageFile, line: 1, column: 1 },
638
+ ),
639
+ );
640
+ return findings;
641
+ }
642
+
643
+ const customProps = collectCustomProps(rules);
644
+
645
+ for (const root of roots) {
646
+ if (root.type === 'fragment') {
647
+ findings.push(
648
+ finding(
649
+ 'root-view',
650
+ '页面根节点必须是 View,不能是 Fragment。底板写在根 class widget-root 上。',
651
+ { file: pageFile, line: 1, column: 1 },
652
+ ),
653
+ );
654
+ continue;
655
+ }
656
+ if (root.type !== 'View') {
657
+ findings.push(
658
+ finding(
659
+ 'root-view',
660
+ `页面根节点必须是 View(当前是 <${root.type || 'unknown'}>)。`,
661
+ { file: pageFile, line: 1, column: 1 },
662
+ ),
663
+ );
664
+ continue;
665
+ }
666
+ if (!root.classes.has(SHELL_CLASS)) {
667
+ findings.push(
668
+ finding(
669
+ 'shell-class',
670
+ '页面根 View 必须带 className widget-root(design-skill 底板)。Loading / Empty 同样落在这块面板上,不要另起一层铺满的透明根。',
671
+ { file: pageFile, line: 1, column: 1 },
672
+ ),
673
+ );
674
+ continue;
675
+ }
676
+
677
+ const { style, loc } = mergeStyleForClasses(rules, root.classes);
678
+ if (!Object.keys(style).length) {
679
+ findings.push(
680
+ finding(
681
+ 'shell-css',
682
+ '找不到 .widget-root 的 CSS。请在页面 CSS 里写底板:非透明背景、1px 边、四角 12px。',
683
+ { file: 'src/pages/index/index.css', line: 1, column: 1 },
684
+ ),
685
+ );
686
+ continue;
687
+ }
688
+ findings.push(...validateMergedShell(style, customProps, loc));
689
+ }
690
+
691
+ return uniqueFindings(findings);
692
+ }
693
+
694
+ function parseInlineCssFiles(files) {
695
+ return files.map((item) => {
696
+ const parsed = lintCssText(item.source, { filename: item.filename });
697
+ return parsed.rules.map((rule) => ({ ...rule, file: item.filename }));
698
+ }).flat();
699
+ }
700
+
701
+ export function lintWidgetShellFromSources({ pageSource, cssFiles }) {
702
+ const rules = parseInlineCssFiles(cssFiles);
703
+ return lintWidgetShell({ pageSource, rules });
704
+ }
705
+
706
+ function assertSelfTest() {
707
+ const page = (className) => `
708
+ export default function IndexPage() {
709
+ return (
710
+ <View className="${className}">
711
+ <Text>ok</Text>
712
+ </View>
713
+ );
714
+ }
715
+ `;
716
+
717
+ const goodCss = `
718
+ :root {
719
+ --bg-default: rgba(255,255,255,0.08);
720
+ --line-subtle: rgba(255,255,255,0.08);
721
+ --widget-bg: var(--bg-default);
722
+ --widget-border: var(--line-subtle);
723
+ --radius-xl: 0.75rem;
724
+ }
725
+ .widget-root {
726
+ box-sizing: border-box;
727
+ display: flex;
728
+ flex-direction: column;
729
+ width: 100%;
730
+ height: 100%;
731
+ margin: 0;
732
+ padding: 1rem 1.25rem;
733
+ background: var(--widget-bg);
734
+ border: 1px solid var(--widget-border);
735
+ border-radius: 12px;
736
+ overflow: hidden;
737
+ }
738
+ `;
739
+
740
+ const cases = [
741
+ {
742
+ name: 'good shell',
743
+ page: page('widget-root'),
744
+ css: goodCss,
745
+ id: null,
746
+ },
747
+ {
748
+ name: 'missing class',
749
+ page: page('opencard-page'),
750
+ css: goodCss.replaceAll('.widget-root', '.opencard-page'),
751
+ id: 'shell-class',
752
+ },
753
+ {
754
+ name: 'transparent background',
755
+ page: page('widget-root'),
756
+ css: goodCss.replace('background: var(--widget-bg);', 'background: transparent;'),
757
+ id: 'opaque-bg',
758
+ },
759
+ {
760
+ name: 'missing border',
761
+ page: page('widget-root'),
762
+ css: goodCss.replace('border: 1px solid var(--widget-border);', 'border: 0;'),
763
+ id: 'border',
764
+ },
765
+ {
766
+ name: 'top-only radius',
767
+ page: page('widget-root'),
768
+ css: goodCss.replace('border-radius: 12px;', 'border-radius: 12px 12px 0 0;'),
769
+ id: 'radius',
770
+ },
771
+ {
772
+ name: '16px radius',
773
+ page: page('widget-root'),
774
+ css: goodCss.replace('border-radius: 12px;', 'border-radius: 16px;'),
775
+ id: 'radius',
776
+ },
777
+ {
778
+ name: '100vh',
779
+ page: page('widget-root'),
780
+ css: goodCss.replace('height: 100%;', 'height: 100vh;'),
781
+ id: 'no-100vh',
782
+ },
783
+ {
784
+ name: 'duplicate returns report once',
785
+ page: `
786
+ export default function IndexPage() {
787
+ if (loading) {
788
+ return (
789
+ <View className="widget-root">
790
+ <Text>a</Text>
791
+ </View>
792
+ );
793
+ }
794
+ if (!data) {
795
+ return (
796
+ <View className="widget-root">
797
+ <Text>b</Text>
798
+ </View>
799
+ );
800
+ }
801
+ return (
802
+ <View className="widget-root">
803
+ <Text>c</Text>
804
+ </View>
805
+ );
806
+ }
807
+ `,
808
+ css: ':root { --widget-bg: rgba(255,255,255,0.08); }',
809
+ id: 'shell-css',
810
+ once: true,
811
+ },
812
+ {
813
+ name: 'intro modifier clears panel',
814
+ page: `
815
+ export default function IndexPage() {
816
+ return (
817
+ <View className={isIntro ? "widget-root widget-root-intro" : "widget-root"}>
818
+ <Text>ok</Text>
819
+ </View>
820
+ );
821
+ }
822
+ `,
823
+ css: `${goodCss}
824
+ .widget-root-intro {
825
+ background: transparent;
826
+ border-color: transparent;
827
+ }
828
+ `,
829
+ id: 'opaque-bg',
830
+ },
831
+ ];
832
+
833
+ const failures = [];
834
+ for (const item of cases) {
835
+ const findings = lintWidgetShellFromSources({
836
+ pageSource: item.page,
837
+ cssFiles: [
838
+ { filename: 'src/app.css', source: item.css.includes(':root') ? item.css : '' },
839
+ { filename: 'src/pages/index/index.css', source: item.css },
840
+ ],
841
+ });
842
+ const ids = findings.map((f) => f.id);
843
+ const hitCount = ids.filter((id) => id === item.id).length;
844
+ const ok =
845
+ item.id === null
846
+ ? findings.length === 0
847
+ : item.once
848
+ ? hitCount === 1
849
+ : hitCount > 0;
850
+ if (!ok) {
851
+ failures.push({ name: item.name, expected: item.id, actual: ids });
852
+ }
853
+ }
854
+
855
+ if (failures.length) {
856
+ console.error(`[widget-shell self-test] ${failures.length} failed`);
857
+ for (const fail of failures) {
858
+ console.error(` ${fail.name}: expected ${fail.expected}, got ${JSON.stringify(fail.actual)}`);
859
+ }
860
+ process.exit(1);
861
+ }
862
+ console.log(`[widget-shell self-test] ${cases.length} passed`);
863
+ }
864
+
865
+ function lintProject() {
866
+ if (!fs.existsSync(PAGE_ENTRY)) {
867
+ return [
868
+ finding(
869
+ 'root-view',
870
+ '缺少 src/pages/index/index.tsx。兴趣卡页面入口必须在这里,根 View 使用 widget-root 底板。',
871
+ { file: 'src/pages/index/index.tsx', line: 1, column: 1 },
872
+ ),
873
+ ];
874
+ }
875
+
876
+ const cssFiles = listFiles(SRC, (name) => name.endsWith('.css'));
877
+ const rules = collectCssRules(cssFiles);
878
+ return lintWidgetShell({
879
+ pageSource: fs.readFileSync(PAGE_ENTRY, 'utf8'),
880
+ pageFile: rel(PAGE_ENTRY),
881
+ rules,
882
+ });
883
+ }
884
+
885
+ export function main() {
886
+ if (process.argv.includes('--self-test')) {
887
+ assertSelfTest();
888
+ return;
889
+ }
890
+ const reports = lintProject();
891
+ if (reports.length === 0) return;
892
+ console.warn(
893
+ `WARN: 兴趣卡底板校验未通过(${reports.length}),不阻断构建:根 View 必须是半透明圆角面板(.widget-root),不要透明根或直角底板。`,
894
+ );
895
+ for (const item of reports) {
896
+ console.warn(formatFinding(item.file, item.line, item.column, item.message));
897
+ }
898
+ }
899
+
900
+ const invoked =
901
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
902
+ if (invoked) main();