@open-dy/vibecoding-cli 0.1.4 → 0.1.6-beta.0
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/package.json +4 -4
- package/release-provenance.json +5 -5
- package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +254 -12
- package/src/commands/build.mjs +7 -3
- package/src/commands/db.mjs +1 -0
- package/src/commands/lint.mjs +15 -12
- package/src/commands/verify.mjs +23 -9
- package/src/core/runner.mjs +27 -6
- package/src/validators/feed-card.cjs +1 -1
- package/src/validators/routes.cjs +4 -3
- package/src/validators/server-artifact.mjs +4 -2
- package/src/lint/lint-lynx-css.mjs +0 -244
- package/src/lint/lynx-css-rules.mjs +0 -138
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-dy/vibecoding-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6-beta.0",
|
|
4
4
|
"description": "Command orchestration, validation and packaging for Douyin vibecoding Taro and OpenCard projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"ajv": "^8.18.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@open-dy/vibecoding-sdk-taro": "
|
|
47
|
-
"@open-dy/vibecoding-sdk-opencard": "0.1.
|
|
46
|
+
"@open-dy/vibecoding-sdk-taro": "0.1.1-beta.0",
|
|
47
|
+
"@open-dy/vibecoding-sdk-opencard": "0.1.5-beta.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@open-dy/vibecoding-sdk-taro": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"license": "UNLICENSED",
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"registry": "https://registry.npmjs.org/",
|
|
63
|
-
"tag": "
|
|
63
|
+
"tag": "beta",
|
|
64
64
|
"access": "public"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/release-provenance.json
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"producer": "vibecoding-cli",
|
|
4
4
|
"packageName": "@open-dy/vibecoding-cli",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.6-beta.0",
|
|
6
6
|
"source": {
|
|
7
|
-
"digest": "sha256-
|
|
8
|
-
"files":
|
|
7
|
+
"digest": "sha256-f2018ad1f7d7656392315c81128e717ce478aeb75dedecdec706fe0035cb026d",
|
|
8
|
+
"files": 87
|
|
9
9
|
},
|
|
10
|
-
"payload": "sha256-
|
|
10
|
+
"payload": "sha256-e85c65882947a652f0d0ec0b05ad00afe0a9106143ff44467c80bb045b86879c",
|
|
11
11
|
"toolchain": {
|
|
12
|
-
"node": "v22.
|
|
12
|
+
"node": "v22.23.1",
|
|
13
13
|
"packageManager": null
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
lintAtRule,
|
|
14
14
|
lintDeclaration,
|
|
15
15
|
lintSelector,
|
|
16
|
+
normalizeValue,
|
|
17
|
+
toKebab,
|
|
16
18
|
} from './lynx-css-rules.mjs';
|
|
17
19
|
|
|
18
20
|
const __dirname = path.join(process.env.VIBECODING_OPENCARD_ROOT || process.cwd(), 'scripts');
|
|
@@ -417,6 +419,32 @@ function collectTextClassesRegex(source) {
|
|
|
417
419
|
return classes;
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
function collectTextParentClassesRegex(source) {
|
|
423
|
+
const classes = new Set();
|
|
424
|
+
const stack = [];
|
|
425
|
+
const tagPattern = /<(\/?)([A-Za-z][\w.]*)\b([^<>]*?)(\/?)>/g;
|
|
426
|
+
for (const match of source.matchAll(tagPattern)) {
|
|
427
|
+
const [, closing, tagName, attributes, selfClosing] = match;
|
|
428
|
+
if (closing) {
|
|
429
|
+
const index = stack.findLastIndex((entry) => entry.tagName === tagName);
|
|
430
|
+
if (index >= 0) stack.splice(index);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (tagName === 'Text') {
|
|
434
|
+
for (const ancestor of stack) {
|
|
435
|
+
for (const className of ancestor.classes) classes.add(className);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!selfClosing) {
|
|
439
|
+
const tagClasses = new Set();
|
|
440
|
+
const className = /className\s*=\s*(?:\{\s*)?(["'`])([^"'`]+)\1/.exec(match[0]);
|
|
441
|
+
if (className) addClassTokens(tagClasses, className[2].replace(/\$\{[^}]*\}/g, ' '));
|
|
442
|
+
stack.push({ tagName, classes: tagClasses });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return classes;
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
export function collectTextClassesFromTsx(source) {
|
|
421
449
|
const babel = loadBabel();
|
|
422
450
|
if (!babel) return collectTextClassesRegex(source);
|
|
@@ -445,6 +473,39 @@ export function collectTextClassesFromTsx(source) {
|
|
|
445
473
|
return classes;
|
|
446
474
|
}
|
|
447
475
|
|
|
476
|
+
export function collectTextParentClassesFromTsx(source) {
|
|
477
|
+
const babel = loadBabel();
|
|
478
|
+
if (!babel) return collectTextParentClassesRegex(source);
|
|
479
|
+
let ast;
|
|
480
|
+
try {
|
|
481
|
+
ast = babel.parser.parse(source, {
|
|
482
|
+
sourceType: 'module',
|
|
483
|
+
plugins: ['jsx', 'typescript'],
|
|
484
|
+
});
|
|
485
|
+
} catch {
|
|
486
|
+
return collectTextParentClassesRegex(source);
|
|
487
|
+
}
|
|
488
|
+
const classes = new Set();
|
|
489
|
+
babel.traverse(ast, {
|
|
490
|
+
JSXOpeningElement(pathNode) {
|
|
491
|
+
const name = pathNode.node.name;
|
|
492
|
+
if (name.type !== 'JSXIdentifier' || name.name !== 'Text') return;
|
|
493
|
+
let ancestor = pathNode.parentPath?.parentPath;
|
|
494
|
+
while (ancestor) {
|
|
495
|
+
if (ancestor.isJSXElement?.()) {
|
|
496
|
+
const openingElement = ancestor.node.openingElement;
|
|
497
|
+
for (const attr of openingElement.attributes) {
|
|
498
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'className') continue;
|
|
499
|
+
for (const chunk of collectStaticStrings(attr.value)) addClassTokens(classes, chunk);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
ancestor = ancestor.parentPath;
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
return classes;
|
|
507
|
+
}
|
|
508
|
+
|
|
448
509
|
function listFiles(dir, test, acc = []) {
|
|
449
510
|
if (!fs.existsSync(dir)) return acc;
|
|
450
511
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -455,8 +516,73 @@ function listFiles(dir, test, acc = []) {
|
|
|
455
516
|
return acc;
|
|
456
517
|
}
|
|
457
518
|
|
|
458
|
-
function
|
|
459
|
-
|
|
519
|
+
function cssImportsFromSource(source, importer) {
|
|
520
|
+
const imports = new Set();
|
|
521
|
+
const patterns = [
|
|
522
|
+
/\bimport\s+(?:[^'"]+\s+from\s+)?["']([^"']+\.css)["']/g,
|
|
523
|
+
/\brequire\(\s*["']([^"']+\.css)["']\s*\)/g,
|
|
524
|
+
/@import\s+(?:url\(\s*)?["']([^"']+\.css)["']\s*\)?\s*;/g,
|
|
525
|
+
];
|
|
526
|
+
for (const pattern of patterns) {
|
|
527
|
+
for (const match of source.matchAll(pattern)) {
|
|
528
|
+
imports.add(path.resolve(path.dirname(importer), match[1]));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return imports;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function addClasses(target, source) {
|
|
535
|
+
for (const className of source) target.add(className);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function classUsageByCss(tsxFiles, cssFiles) {
|
|
539
|
+
const usage = new Map(
|
|
540
|
+
cssFiles.map((file) => [
|
|
541
|
+
file,
|
|
542
|
+
{ textClasses: new Set(), textParentClasses: new Set() },
|
|
543
|
+
]),
|
|
544
|
+
);
|
|
545
|
+
const allTextClasses = new Set();
|
|
546
|
+
const allTextParentClasses = new Set();
|
|
547
|
+
for (const file of tsxFiles) {
|
|
548
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
549
|
+
const textClasses = collectTextClassesFromTsx(source);
|
|
550
|
+
const textParentClasses = collectTextParentClassesFromTsx(source);
|
|
551
|
+
addClasses(allTextClasses, textClasses);
|
|
552
|
+
addClasses(allTextParentClasses, textParentClasses);
|
|
553
|
+
for (const cssFile of cssImportsFromSource(source, file)) {
|
|
554
|
+
const entry = usage.get(cssFile);
|
|
555
|
+
if (!entry) continue;
|
|
556
|
+
addClasses(entry.textClasses, textClasses);
|
|
557
|
+
addClasses(entry.textParentClasses, textParentClasses);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const file of cssFiles) {
|
|
562
|
+
if (path.basename(file) !== 'app.css') continue;
|
|
563
|
+
addClasses(usage.get(file).textClasses, allTextClasses);
|
|
564
|
+
addClasses(usage.get(file).textParentClasses, allTextParentClasses);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let changed = true;
|
|
568
|
+
while (changed) {
|
|
569
|
+
changed = false;
|
|
570
|
+
for (const file of cssFiles) {
|
|
571
|
+
const entry = usage.get(file);
|
|
572
|
+
if (entry.textClasses.size === 0 && entry.textParentClasses.size === 0) continue;
|
|
573
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
574
|
+
for (const importedFile of cssImportsFromSource(source, file)) {
|
|
575
|
+
const importedEntry = usage.get(importedFile);
|
|
576
|
+
if (!importedEntry) continue;
|
|
577
|
+
const before = importedEntry.textClasses.size + importedEntry.textParentClasses.size;
|
|
578
|
+
addClasses(importedEntry.textClasses, entry.textClasses);
|
|
579
|
+
addClasses(importedEntry.textParentClasses, entry.textParentClasses);
|
|
580
|
+
const after = importedEntry.textClasses.size + importedEntry.textParentClasses.size;
|
|
581
|
+
if (after > before) changed = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return usage;
|
|
460
586
|
}
|
|
461
587
|
|
|
462
588
|
function lintTextContainerUsage(rules, textClasses, filename, extraFindings) {
|
|
@@ -481,32 +607,70 @@ function lintTextContainerUsage(rules, textClasses, filename, extraFindings) {
|
|
|
481
607
|
}
|
|
482
608
|
}
|
|
483
609
|
|
|
484
|
-
|
|
610
|
+
function lintHiddenTextParentUsage(rules, textParentClasses, filename, extraFindings) {
|
|
611
|
+
if (textParentClasses.size === 0) return;
|
|
612
|
+
for (const rule of rules) {
|
|
613
|
+
const subjects = lastCompoundSelector(rule.selector);
|
|
614
|
+
const hitsTextParent = subjects.some((compound) =>
|
|
615
|
+
classesInCompound(compound).some((name) => textParentClasses.has(name)),
|
|
616
|
+
);
|
|
617
|
+
if (!hitsTextParent) continue;
|
|
618
|
+
const displayNone = rule.decls.find(
|
|
619
|
+
(decl) =>
|
|
620
|
+
toKebab(decl.prop) === 'display' &&
|
|
621
|
+
normalizeValue(decl.value).toLowerCase() === 'none',
|
|
622
|
+
);
|
|
623
|
+
if (!displayNone) continue;
|
|
624
|
+
const clipsHiddenText = rule.decls.some(
|
|
625
|
+
(decl) =>
|
|
626
|
+
toKebab(decl.prop) === 'overflow' &&
|
|
627
|
+
normalizeValue(decl.value).toLowerCase() === 'hidden',
|
|
628
|
+
);
|
|
629
|
+
if (clipsHiddenText) continue;
|
|
630
|
+
extraFindings.push({
|
|
631
|
+
id: 'hidden-text-parent-overflow',
|
|
632
|
+
message:
|
|
633
|
+
'隐藏包含 Text 的父级时,请在同一个隐藏 selector 中同时写 ' +
|
|
634
|
+
'display: none 与 overflow: hidden,避免 Lynx 残留文字。',
|
|
635
|
+
line: displayNone.line,
|
|
636
|
+
column: displayNone.column,
|
|
637
|
+
selector: rule.selector,
|
|
638
|
+
file: filename,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function lintProject(
|
|
644
|
+
srcDir = SRC,
|
|
645
|
+
{ projectRoot = path.resolve(srcDir, '..') } = {},
|
|
646
|
+
) {
|
|
485
647
|
const cssFiles = listFiles(srcDir, (name) => name.endsWith('.css'));
|
|
486
648
|
const tsxFiles = listFiles(srcDir, (name) => /\.[jt]sx$/.test(name));
|
|
487
|
-
const
|
|
488
|
-
for (const file of tsxFiles) {
|
|
489
|
-
for (const name of collectTextClassesFromTsx(fs.readFileSync(file, 'utf8'))) {
|
|
490
|
-
textClasses.add(name);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
649
|
+
const usage = classUsageByCss(tsxFiles, cssFiles);
|
|
493
650
|
|
|
494
651
|
const reports = [];
|
|
495
652
|
const allRules = [];
|
|
496
653
|
for (const file of cssFiles) {
|
|
497
|
-
const filename =
|
|
654
|
+
const filename = path.relative(projectRoot, file).replace(/\\/g, '/');
|
|
498
655
|
const { findings, rules } = lintCssText(fs.readFileSync(file, 'utf8'), { filename });
|
|
499
656
|
for (const item of findings) reports.push({ ...item, file: filename });
|
|
500
657
|
for (const rule of rules) allRules.push({ ...rule, file: filename });
|
|
501
658
|
}
|
|
502
659
|
for (const file of cssFiles) {
|
|
503
|
-
const filename =
|
|
660
|
+
const filename = path.relative(projectRoot, file).replace(/\\/g, '/');
|
|
504
661
|
const subset = allRules.filter((rule) => rule.file === filename);
|
|
662
|
+
const { textClasses, textParentClasses } = usage.get(file);
|
|
505
663
|
lintTextContainerUsage(subset, textClasses, filename, reports);
|
|
664
|
+
lintHiddenTextParentUsage(subset, textParentClasses, filename, reports);
|
|
506
665
|
}
|
|
507
666
|
return reports;
|
|
508
667
|
}
|
|
509
668
|
|
|
669
|
+
export function lintOpenCardProject(projectRoot) {
|
|
670
|
+
const root = path.resolve(projectRoot);
|
|
671
|
+
return lintProject(path.join(root, 'src'), { projectRoot: root });
|
|
672
|
+
}
|
|
673
|
+
|
|
510
674
|
function printReports(reports) {
|
|
511
675
|
for (const item of reports) {
|
|
512
676
|
console.error(formatFinding(item.file, item.line, item.column, item.message));
|
|
@@ -649,6 +813,84 @@ function assertSelfTest() {
|
|
|
649
813
|
failures.push({ css: textCss, expected: 'text-container', actual: extra.map((f) => f.id) });
|
|
650
814
|
}
|
|
651
815
|
|
|
816
|
+
const ordinaryTextCss = '.label { display: block; } .label.is-hidden { display: none; }';
|
|
817
|
+
const ordinaryTextRules = lintCssText(ordinaryTextCss, { filename: 'src/pages/x.css' }).rules;
|
|
818
|
+
const ordinaryTextFindings = [];
|
|
819
|
+
lintTextContainerUsage(
|
|
820
|
+
ordinaryTextRules,
|
|
821
|
+
new Set(['label']),
|
|
822
|
+
'src/pages/x.css',
|
|
823
|
+
ordinaryTextFindings,
|
|
824
|
+
);
|
|
825
|
+
if (ordinaryTextFindings.length > 0) {
|
|
826
|
+
failures.push({
|
|
827
|
+
css: ordinaryTextCss,
|
|
828
|
+
expected: null,
|
|
829
|
+
actual: ordinaryTextFindings.map((finding) => finding.id),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const hiddenParentCases = [
|
|
834
|
+
{
|
|
835
|
+
css: '.panel { display: none; }',
|
|
836
|
+
expected: 'hidden-text-parent-overflow',
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
css: '.panel { display: none; overflow: hidden; }',
|
|
840
|
+
expected: null,
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
css: '.icon-wrap { display: none; }',
|
|
844
|
+
expected: null,
|
|
845
|
+
textParentClasses: new Set(),
|
|
846
|
+
},
|
|
847
|
+
];
|
|
848
|
+
const hiddenTextParents = collectTextParentClassesFromTsx(
|
|
849
|
+
'<View className="panel"><Text className="label">文字</Text></View>',
|
|
850
|
+
);
|
|
851
|
+
for (const item of hiddenParentCases) {
|
|
852
|
+
const hiddenRules = lintCssText(item.css, { filename: 'src/pages/x.css' }).rules;
|
|
853
|
+
const hiddenFindings = [];
|
|
854
|
+
lintHiddenTextParentUsage(
|
|
855
|
+
hiddenRules,
|
|
856
|
+
item.textParentClasses ?? hiddenTextParents,
|
|
857
|
+
'src/pages/x.css',
|
|
858
|
+
hiddenFindings,
|
|
859
|
+
);
|
|
860
|
+
const hit = hiddenFindings.some((finding) => finding.id === item.expected);
|
|
861
|
+
const ok = item.expected === null ? hiddenFindings.length === 0 : hit;
|
|
862
|
+
if (!ok) {
|
|
863
|
+
failures.push({
|
|
864
|
+
css: item.css,
|
|
865
|
+
expected: item.expected,
|
|
866
|
+
actual: hiddenFindings.map((finding) => finding.id),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const fixture = fs.mkdtempSync(path.join(process.cwd(), '.lynx-css-test-'));
|
|
872
|
+
try {
|
|
873
|
+
fs.writeFileSync(
|
|
874
|
+
path.join(fixture, 'index.tsx'),
|
|
875
|
+
'import "./index.css";\n<View className="panel"><Text className="label">文字</Text></View>',
|
|
876
|
+
);
|
|
877
|
+
fs.writeFileSync(
|
|
878
|
+
path.join(fixture, 'index.css'),
|
|
879
|
+
'.panel { display: none; overflow: hidden; }',
|
|
880
|
+
);
|
|
881
|
+
fs.writeFileSync(path.join(fixture, 'unrelated.css'), '.panel { display: none; }');
|
|
882
|
+
const crossFileReports = lintProject(fixture);
|
|
883
|
+
if (crossFileReports.length > 0) {
|
|
884
|
+
failures.push({
|
|
885
|
+
css: '.panel { display: none; } in unimported CSS',
|
|
886
|
+
expected: null,
|
|
887
|
+
actual: crossFileReports.map((finding) => finding.id),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
} finally {
|
|
891
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
892
|
+
}
|
|
893
|
+
|
|
652
894
|
if (failures.length) {
|
|
653
895
|
console.error(`[lynx-css self-test] ${failures.length} failed`);
|
|
654
896
|
for (const fail of failures) {
|
|
@@ -657,7 +899,7 @@ function assertSelfTest() {
|
|
|
657
899
|
}
|
|
658
900
|
process.exit(1);
|
|
659
901
|
}
|
|
660
|
-
console.log(`[lynx-css self-test] ${cases.length +
|
|
902
|
+
console.log(`[lynx-css self-test] ${cases.length + 6} passed`);
|
|
661
903
|
}
|
|
662
904
|
|
|
663
905
|
export function main() {
|
package/src/commands/build.mjs
CHANGED
|
@@ -24,6 +24,7 @@ export async function buildTaroFrontend(context, type, options = {}) {
|
|
|
24
24
|
cwd: context.taroRoot,
|
|
25
25
|
env: taroEnvironment(context.taroRoot, options.env),
|
|
26
26
|
label: `taro ${type} build`,
|
|
27
|
+
quiet: options.quiet,
|
|
27
28
|
})
|
|
28
29
|
if (type === 'tt' && !options.watch) {
|
|
29
30
|
writeWebConfig(path.join(context.taroRoot, 'dist-tt'))
|
|
@@ -45,6 +46,7 @@ export async function buildOpenCard(context, options = {}) {
|
|
|
45
46
|
...cardEnvironment,
|
|
46
47
|
}),
|
|
47
48
|
label: 'opencard tt build',
|
|
49
|
+
quiet: options.quiet,
|
|
48
50
|
})
|
|
49
51
|
if (!options.watch) {
|
|
50
52
|
writeWebConfig(path.join(root, options.env?.TT_OUTPUT_ROOT?.trim() || 'dist'))
|
|
@@ -62,6 +64,7 @@ export function buildServer(context, options = {}) {
|
|
|
62
64
|
cwd: context.taroRoot,
|
|
63
65
|
env: options.env,
|
|
64
66
|
label: 'server build',
|
|
67
|
+
quiet: options.quiet,
|
|
65
68
|
})
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -70,6 +73,7 @@ export function buildClosedDomainServer(context, options = {}) {
|
|
|
70
73
|
cwd: context.taroRoot,
|
|
71
74
|
env: options.env,
|
|
72
75
|
label: 'closed-domain-server build',
|
|
76
|
+
quiet: options.quiet,
|
|
73
77
|
})
|
|
74
78
|
}
|
|
75
79
|
|
|
@@ -90,7 +94,7 @@ export async function buildPackInputs(context, options = {}) {
|
|
|
90
94
|
if (context.hasOpenCard) {
|
|
91
95
|
tasks.push({ name: 'opencard', run: () => buildOpenCard(context, options) })
|
|
92
96
|
}
|
|
93
|
-
await runTasks(tasks, { label: 'pack build' })
|
|
97
|
+
await runTasks(tasks, { label: 'pack build', quiet: options.quiet })
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
export async function runBuild(context, options = {}) {
|
|
@@ -136,6 +140,6 @@ export async function runBuild(context, options = {}) {
|
|
|
136
140
|
if (context.hasOpenCard) {
|
|
137
141
|
tasks.push({ name: 'opencard', run: () => buildOpenCard(context, buildOptions) })
|
|
138
142
|
}
|
|
139
|
-
await runTasks(tasks, { label: 'build' })
|
|
140
|
-
validateRoutes(context)
|
|
143
|
+
await runTasks(tasks, { label: 'build', quiet: buildOptions.quiet })
|
|
144
|
+
validateRoutes(context, { quiet: buildOptions.quiet })
|
|
141
145
|
}
|
package/src/commands/db.mjs
CHANGED
package/src/commands/lint.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { CliError } from '../core/errors.mjs'
|
|
4
4
|
import { resolveProjectBinary, runCaptured, runProcess, runTasks } from '../core/runner.mjs'
|
|
5
|
-
import { lintOpenCardProject } from '../
|
|
5
|
+
import { lintOpenCardProject } from '../adapters/interest-card/opencard/lint-lynx-css.mjs'
|
|
6
6
|
import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
|
|
7
7
|
import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
|
|
8
8
|
import { validateSqlQueries } from '../validators/sql.mjs'
|
|
@@ -106,21 +106,21 @@ export async function lintOpenCard(context, options = {}) {
|
|
|
106
106
|
{ name: 'opencard-structure', run: async () => validateOpenCardStructure(validationContext) },
|
|
107
107
|
{ name: 'opencard-widget-shell', run: async () => validateWidgetShell(validationContext) },
|
|
108
108
|
{ name: 'opencard-lynx-css', run: () => runLynxCss(root, options.lintProject) },
|
|
109
|
-
{ name: 'opencard-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: root }) },
|
|
109
|
+
{ name: 'opencard-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: root, quiet: options.quiet }) },
|
|
110
110
|
]
|
|
111
111
|
if (eslintSelection.runEslint) {
|
|
112
112
|
tasks.push({
|
|
113
113
|
name: 'opencard-eslint',
|
|
114
|
-
run: () => runProcess(eslint, eslintArgs, { cwd: root }),
|
|
114
|
+
run: () => runProcess(eslint, eslintArgs, { cwd: root, quiet: options.quiet }),
|
|
115
115
|
})
|
|
116
116
|
}
|
|
117
|
-
await runTasks(tasks, { label: 'opencard lint' })
|
|
117
|
+
await runTasks(tasks, { label: 'opencard lint', quiet: options.quiet })
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export async function runLint(context, options = {}) {
|
|
121
121
|
const incremental = options.incremental || process.env.LINT_INCREMENTAL === '1'
|
|
122
122
|
if (context.target === 'opencard') {
|
|
123
|
-
await lintOpenCard(context, { incremental })
|
|
123
|
+
await lintOpenCard(context, { incremental, quiet: options.quiet })
|
|
124
124
|
return
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -142,8 +142,8 @@ export async function runLint(context, options = {}) {
|
|
|
142
142
|
const tasks = []
|
|
143
143
|
if (full || changed.some((file) => file.startsWith('src/'))) {
|
|
144
144
|
tasks.push(
|
|
145
|
-
{ name: 'taro-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.root }) },
|
|
146
|
-
{ name: 'taro-eslint', run: () => runProcess(eslint, eslintArgs, { cwd: context.root }) },
|
|
145
|
+
{ name: 'taro-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.root, quiet: options.quiet }) },
|
|
146
|
+
{ name: 'taro-eslint', run: () => runProcess(eslint, eslintArgs, { cwd: context.root, quiet: options.quiet }) },
|
|
147
147
|
)
|
|
148
148
|
}
|
|
149
149
|
if (full || serverChanged || closedDomainChanged) {
|
|
@@ -156,7 +156,7 @@ export async function runLint(context, options = {}) {
|
|
|
156
156
|
tasks.push({
|
|
157
157
|
name: 'server-lint',
|
|
158
158
|
run: () => runTasks([
|
|
159
|
-
{ name: 'server-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.serverRoot }) },
|
|
159
|
+
{ name: 'server-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.serverRoot, quiet: options.quiet }) },
|
|
160
160
|
{
|
|
161
161
|
name: 'server-eslint',
|
|
162
162
|
run: () => runProcess(eslint, [
|
|
@@ -164,13 +164,16 @@ export async function runLint(context, options = {}) {
|
|
|
164
164
|
'--cache-location',
|
|
165
165
|
'node_modules/.cache/eslint/server-src',
|
|
166
166
|
'src/**/*.{js,jsx,ts,tsx}',
|
|
167
|
-
], { cwd: context.serverRoot }),
|
|
167
|
+
], { cwd: context.serverRoot, quiet: options.quiet }),
|
|
168
168
|
},
|
|
169
|
-
], { label: 'server lint' }),
|
|
169
|
+
], { label: 'server lint', quiet: options.quiet }),
|
|
170
170
|
})
|
|
171
171
|
}
|
|
172
172
|
if (context.hasOpenCard && (full || changed.some((file) => file.startsWith('opencard/')))) {
|
|
173
|
-
tasks.push({
|
|
173
|
+
tasks.push({
|
|
174
|
+
name: 'opencard-lint',
|
|
175
|
+
run: () => lintOpenCard(context, { incremental: !full, quiet: options.quiet }),
|
|
176
|
+
})
|
|
174
177
|
}
|
|
175
|
-
await runTasks(tasks, { label: 'lint' })
|
|
178
|
+
await runTasks(tasks, { label: 'lint', quiet: options.quiet })
|
|
176
179
|
}
|
package/src/commands/verify.mjs
CHANGED
|
@@ -33,21 +33,23 @@ async function runStatic(context) {
|
|
|
33
33
|
validateDependencies(context)
|
|
34
34
|
const env = await readRuntimeEnvironment(context.root)
|
|
35
35
|
const tasks = [
|
|
36
|
-
{ name: 'lint', run: () => runLint(context) },
|
|
37
|
-
{ name: 'build', run: () => runBuild(context, { frontendTarget: 'tt' }) },
|
|
36
|
+
{ name: 'lint', run: () => runLint(context, { quiet: true }) },
|
|
37
|
+
{ name: 'build', run: () => runBuild(context, { frontendTarget: 'tt', quiet: true }) },
|
|
38
38
|
]
|
|
39
39
|
if (context.target === 'taro') {
|
|
40
40
|
tasks.push(
|
|
41
|
-
{ name: 'database', run: () => runDatabaseInit(context, { env }) },
|
|
42
|
-
{ name: 'feed-card', run: async () => validateFeedCard(context) },
|
|
41
|
+
{ name: 'database', run: () => runDatabaseInit(context, { env, quiet: true }) },
|
|
42
|
+
{ name: 'feed-card', run: async () => validateFeedCard(context, { quiet: true }) },
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
|
-
await runTasks(tasks, { label: 'static verification' })
|
|
45
|
+
await runTasks(tasks, { label: 'static verification', quiet: true })
|
|
46
46
|
if (context.target === 'taro') {
|
|
47
47
|
validateServerArtifactFileCount(context.serverRoot ?? path.join(context.root, 'server'), {
|
|
48
48
|
packScriptPath: path.join(context.root, 'scripts/pack.sh'),
|
|
49
|
+
quiet: true,
|
|
49
50
|
})
|
|
50
51
|
}
|
|
52
|
+
console.log('[verify] Static phase passed (all parallel tasks OK).')
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export function startDevDetached(context, spawnProcess = spawn) {
|
|
@@ -623,19 +625,31 @@ export async function runVerify(context, options = {}) {
|
|
|
623
625
|
options = { timeoutSeconds: 90, ...options, full }
|
|
624
626
|
const frontendUrl = resolveFrontendUrl(context, options)
|
|
625
627
|
parseHttpUrl(frontendUrl, 'frontend')
|
|
626
|
-
if (full || options.staticOnly)
|
|
627
|
-
|
|
628
|
+
if (full || options.staticOnly) {
|
|
629
|
+
await runStatic(context)
|
|
630
|
+
}
|
|
631
|
+
if (options.staticOnly) {
|
|
632
|
+
console.log('[verify] --static-only: done.')
|
|
633
|
+
return
|
|
634
|
+
}
|
|
628
635
|
|
|
629
636
|
await verifyPorts(context, options, frontendUrl)
|
|
630
|
-
if (options.portsOnly)
|
|
631
|
-
|
|
637
|
+
if (options.portsOnly) {
|
|
638
|
+
console.log('[verify] --ports-only: done.')
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
if (full && context.target === 'taro') {
|
|
642
|
+
await verifyFeedApi(context)
|
|
643
|
+
}
|
|
632
644
|
if (context.target === 'taro' &&
|
|
633
645
|
existsSync(path.resolve(context.root, '../sar_game/package.json'))) {
|
|
634
646
|
console.log('[vibecoding:verify] SAR game project detected; skipping browser runtime checks')
|
|
647
|
+
console.log('[verify] All checks passed.')
|
|
635
648
|
return
|
|
636
649
|
}
|
|
637
650
|
await browserCheck(context.root, frontendUrl, context.target)
|
|
638
651
|
if (context.target === 'taro' && context.hasOpenCard) {
|
|
639
652
|
await browserCheck(context.opencardRoot, 'http://localhost:10087/', 'opencard')
|
|
640
653
|
}
|
|
654
|
+
console.log('[verify] All checks passed.')
|
|
641
655
|
}
|
package/src/core/runner.mjs
CHANGED
|
@@ -12,28 +12,49 @@ export function resolveSdkTaroEntry(root) {
|
|
|
12
12
|
return createRequire(path.join(root, 'package.json')).resolve('@open-dy/vibecoding-sdk-opencard/build/taro-toolchain')
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function withCapturedOutput(message, stdout, stderr) {
|
|
16
|
+
if (!stdout && !stderr) return message
|
|
17
|
+
const separator = stdout && stderr && !stdout.endsWith('\n') ? '\n' : ''
|
|
18
|
+
return `${message}\n${stdout}${separator}${stderr}`
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export function runProcess(command, args = [], options = {}) {
|
|
16
22
|
const label = options.label ?? path.basename(command)
|
|
17
23
|
return new Promise((resolve, reject) => {
|
|
24
|
+
const captureOutput = options.quiet && options.stdio === undefined
|
|
18
25
|
const child = spawn(command, args, {
|
|
19
26
|
cwd: options.cwd,
|
|
20
27
|
env: { ...process.env, ...options.env },
|
|
21
|
-
stdio: options.stdio ?? 'inherit',
|
|
28
|
+
stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : (options.stdio ?? 'inherit'),
|
|
22
29
|
detached: options.detached ?? false,
|
|
23
30
|
shell: options.shell ?? false,
|
|
24
31
|
})
|
|
32
|
+
let stdout = ''
|
|
33
|
+
let stderr = ''
|
|
34
|
+
if (captureOutput) {
|
|
35
|
+
child.stdout.setEncoding('utf8')
|
|
36
|
+
child.stderr.setEncoding('utf8')
|
|
37
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
38
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
39
|
+
}
|
|
25
40
|
|
|
26
41
|
child.on('error', (error) => {
|
|
27
|
-
reject(new CliError(
|
|
42
|
+
reject(new CliError(withCapturedOutput(
|
|
43
|
+
`${label} failed to start: ${error.message}`,
|
|
44
|
+
stdout,
|
|
45
|
+
stderr,
|
|
46
|
+
)))
|
|
28
47
|
})
|
|
29
48
|
child.on('exit', (code, signal) => {
|
|
30
49
|
if (code === 0) {
|
|
31
50
|
resolve({ code: 0, signal, child })
|
|
32
51
|
return
|
|
33
52
|
}
|
|
34
|
-
reject(new CliError(
|
|
53
|
+
reject(new CliError(withCapturedOutput(
|
|
35
54
|
`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`,
|
|
36
|
-
|
|
55
|
+
stdout,
|
|
56
|
+
stderr,
|
|
57
|
+
)))
|
|
37
58
|
})
|
|
38
59
|
})
|
|
39
60
|
}
|
|
@@ -78,9 +99,9 @@ export function runCaptured(command, args = [], options = {}) {
|
|
|
78
99
|
export async function runTasks(tasks, options = {}) {
|
|
79
100
|
const results = await Promise.allSettled(
|
|
80
101
|
tasks.map(async ({ name, run }) => {
|
|
81
|
-
console.log(`[vibecoding] starting ${name}`)
|
|
102
|
+
if (!options.quiet) console.log(`[vibecoding] starting ${name}`)
|
|
82
103
|
await run()
|
|
83
|
-
console.log(`[vibecoding] completed ${name}`)
|
|
104
|
+
if (!options.quiet) console.log(`[vibecoding] completed ${name}`)
|
|
84
105
|
return name
|
|
85
106
|
}),
|
|
86
107
|
)
|
|
@@ -3,6 +3,7 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
function validateRoutes(context, options = {}) {
|
|
5
5
|
if (context.target !== 'taro') return;
|
|
6
|
+
const quiet = Boolean(options.quiet);
|
|
6
7
|
const ROOT = context.root;
|
|
7
8
|
const routesDir = options.routesDir || '/tmp/routes';
|
|
8
9
|
const CLIENT_FILE = path.join(routesDir, 'client-routes.json');
|
|
@@ -402,7 +403,7 @@ function main() {
|
|
|
402
403
|
const closedDomainServerRoutes = loadClosedDomainServerRoutes();
|
|
403
404
|
const cardRoutingConfig = loadCardRoutingConfig();
|
|
404
405
|
|
|
405
|
-
console.log('=== 前后端路由一致性检测报告 ===\n');
|
|
406
|
+
if (!quiet) console.log('=== 前后端路由一致性检测报告 ===\n');
|
|
406
407
|
|
|
407
408
|
assertOpenDomainServerDoesNotCallClosedDomainOpenAPI();
|
|
408
409
|
|
|
@@ -451,7 +452,7 @@ function main() {
|
|
|
451
452
|
}
|
|
452
453
|
}
|
|
453
454
|
|
|
454
|
-
console.log('--- 检测结果 ---\n');
|
|
455
|
+
if (!quiet) console.log('--- 检测结果 ---\n');
|
|
455
456
|
|
|
456
457
|
if (mismatched.length > 0) {
|
|
457
458
|
console.error(`未匹配(${mismatched.length} 条):以下前端请求路径未匹配到所选服务域的服务端路由,请检查前端请求路径、服务域配置,或确认服务端已注册对应路由`);
|
|
@@ -460,7 +461,7 @@ function main() {
|
|
|
460
461
|
}
|
|
461
462
|
throw new Error('前后端路由一致性检测失败');
|
|
462
463
|
} else {
|
|
463
|
-
console.log('所有前端静态请求路径均已在服务端注册对应路由,检测通过。');
|
|
464
|
+
if (!quiet) console.log('所有前端静态请求路径均已在服务端注册对应路由,检测通过。');
|
|
464
465
|
}
|
|
465
466
|
}
|
|
466
467
|
|
|
@@ -63,7 +63,7 @@ function countFiles(target, policy) {
|
|
|
63
63
|
(policy.excludes && isServerPackExcluded(name, policy) ? 0 : countFiles(path.join(target, name), policy)), 0)
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export function validateServerArtifactFileCount(serverRoot, { packScriptPath } = {}) {
|
|
66
|
+
export function validateServerArtifactFileCount(serverRoot, { packScriptPath, quiet = false } = {}) {
|
|
67
67
|
if (!existsSync(serverRoot)) throw new CliError(`Server directory is missing: ${serverRoot}`)
|
|
68
68
|
let policy = DEFAULT_POLICY
|
|
69
69
|
if (packScriptPath) {
|
|
@@ -84,6 +84,8 @@ export function validateServerArtifactFileCount(serverRoot, { packScriptPath } =
|
|
|
84
84
|
if (count > MAX_FILE_COUNT) {
|
|
85
85
|
throw new CliError(`Packed server artifact ${serverRoot} would contain ${count} files, exceeding the limit ${MAX_FILE_COUNT}. This count follows the server pack policy. Reduce files included by pack before submission.`)
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
if (!quiet) {
|
|
88
|
+
console.log(`[verify] static:server-artifact-file-count → OK (${count}/${MAX_FILE_COUNT}, ${serverRoot}).`)
|
|
89
|
+
}
|
|
88
90
|
return { skipped: false, count, missing: [] }
|
|
89
91
|
}
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* OpenCard Lynx CSS 静态检查:扫 src 下 CSS,并复用同一规则检查 Text class。
|
|
4
|
-
*/
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import {
|
|
9
|
-
formatFinding,
|
|
10
|
-
isTextContainerDeclaration,
|
|
11
|
-
lintAtRule,
|
|
12
|
-
lintDeclaration,
|
|
13
|
-
lintSelector,
|
|
14
|
-
} from './lynx-css-rules.mjs';
|
|
15
|
-
|
|
16
|
-
// Keep string literals intact: comment delimiters inside quoted URLs are data.
|
|
17
|
-
function scanComments(source) {
|
|
18
|
-
const chars = source.split('');
|
|
19
|
-
const errors = [];
|
|
20
|
-
let quote = '';
|
|
21
|
-
for (let i = 0; i < source.length; i += 1) {
|
|
22
|
-
if (quote) {
|
|
23
|
-
if (source[i] === '\\') i += 1;
|
|
24
|
-
else if (source[i] === quote) quote = '';
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
if (source[i] === '"' || source[i] === "'") {
|
|
28
|
-
quote = source[i];
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (source.slice(i, i + 2) === '*/') {
|
|
32
|
-
errors.push({ index: i, message: '孤立的 CSS 注释结束符 */;检查文件是否缺失开头。' });
|
|
33
|
-
i += 1;
|
|
34
|
-
} else if (source.slice(i, i + 2) === '/*') {
|
|
35
|
-
const end = source.indexOf('*/', i + 2);
|
|
36
|
-
if (end < 0) errors.push({ index: i, message: 'CSS 注释未闭合,缺少 */。' });
|
|
37
|
-
const stop = end < 0 ? source.length : end + 2;
|
|
38
|
-
for (let j = i; j < stop; j += 1) {
|
|
39
|
-
if (chars[j] !== '\n') chars[j] = ' ';
|
|
40
|
-
}
|
|
41
|
-
i = stop - 1;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return { content: chars.join(''), errors };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function stripCommentsPreservingLines(source) {
|
|
48
|
-
return scanComments(source).content;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function lineAt(source, index) {
|
|
52
|
-
const before = source.slice(0, index);
|
|
53
|
-
return {
|
|
54
|
-
line: before.split('\n').length,
|
|
55
|
-
column: index - before.lastIndexOf('\n'),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function splitTopLevel(source, delimiter) {
|
|
60
|
-
const parts = [];
|
|
61
|
-
let current = '';
|
|
62
|
-
let depth = 0;
|
|
63
|
-
let quote = '';
|
|
64
|
-
for (const char of source) {
|
|
65
|
-
if (quote) {
|
|
66
|
-
current += char;
|
|
67
|
-
if (char === quote) quote = '';
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (char === '"' || char === "'") {
|
|
71
|
-
quote = char;
|
|
72
|
-
current += char;
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
if (char === '(') depth += 1;
|
|
76
|
-
if (char === ')') depth = Math.max(0, depth - 1);
|
|
77
|
-
if (char === delimiter && depth === 0) {
|
|
78
|
-
parts.push(current);
|
|
79
|
-
current = '';
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
current += char;
|
|
83
|
-
}
|
|
84
|
-
if (current) parts.push(current);
|
|
85
|
-
return parts;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function walkRules(source, onRule, offset = 0) {
|
|
89
|
-
let index = 0;
|
|
90
|
-
while (index < source.length) {
|
|
91
|
-
const open = source.indexOf('{', index);
|
|
92
|
-
if (open < 0) break;
|
|
93
|
-
const selector = source.slice(index, open).trim();
|
|
94
|
-
let depth = 1;
|
|
95
|
-
let cursor = open + 1;
|
|
96
|
-
while (cursor < source.length && depth > 0) {
|
|
97
|
-
if (source[cursor] === '{') depth += 1;
|
|
98
|
-
if (source[cursor] === '}') depth -= 1;
|
|
99
|
-
cursor += 1;
|
|
100
|
-
}
|
|
101
|
-
const bodyEnd = depth === 0 ? cursor - 1 : source.length;
|
|
102
|
-
const body = source.slice(open + 1, bodyEnd);
|
|
103
|
-
onRule({ selector, body, selectorOffset: offset + index, bodyOffset: offset + open + 1 });
|
|
104
|
-
index = cursor;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function listFiles(directory, predicate, files = []) {
|
|
109
|
-
if (!fs.existsSync(directory)) return files;
|
|
110
|
-
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
111
|
-
const target = path.join(directory, entry.name);
|
|
112
|
-
if (entry.isDirectory()) {
|
|
113
|
-
listFiles(target, predicate, files);
|
|
114
|
-
} else if (predicate(target)) {
|
|
115
|
-
files.push(target);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return files;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function textClassTokensFromTsx(source) {
|
|
122
|
-
const names = new Set();
|
|
123
|
-
for (const match of source.matchAll(/<Text\b[^>]*\bclassName\s*=\s*(?:"([^"]+)"|'([^']+)')/g)) {
|
|
124
|
-
const value = match[1] ?? match[2] ?? '';
|
|
125
|
-
for (const className of value.split(/\s+/)) {
|
|
126
|
-
if (className) names.add(className);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return names;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function lintCssText(source, { filename = 'input.css' } = {}) {
|
|
133
|
-
const { content, errors } = scanComments(source);
|
|
134
|
-
const findings = errors.map(({ index, message }) => ({
|
|
135
|
-
file: filename, ...lineAt(source, index), message,
|
|
136
|
-
}));
|
|
137
|
-
const textClassRules = new Map();
|
|
138
|
-
|
|
139
|
-
walkRules(content, ({ selector, body, selectorOffset, bodyOffset }) => {
|
|
140
|
-
const selectorLocation = lineAt(content, selectorOffset);
|
|
141
|
-
if (selector.startsWith('@')) {
|
|
142
|
-
const name = selector.split(/\s|\{/)[0];
|
|
143
|
-
for (const item of lintAtRule(name)) {
|
|
144
|
-
findings.push({ file: filename, ...selectorLocation, message: item.message });
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const allowRoot = filename.replace(/\\/g, '/').endsWith('/src/app.css') || filename === 'src/app.css';
|
|
150
|
-
for (const item of lintSelector(selector, { allowRoot })) {
|
|
151
|
-
findings.push({ file: filename, ...selectorLocation, message: item.message });
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const classNames = [...selector.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((match) => match[1]);
|
|
155
|
-
const declarations = [];
|
|
156
|
-
let consumed = 0;
|
|
157
|
-
for (const fragment of splitTopLevel(body, ';')) {
|
|
158
|
-
const colon = fragment.indexOf(':');
|
|
159
|
-
if (colon < 0) {
|
|
160
|
-
consumed += fragment.length + 1;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const property = fragment.slice(0, colon).trim();
|
|
164
|
-
const value = fragment.slice(colon + 1).trim();
|
|
165
|
-
if (!property || !value) {
|
|
166
|
-
consumed += fragment.length + 1;
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const location = lineAt(content, bodyOffset + consumed);
|
|
170
|
-
declarations.push({ property, value, location });
|
|
171
|
-
for (const item of lintDeclaration(property, value)) {
|
|
172
|
-
findings.push({ file: filename, ...location, message: item.message });
|
|
173
|
-
}
|
|
174
|
-
consumed += fragment.length + 1;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
for (const className of classNames) {
|
|
178
|
-
textClassRules.set(className, declarations);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
return { findings, textClassRules };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function lintProject(
|
|
186
|
-
sourceDirectory = path.join(process.cwd(), 'src'),
|
|
187
|
-
{ projectRoot = path.resolve(sourceDirectory, '..') } = {},
|
|
188
|
-
) {
|
|
189
|
-
const reports = [];
|
|
190
|
-
const textClasses = new Set();
|
|
191
|
-
const cssFiles = listFiles(sourceDirectory, (file) => file.endsWith('.css'));
|
|
192
|
-
const tsxFiles = listFiles(sourceDirectory, (file) => /\.(?:tsx|jsx)$/.test(file));
|
|
193
|
-
const ruleMap = new Map();
|
|
194
|
-
|
|
195
|
-
for (const file of tsxFiles) {
|
|
196
|
-
for (const className of textClassTokensFromTsx(fs.readFileSync(file, 'utf8'))) {
|
|
197
|
-
textClasses.add(className);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
for (const file of cssFiles) {
|
|
202
|
-
const relative = path.relative(projectRoot, file).replace(/\\/g, '/');
|
|
203
|
-
const result = lintCssText(fs.readFileSync(file, 'utf8'), { filename: relative });
|
|
204
|
-
reports.push(...result.findings);
|
|
205
|
-
for (const [className, declarations] of result.textClassRules) {
|
|
206
|
-
ruleMap.set(className, { declarations, filename: relative });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
for (const [className, rule] of ruleMap) {
|
|
211
|
-
if (!textClasses.has(className)) continue;
|
|
212
|
-
for (const declaration of rule.declarations) {
|
|
213
|
-
if (!isTextContainerDeclaration(declaration.property)) continue;
|
|
214
|
-
reports.push({
|
|
215
|
-
file: rule.filename,
|
|
216
|
-
...declaration.location,
|
|
217
|
-
message: `Text 不是 Flex/Grid 容器,不要写 ${declaration.property}: ${declaration.value}。多个节点的排列由外层 View 承担。`,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return reports;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export function lintOpenCardProject(projectRoot) {
|
|
226
|
-
const root = path.resolve(projectRoot);
|
|
227
|
-
return lintProject(path.join(root, 'src'), { projectRoot: root });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function main() {
|
|
231
|
-
const reports = lintOpenCardProject(process.cwd());
|
|
232
|
-
if (reports.length === 0) return;
|
|
233
|
-
console.error(`ERROR: OpenCard Lynx CSS 检查失败(${reports.length})`);
|
|
234
|
-
for (const report of reports) {
|
|
235
|
-
console.error(formatFinding(report.file, report.line, report.column, report.message));
|
|
236
|
-
}
|
|
237
|
-
process.exit(1);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const invoked =
|
|
241
|
-
process.argv[1] &&
|
|
242
|
-
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
243
|
-
|
|
244
|
-
if (invoked) main();
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenCard Lynx CSS 机械检查规则。
|
|
3
|
-
*
|
|
4
|
-
* CLI 自己执行 OpenCard lint,因此这里不依赖 SDK;SDK 暴露给项目 ESLint 配置的
|
|
5
|
-
* 规则仍独立维护在 SDK 内。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export const HINT = '见 references/opencard/rendering.md';
|
|
9
|
-
|
|
10
|
-
const FORBIDDEN_AT_RULES = new Set(['media', 'supports', 'layer', 'namespace']);
|
|
11
|
-
|
|
12
|
-
const FORBIDDEN_PROPERTIES = new Map([
|
|
13
|
-
['float', '不要用 float。改用 Flex / Grid。'],
|
|
14
|
-
['clear', '不要用 clear。改用 Flex / Grid。'],
|
|
15
|
-
['pointer-events', 'Lynx 不支持 pointer-events。用条件渲染或事件层级。'],
|
|
16
|
-
['cursor', 'Lynx 不支持 cursor。不要生成。'],
|
|
17
|
-
['resize', 'Lynx 不支持 resize。用组件状态或 JS 调尺寸。'],
|
|
18
|
-
['scroll-behavior', 'Lynx 不支持 scroll-behavior。不要生成。'],
|
|
19
|
-
['overscroll-behavior', '不要用 overscroll-behavior 承接滚动。'],
|
|
20
|
-
['content', '不能用伪元素生成内容。增加真实 Taro 节点。'],
|
|
21
|
-
['list-style', '列表 marker 不可用。用真实节点表达。'],
|
|
22
|
-
['list-style-type', '列表 marker 不可用。用真实节点表达。'],
|
|
23
|
-
['word-break', 'Lynx 不支持 word-break。控制文本节点宽度。'],
|
|
24
|
-
['overflow-wrap', 'Lynx 不支持 overflow-wrap。控制文本节点宽度。'],
|
|
25
|
-
['word-wrap', 'Lynx 不支持 word-wrap。控制文本节点宽度。'],
|
|
26
|
-
['-webkit-line-clamp', '不要迁移 -webkit-line-clamp。收敛内容或使用单行省略。'],
|
|
27
|
-
['-webkit-box-orient', '不要迁移 -webkit-box。'],
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
const TEXT_CONTAINER_PROPS = new Set([
|
|
31
|
-
'flex-direction',
|
|
32
|
-
'flex-wrap',
|
|
33
|
-
'flex-flow',
|
|
34
|
-
'justify-content',
|
|
35
|
-
'align-items',
|
|
36
|
-
'align-content',
|
|
37
|
-
'gap',
|
|
38
|
-
'row-gap',
|
|
39
|
-
'column-gap',
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
export function toKebab(value) {
|
|
43
|
-
return String(value ?? '')
|
|
44
|
-
.trim()
|
|
45
|
-
.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)
|
|
46
|
-
.toLowerCase();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function formatFinding(file, line, column, message) {
|
|
50
|
-
return `${file}:${line}:${column} ${message}`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function issue(message) {
|
|
54
|
-
return { message: `${message} ${HINT}` };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function lintAtRule(name) {
|
|
58
|
-
const normalized = String(name ?? '').trim().replace(/^@/, '').toLowerCase();
|
|
59
|
-
if (!FORBIDDEN_AT_RULES.has(normalized)) {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
if (normalized === 'media') {
|
|
63
|
-
return [issue('Lynx 的 @media 不生效。容器用 flex、vw/vh 适配。')];
|
|
64
|
-
}
|
|
65
|
-
return [issue(`Lynx 不支持 @${normalized}。使用常规 CSS 或运行时 class。`)];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function lintSelector(selector, { allowRoot = false } = {}) {
|
|
69
|
-
const normalized = String(selector ?? '');
|
|
70
|
-
const findings = [];
|
|
71
|
-
if (/::(?:before|after|first-line|first-letter|marker|backdrop)\b/i.test(normalized)) {
|
|
72
|
-
findings.push(issue('不要使用 CSS 伪元素。需要内容时增加真实节点。'));
|
|
73
|
-
}
|
|
74
|
-
if (/:?(?:first-child|last-child|only-child|nth-child|nth-of-type|has)\b/i.test(normalized)) {
|
|
75
|
-
findings.push(issue('不要依赖结构伪类。为对应节点添加显式 class。'));
|
|
76
|
-
}
|
|
77
|
-
if (!allowRoot && /(?:^|[^A-Za-z0-9_-]):root(?![A-Za-z0-9_-])/i.test(normalized)) {
|
|
78
|
-
findings.push(issue(':root 只允许出现在 src/app.css。页面和组件变量写在局部 class。'));
|
|
79
|
-
}
|
|
80
|
-
return findings;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function isTextContainerDeclaration(prop) {
|
|
84
|
-
return TEXT_CONTAINER_PROPS.has(toKebab(prop));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function lintDeclaration(prop, rawValue) {
|
|
88
|
-
const property = toKebab(prop);
|
|
89
|
-
const value = String(rawValue ?? '').trim();
|
|
90
|
-
const normalized = value.toLowerCase();
|
|
91
|
-
const findings = [];
|
|
92
|
-
|
|
93
|
-
if (property.startsWith('--')) {
|
|
94
|
-
return findings;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const propertyMessage = FORBIDDEN_PROPERTIES.get(property);
|
|
98
|
-
if (propertyMessage) {
|
|
99
|
-
findings.push(issue(propertyMessage));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (property.startsWith('scroll-snap-')) {
|
|
103
|
-
findings.push(issue('Lynx 不支持 scroll-snap。横向轮播使用 Swiper。'));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (property === 'display' && /(?:^|\s)(?:inline|inline-block|table|contents|list-item)(?:\s|$)/i.test(normalized)) {
|
|
107
|
-
findings.push(issue('不要依赖 inline、table、contents 或 list-item 布局。使用 Flex / Grid。'));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (property === 'position' && /^(?:static|sticky)$/i.test(normalized)) {
|
|
111
|
-
findings.push(issue(`不要使用 position: ${normalized}。改用普通布局或 fixed / absolute。`));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
(property === 'overflow' || property === 'overflow-y') &&
|
|
116
|
-
/(?:^|\s)(?:auto|scroll)(?:\s|$)/i.test(normalized)
|
|
117
|
-
) {
|
|
118
|
-
findings.push(issue('固定 widget 不允许纵向滚动。减项、摘要化或切换局部状态。'));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (property === 'white-space' && /(?:pre|pre-wrap|pre-line|break-spaces)/i.test(normalized)) {
|
|
122
|
-
findings.push(issue('不要使用预格式 white-space。收敛文案并控制节点尺寸。'));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (property === 'flex' && /^\s*\d+(?:\.\d+)?\s*$/i.test(normalized)) {
|
|
126
|
-
findings.push(issue('不要写 flex: 1。使用完整三值 flex: 1 1 0,并显式设置 min-height / min-width。'));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (/\b(?:min|max|clamp)\s*\(/i.test(value)) {
|
|
130
|
-
findings.push(issue('Lynx 不支持 min()、max()、clamp()。用 flex、vw/vh 或明确尺寸。'));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (/(?:^|[^\w-])(?:cm|mm|in|pt|pc|ch|ex)(?:$|[^\w-])/i.test(value)) {
|
|
134
|
-
findings.push(issue('不要使用 cm/mm/in/pt/pc/ch/ex 单位。使用 px、rem、vw 或 vh。'));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return findings;
|
|
138
|
-
}
|