@open-dy/vibecoding-cli 0.1.3-beta.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -4
- package/release-provenance.json +5 -5
- package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +12 -254
- package/src/commands/lint.mjs +1 -1
- package/src/commands/pack.mjs +5 -2
- package/src/lint/lint-lynx-css.mjs +244 -0
- package/src/lint/lynx-css-rules.mjs +138 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ CLI 内置 Schema 与通用小程序模板支持可选 `BgMusicUrl`;填写时
|
|
|
36
36
|
|
|
37
37
|
两套模板的 `node scripts/check-routes.js` 与 `node scripts/validate-feed-card.js` 均保留为兼容入口,规则统一来自 CLI 的 CJS validator 导出;CLI 命令本身使用对应的 ESM 外层。程序化调用 `validateFeedCard(context, { touchTimestamp: false })` 可禁用时间戳更新。Ajv 由 CLI 自带;OpenCard Lynx CSS 检查复用 `@open-dy/vibecoding-sdk-opencard/eslint/lint-lynx-css`。
|
|
38
38
|
|
|
39
|
-
`pack --target taro` 默认先清理 `/tmp/microapp_artifacts`,由 CLI 编排 TT 生产构建、server 生产构建以及存在的封闭域服务和 OpenCard 构建,再生成归档。包含 `microapp.zip`、`server.zip`、`Dockerfile`、`run.sh
|
|
39
|
+
`pack --target taro` 默认先清理 `/tmp/microapp_artifacts`,由 CLI 编排 TT 生产构建、server 生产构建以及存在的封闭域服务和 OpenCard 构建,再生成归档。包含 `microapp.zip`、`server.zip`、`Dockerfile`、`run.sh`;仅当 `config/card-routing.json` 的 `cardData.serviceDomain` 为 `closed`,且 `closed-domain-server/package.json` 是文件时,追加 `closed-domain-server.zip`、`closed-domain-Dockerfile`、`closed-domain-run.sh`。`open`、`none` 或缺少路由配置时跳过封闭域产物;存在路由配置时复制 `card-routing.json`,存在 OpenCard 时生成 `opencard.zip`。两个 server 每次均构建,不受卡片域开关影响。旧项目若自行提供 `build:pack`,CLI 仍会传递 `ARTIFACTS_DIR` 兼容其产物;两套当前模板均不依赖该分支。
|
|
40
40
|
|
|
41
41
|
服务包仅包含 `dist`、生成的 `Dockerfile`、`package.json`、`run.sh` 以及可选的 `sql`、`scripts`。生成 Dockerfile 时按原脚本规则为每条 `COPY package.json` 追加存在的目录,项目源文件不改写。构建/zip 失败保留退出码,临时目录在失败时也会清理。
|
|
42
42
|
|
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.4",
|
|
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": "0.1.0
|
|
47
|
-
"@open-dy/vibecoding-sdk-opencard": "0.1.
|
|
46
|
+
"@open-dy/vibecoding-sdk-taro": "^0.1.0",
|
|
47
|
+
"@open-dy/vibecoding-sdk-opencard": "0.1.3"
|
|
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": "latest",
|
|
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.4",
|
|
6
6
|
"source": {
|
|
7
|
-
"digest": "sha256-
|
|
8
|
-
"files":
|
|
7
|
+
"digest": "sha256-e03f57170ea6127cad313ef3d94a0f50d515eee64741b0675ed4536132c285e6",
|
|
8
|
+
"files": 85
|
|
9
9
|
},
|
|
10
|
-
"payload": "sha256-
|
|
10
|
+
"payload": "sha256-64c484bf390b2145117810f76b8d81868ab4e969d931d401a1bc01e3d1b65cf5",
|
|
11
11
|
"toolchain": {
|
|
12
|
-
"node": "v22.
|
|
12
|
+
"node": "v22.22.3",
|
|
13
13
|
"packageManager": null
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -13,8 +13,6 @@ import {
|
|
|
13
13
|
lintAtRule,
|
|
14
14
|
lintDeclaration,
|
|
15
15
|
lintSelector,
|
|
16
|
-
normalizeValue,
|
|
17
|
-
toKebab,
|
|
18
16
|
} from './lynx-css-rules.mjs';
|
|
19
17
|
|
|
20
18
|
const __dirname = path.join(process.env.VIBECODING_OPENCARD_ROOT || process.cwd(), 'scripts');
|
|
@@ -419,32 +417,6 @@ function collectTextClassesRegex(source) {
|
|
|
419
417
|
return classes;
|
|
420
418
|
}
|
|
421
419
|
|
|
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
|
-
|
|
448
420
|
export function collectTextClassesFromTsx(source) {
|
|
449
421
|
const babel = loadBabel();
|
|
450
422
|
if (!babel) return collectTextClassesRegex(source);
|
|
@@ -473,39 +445,6 @@ export function collectTextClassesFromTsx(source) {
|
|
|
473
445
|
return classes;
|
|
474
446
|
}
|
|
475
447
|
|
|
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
|
-
|
|
509
448
|
function listFiles(dir, test, acc = []) {
|
|
510
449
|
if (!fs.existsSync(dir)) return acc;
|
|
511
450
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -516,73 +455,8 @@ function listFiles(dir, test, acc = []) {
|
|
|
516
455
|
return acc;
|
|
517
456
|
}
|
|
518
457
|
|
|
519
|
-
function
|
|
520
|
-
|
|
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;
|
|
458
|
+
function rel(file) {
|
|
459
|
+
return path.relative(ROOT, file).replace(/\\/g, '/');
|
|
586
460
|
}
|
|
587
461
|
|
|
588
462
|
function lintTextContainerUsage(rules, textClasses, filename, extraFindings) {
|
|
@@ -607,70 +481,32 @@ function lintTextContainerUsage(rules, textClasses, filename, extraFindings) {
|
|
|
607
481
|
}
|
|
608
482
|
}
|
|
609
483
|
|
|
610
|
-
function
|
|
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
|
-
) {
|
|
484
|
+
export function lintProject(srcDir = SRC) {
|
|
647
485
|
const cssFiles = listFiles(srcDir, (name) => name.endsWith('.css'));
|
|
648
486
|
const tsxFiles = listFiles(srcDir, (name) => /\.[jt]sx$/.test(name));
|
|
649
|
-
const
|
|
487
|
+
const textClasses = new Set();
|
|
488
|
+
for (const file of tsxFiles) {
|
|
489
|
+
for (const name of collectTextClassesFromTsx(fs.readFileSync(file, 'utf8'))) {
|
|
490
|
+
textClasses.add(name);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
650
493
|
|
|
651
494
|
const reports = [];
|
|
652
495
|
const allRules = [];
|
|
653
496
|
for (const file of cssFiles) {
|
|
654
|
-
const filename =
|
|
497
|
+
const filename = rel(file);
|
|
655
498
|
const { findings, rules } = lintCssText(fs.readFileSync(file, 'utf8'), { filename });
|
|
656
499
|
for (const item of findings) reports.push({ ...item, file: filename });
|
|
657
500
|
for (const rule of rules) allRules.push({ ...rule, file: filename });
|
|
658
501
|
}
|
|
659
502
|
for (const file of cssFiles) {
|
|
660
|
-
const filename =
|
|
503
|
+
const filename = rel(file);
|
|
661
504
|
const subset = allRules.filter((rule) => rule.file === filename);
|
|
662
|
-
const { textClasses, textParentClasses } = usage.get(file);
|
|
663
505
|
lintTextContainerUsage(subset, textClasses, filename, reports);
|
|
664
|
-
lintHiddenTextParentUsage(subset, textParentClasses, filename, reports);
|
|
665
506
|
}
|
|
666
507
|
return reports;
|
|
667
508
|
}
|
|
668
509
|
|
|
669
|
-
export function lintOpenCardProject(projectRoot) {
|
|
670
|
-
const root = path.resolve(projectRoot);
|
|
671
|
-
return lintProject(path.join(root, 'src'), { projectRoot: root });
|
|
672
|
-
}
|
|
673
|
-
|
|
674
510
|
function printReports(reports) {
|
|
675
511
|
for (const item of reports) {
|
|
676
512
|
console.error(formatFinding(item.file, item.line, item.column, item.message));
|
|
@@ -813,84 +649,6 @@ function assertSelfTest() {
|
|
|
813
649
|
failures.push({ css: textCss, expected: 'text-container', actual: extra.map((f) => f.id) });
|
|
814
650
|
}
|
|
815
651
|
|
|
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
|
-
|
|
894
652
|
if (failures.length) {
|
|
895
653
|
console.error(`[lynx-css self-test] ${failures.length} failed`);
|
|
896
654
|
for (const fail of failures) {
|
|
@@ -899,7 +657,7 @@ function assertSelfTest() {
|
|
|
899
657
|
}
|
|
900
658
|
process.exit(1);
|
|
901
659
|
}
|
|
902
|
-
console.log(`[lynx-css self-test] ${cases.length +
|
|
660
|
+
console.log(`[lynx-css self-test] ${cases.length + 1} passed`);
|
|
903
661
|
}
|
|
904
662
|
|
|
905
663
|
export function main() {
|
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 '../lint/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'
|
package/src/commands/pack.mjs
CHANGED
|
@@ -108,16 +108,19 @@ async function packServer(server, artifacts, closed = false) {
|
|
|
108
108
|
|
|
109
109
|
export async function packTaroArtifacts(context, artifacts) {
|
|
110
110
|
artifacts = resolveArtifactsDirectory(context.root, artifacts)
|
|
111
|
+
const serviceDomain = readCardRouting({ root: context.root, target: 'taro' })?.cardData?.serviceDomain
|
|
111
112
|
mkdirSync(artifacts, { recursive: true })
|
|
112
113
|
const dist = path.join(context.root, 'dist-tt')
|
|
113
114
|
console.log(`Packing ${dist} into ${artifacts}/microapp.zip`)
|
|
114
115
|
await zipDirectory(dist, path.join(artifacts, 'microapp.zip'))
|
|
115
116
|
await packServer(context.serverRoot ?? path.join(context.root, 'server'), artifacts)
|
|
116
117
|
const closedServer = path.join(context.root, 'closed-domain-server')
|
|
117
|
-
if (
|
|
118
|
+
if (serviceDomain === 'closed' &&
|
|
119
|
+
existsSync(path.join(closedServer, 'package.json')) &&
|
|
120
|
+
statSync(path.join(closedServer, 'package.json')).isFile()) {
|
|
118
121
|
await packServer(closedServer, artifacts, true)
|
|
119
122
|
} else {
|
|
120
|
-
console.log(
|
|
123
|
+
console.log(`Skipping closed-domain-server artifacts: requires cardData.serviceDomain=closed and a closed-domain-server/package.json file (serviceDomain=${serviceDomain ?? 'unset'})`)
|
|
121
124
|
}
|
|
122
125
|
const routing = path.join(context.root, 'config/card-routing.json')
|
|
123
126
|
if (existsSync(routing) && statSync(routing).isFile()) {
|
|
@@ -0,0 +1,244 @@
|
|
|
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();
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
}
|