@open-dy/vibecoding-cli 0.1.11 → 0.1.12-beta.c4e0af5db5e03e294f48552f203ad42582c612347.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/README.md +10 -0
- package/package.json +6 -5
- package/release-provenance.json +7 -7
- package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +10 -7
- package/src/adapters/interest-card/opencard/lint.sh +11 -2
- package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +9 -0
- package/src/build/opencard-environment.mjs +10 -4
- package/src/build/turbo-config.mjs +18 -2
- package/src/cli.mjs +1 -1
- package/src/commands/build.mjs +3 -0
- package/src/commands/lint.mjs +32 -10
- package/src/commands/validate.mjs +5 -2
- package/src/config/card-lint.mjs +61 -0
- package/src/config/card-profile.mjs +96 -0
- package/src/preview/watch-card-routing.mjs +7 -9
- package/src/validators/feed-card.cjs +35 -1
- package/src/validators/feed-card.mjs +15 -1
- package/src/validators/image-inline.mjs +82 -0
- package/src/validators/opencard-structure.mjs +1 -1
- package/src/validators/validate-card-background.py +216 -0
package/README.md
CHANGED
|
@@ -59,3 +59,13 @@ CLI 对外命令和校验入口的 JSDoc 使用 `@desc`、`@params`、`@return`
|
|
|
59
59
|
## 准出测试
|
|
60
60
|
|
|
61
61
|
在仓库根目录运行 `node release/verification/run-release-contracts.ts`,或在本包目录运行 `pnpm test:release`。该命令构建并验证 SDK、CLI 和隔离消费者;终端进度、JUnit 报告与 push CI 配置见 [packages/TESTING.md](../TESTING.md)。
|
|
62
|
+
|
|
63
|
+
## 卡片形态
|
|
64
|
+
|
|
65
|
+
`config/card-routing.json` 顶层的 `cardType` 支持 `interest` 和 `post`,不改变 `cardData.serviceDomain` 的含义。缺少文件或字段时沿用兴趣卡规范;显式空值、未知枚举和损坏的配置报错。解析器只读,普通续改不会补写作品标记。
|
|
66
|
+
|
|
67
|
+
工程两端的 `eslint.config.mjs` 保持现有 SDK 配置工厂调用。CLI 从宿主目录读取配置,通过 ESLint 子进程环境 `VIBECODING_CARD_TYPE` 传递形态;直接执行 ESLint 时,SDK 根据待检查文件定位所属工程,OpenCard 使用宿主配置。解析器在 SDK 构建时内置,和 CLI 使用同一份源码,不依赖命令 cwd。CLI 在作品卡检查前要求配套 SDK 导出 `CARD_TYPE_RULES_VERSION = 1`;历史项目按 Skill 的 `upgrade-to-post.md` 接入,不能只安装依赖而保留未接通的旧入口。
|
|
68
|
+
|
|
69
|
+
通用和兴趣卡专用 lint 均记录成功检查时的路由配置摘要。配置新增、修改或删除后,下一次检查强制全量执行;失败不会更新摘要。作品卡另用独立 ESLint 缓存标识,防止复用兴趣卡结果。两端沿用原命令和产物结构。
|
|
70
|
+
|
|
71
|
+
OpenCard 构建同样通过 `VIBECODING_CARD_TYPE` 传递形态,SDK 和编译器在作品卡 `app.json` 中输出 `meta.cardType: "post"`,兴趣卡保持旧输出。开发预览监听形态与服务域变化并自动重启;非法或临时缺失配置保留当前会话。更新工具链依赖后需先重启开发入口,独立 `dev:tt` 的配置变更也需重启。正式 build/pack 每次重新读取配置,Turbo 缓存同时包含形态环境变量和宿主路由文件。
|
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.12-beta.c4e0af5db5e03e294f48552f203ad42582c612347.0",
|
|
4
4
|
"description": "Command orchestration, validation and packaging for Douyin vibecoding Taro and OpenCard projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"import": "./src/validators/feed-card.mjs",
|
|
19
19
|
"require": "./src/validators/feed-card.cjs"
|
|
20
20
|
},
|
|
21
|
-
"./adapters/interest-card/opencard/*": "./src/adapters/interest-card/opencard/*"
|
|
21
|
+
"./adapters/interest-card/opencard/*": "./src/adapters/interest-card/opencard/*",
|
|
22
|
+
"./config/card-profile": "./src/config/card-profile.mjs"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"bin",
|
|
@@ -42,8 +43,8 @@
|
|
|
42
43
|
"ajv": "^8.18.0"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
45
|
-
"@open-dy/vibecoding-sdk-taro": "0.1.
|
|
46
|
-
"@open-dy/vibecoding-sdk-opencard": "0.1.
|
|
46
|
+
"@open-dy/vibecoding-sdk-taro": "0.1.8-beta.c4e0af5db5e03e294f48552f203ad42582c612347.0",
|
|
47
|
+
"@open-dy/vibecoding-sdk-opencard": "0.1.9-beta.c4e0af5db5e03e294f48552f203ad42582c612347.0"
|
|
47
48
|
},
|
|
48
49
|
"peerDependenciesMeta": {
|
|
49
50
|
"@open-dy/vibecoding-sdk-taro": {
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"license": "UNLICENSED",
|
|
60
61
|
"publishConfig": {
|
|
61
62
|
"registry": "https://registry.npmjs.org/",
|
|
62
|
-
"tag": "
|
|
63
|
+
"tag": "beta-c4e0af5db5e03e294f48552f203ad42582c612347",
|
|
63
64
|
"access": "public"
|
|
64
65
|
}
|
|
65
66
|
}
|
package/release-provenance.json
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"producer": "vibecoding-cli",
|
|
4
4
|
"packageName": "@open-dy/vibecoding-cli",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.12-beta.c4e0af5db5e03e294f48552f203ad42582c612347.0",
|
|
6
6
|
"source": {
|
|
7
|
-
"digest": "sha256-
|
|
8
|
-
"files":
|
|
7
|
+
"digest": "sha256-34fcbffdaf45a69167b0389ffb22452a2a3e7b4a91aa7a33795ce592582ed11b",
|
|
8
|
+
"files": 96
|
|
9
9
|
},
|
|
10
|
-
"payload": "sha256-
|
|
10
|
+
"payload": "sha256-f0b684e0efcaaad251fae46f54ec60f5b2f0fe04a78f726bdb842caeada52831",
|
|
11
11
|
"toolchain": {
|
|
12
|
-
"node": "v22.22.
|
|
12
|
+
"node": "v22.22.0",
|
|
13
13
|
"runtime": {
|
|
14
|
-
"node": "v22.22.
|
|
14
|
+
"node": "v22.22.0",
|
|
15
15
|
"platform": "linux",
|
|
16
16
|
"arch": "x64",
|
|
17
|
-
"environment": "sha256-
|
|
17
|
+
"environment": "sha256-5b81e7cc338f03f0ff65566f99f994cca05b90cc836ff863506eed6afe9f5a24"
|
|
18
18
|
},
|
|
19
19
|
"packageManager": null
|
|
20
20
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 规则见 lynx-css-rules.mjs;inline style 由 eslint-plugin-opencard-taro 复用同一套规则。
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
+
import { readCardProfile } from '../../../config/card-profile.mjs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { createRequire } from 'node:module';
|
|
9
10
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
isTextContainerDeclaration,
|
|
13
14
|
lintAtRule,
|
|
14
15
|
lintDeclaration,
|
|
16
|
+
lintPostCardDeclaration,
|
|
15
17
|
lintSelector,
|
|
16
18
|
normalizeValue,
|
|
17
19
|
toKebab,
|
|
@@ -309,7 +311,7 @@ function classesInCompound(compound) {
|
|
|
309
311
|
return names;
|
|
310
312
|
}
|
|
311
313
|
|
|
312
|
-
export function lintCssText(source, { filename = 'input.css' } = {}) {
|
|
314
|
+
export function lintCssText(source, { filename = 'input.css', cardType = 'interest' } = {}) {
|
|
313
315
|
const allowRoot = isAppCss(filename);
|
|
314
316
|
const findings = scanComments(source).errors.map(({ index, message }) => ({
|
|
315
317
|
id: "css-comment", severity: "error", ...lineAt(source, index), message,
|
|
@@ -340,7 +342,7 @@ export function lintCssText(source, { filename = 'input.css' } = {}) {
|
|
|
340
342
|
}
|
|
341
343
|
if (node.type === 'decl') {
|
|
342
344
|
if (currentRule) currentRule.decls.push(node);
|
|
343
|
-
for (const item of lintDeclaration(node.prop, node.value)) {
|
|
345
|
+
for (const item of [...lintDeclaration(node.prop, node.value), ...(cardType === 'post' ? lintPostCardDeclaration(node.prop, node.value) : [])]) {
|
|
344
346
|
findings.push({
|
|
345
347
|
...item,
|
|
346
348
|
line: node.line,
|
|
@@ -642,7 +644,7 @@ function lintHiddenTextParentUsage(rules, textParentClasses, filename, extraFind
|
|
|
642
644
|
|
|
643
645
|
export function lintProject(
|
|
644
646
|
srcDir = SRC,
|
|
645
|
-
{ projectRoot = path.resolve(srcDir, '..') } = {},
|
|
647
|
+
{ projectRoot = path.resolve(srcDir, '..'), cardType = 'interest' } = {},
|
|
646
648
|
) {
|
|
647
649
|
const cssFiles = listFiles(srcDir, (name) => name.endsWith('.css'));
|
|
648
650
|
const tsxFiles = listFiles(srcDir, (name) => /\.[jt]sx$/.test(name));
|
|
@@ -652,7 +654,7 @@ export function lintProject(
|
|
|
652
654
|
const allRules = [];
|
|
653
655
|
for (const file of cssFiles) {
|
|
654
656
|
const filename = path.relative(projectRoot, file).replace(/\\/g, '/');
|
|
655
|
-
const { findings, rules } = lintCssText(fs.readFileSync(file, 'utf8'), { filename });
|
|
657
|
+
const { findings, rules } = lintCssText(fs.readFileSync(file, 'utf8'), { filename, cardType });
|
|
656
658
|
for (const item of findings) reports.push({ ...item, file: filename });
|
|
657
659
|
for (const rule of rules) allRules.push({ ...rule, file: filename });
|
|
658
660
|
}
|
|
@@ -666,9 +668,9 @@ export function lintProject(
|
|
|
666
668
|
return reports;
|
|
667
669
|
}
|
|
668
670
|
|
|
669
|
-
export function lintOpenCardProject(projectRoot) {
|
|
671
|
+
export function lintOpenCardProject(projectRoot, { cardType = 'interest' } = {}) {
|
|
670
672
|
const root = path.resolve(projectRoot);
|
|
671
|
-
return lintProject(path.join(root, 'src'), { projectRoot: root });
|
|
673
|
+
return lintProject(path.join(root, 'src'), { projectRoot: root, cardType });
|
|
672
674
|
}
|
|
673
675
|
|
|
674
676
|
function printReports(reports) {
|
|
@@ -907,7 +909,8 @@ export function main() {
|
|
|
907
909
|
assertSelfTest();
|
|
908
910
|
return;
|
|
909
911
|
}
|
|
910
|
-
const
|
|
912
|
+
const { cardType } = readCardProfile({ projectRoot: path.dirname(ROOT) });
|
|
913
|
+
const reports = lintProject(SRC, { cardType });
|
|
911
914
|
if (reports.length === 0) return;
|
|
912
915
|
console.error(`ERROR: Lynx CSS lint 失败(${reports.length}):机械禁写见 rendering.md,不要手工 grep。`);
|
|
913
916
|
printReports(reports);
|
|
@@ -7,6 +7,14 @@ ROOT="${1:?Missing project root}"
|
|
|
7
7
|
shift
|
|
8
8
|
cd "$ROOT"
|
|
9
9
|
ADAPTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
|
+
PROFILE_ENTRY="$ADAPTER_DIR/../../../config/card-lint.mjs"
|
|
11
|
+
PROFILE="$(node "$PROFILE_ENTRY" prepare "$(dirname "$ROOT")" "$ROOT")"
|
|
12
|
+
read -r CARD_TYPE FORCE_FULL PROFILE_SIGNATURE <<< "$PROFILE"
|
|
13
|
+
export VIBECODING_CARD_TYPE="$CARD_TYPE"
|
|
14
|
+
echo "[opencard lint] cardType=${CARD_TYPE}; config=$(dirname "$ROOT")/config/card-routing.json"
|
|
15
|
+
[[ "$FORCE_FULL" != "1" ]] || LINT_INCREMENTAL=0
|
|
16
|
+
ESLINT_CACHE="node_modules/.cache/eslint/opencard-src"
|
|
17
|
+
[[ "$CARD_TYPE" != "post" ]] || ESLINT_CACHE="${ESLINT_CACHE}-post-v1"
|
|
10
18
|
|
|
11
19
|
resolve_git_base() {
|
|
12
20
|
local top
|
|
@@ -97,9 +105,9 @@ launch tsc tsc --noEmit
|
|
|
97
105
|
|
|
98
106
|
if [[ "${RUN_ESLINT}" -eq 1 ]]; then
|
|
99
107
|
if [[ "${#ESLINT_FILES[@]}" -gt 0 ]]; then
|
|
100
|
-
launch eslint eslint --cache --cache-location
|
|
108
|
+
launch eslint eslint --cache --cache-location "$ESLINT_CACHE" "${ESLINT_FILES[@]}"
|
|
101
109
|
else
|
|
102
|
-
launch eslint eslint --cache --cache-location
|
|
110
|
+
launch eslint eslint --cache --cache-location "$ESLINT_CACHE" 'src/**/*.{js,jsx,ts,tsx}'
|
|
103
111
|
fi
|
|
104
112
|
fi
|
|
105
113
|
|
|
@@ -129,3 +137,4 @@ if [[ "${FAILED}" -ne 0 ]]; then
|
|
|
129
137
|
echo "[opencard lint] ERROR: lint 失败,需要修改后重新检测,直到通过为止" >&2
|
|
130
138
|
exit 1
|
|
131
139
|
fi
|
|
140
|
+
node "$PROFILE_ENTRY" complete "$(dirname "$ROOT")" "$ROOT" "$PROFILE_SIGNATURE"
|
|
@@ -506,3 +506,12 @@ export function formatFinding(file, line, column, message) {
|
|
|
506
506
|
const loc = column ? `${line}:${column}` : String(line);
|
|
507
507
|
return `${file}:${loc}: ${message}`;
|
|
508
508
|
}
|
|
509
|
+
|
|
510
|
+
/** Restrict scrolling only for post cards; legacy rules remain unchanged. */
|
|
511
|
+
export function lintPostCardDeclaration(property, value) {
|
|
512
|
+
const name = toKebab(property);
|
|
513
|
+
if (/^overflow(?:-[xy])?$/.test(name) && String(value).replace(/\s*!important\s*$/i, '').trim().split(/\s+/).some(token => /^(auto|scroll)$/i.test(token))) {
|
|
514
|
+
return [{ id: 'post-card-scroll', severity: 'error', message: '作品卡不支持滚动交互。请通过点击切换内容,不使用 overflow: auto / scroll。' }];
|
|
515
|
+
}
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import { parseCardType } from '../config/card-profile.mjs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
|
|
4
5
|
/** Locate host routing for standalone preview callers without a resolved CLI context. */
|
|
@@ -23,13 +24,13 @@ export function resolveCardRoutingPath(context) {
|
|
|
23
24
|
: findRoutingPath(context.root)
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function readCardRouting(context) {
|
|
27
|
+
export function readCardRouting(context, { allowMissing = true } = {}) {
|
|
27
28
|
const file = resolveCardRoutingPath(context)
|
|
28
29
|
let raw
|
|
29
30
|
try {
|
|
30
31
|
raw = fs.readFileSync(file, 'utf8')
|
|
31
32
|
} catch (error) {
|
|
32
|
-
if (error.code === 'ENOENT') return {}
|
|
33
|
+
if (error.code === 'ENOENT' && allowMissing) return {}
|
|
33
34
|
throw error
|
|
34
35
|
}
|
|
35
36
|
let routing
|
|
@@ -47,10 +48,15 @@ export function readCardRouting(context) {
|
|
|
47
48
|
if (!['closed', 'open', 'none'].includes(serviceDomain)) {
|
|
48
49
|
throw new Error(`${file} cardData.serviceDomain must be none, open, or closed; got ${JSON.stringify(serviceDomain)}。\n${guidance}`)
|
|
49
50
|
}
|
|
51
|
+
parseCardType(routing, file)
|
|
50
52
|
return routing
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Pass to the compiler child only; never mutate the CLI process environment. */
|
|
54
|
-
export function openCardBuildEnvironment(context) {
|
|
55
|
-
|
|
56
|
+
export function openCardBuildEnvironment(context, options) {
|
|
57
|
+
const routing = readCardRouting(context, options)
|
|
58
|
+
return {
|
|
59
|
+
IS_CARD_CLOSED_DOMAIN: String(routing?.cardData?.serviceDomain === 'closed'),
|
|
60
|
+
VIBECODING_CARD_TYPE: parseCardType(routing, resolveCardRoutingPath(context)),
|
|
61
|
+
}
|
|
56
62
|
}
|
|
@@ -7,10 +7,26 @@ const ARRAY_FIELDS = new Set(['env', 'inputs', 'outputs', 'dependsOn'])
|
|
|
7
7
|
// These settings are part of the CLI contract. They are kept here, rather
|
|
8
8
|
// than in generated projects, so all projects receive the same cache key and
|
|
9
9
|
// closed-domain environment behavior as the CLI is upgraded.
|
|
10
|
+
const PROJECT_BUILD_ENV = [
|
|
11
|
+
'APP_ID',
|
|
12
|
+
'OPEN_CARD_ID',
|
|
13
|
+
'DOUYIN_CLOUD_ENV_ID',
|
|
14
|
+
'DOUYIN_CLOUD_DEV_ENV_ID',
|
|
15
|
+
'DOUYIN_CLOUD_SERVICE_ID',
|
|
16
|
+
'AUDIT_DOUYIN_CLOUD_ENV_ID',
|
|
17
|
+
'AUDIT_DOUYIN_CLOUD_SERVICE_ID',
|
|
18
|
+
'DOUYIN_CLOUD_CLOSED_DOMAIN_SERVICE_ID',
|
|
19
|
+
]
|
|
20
|
+
|
|
10
21
|
const OPENCARD_DEFAULTS = {
|
|
11
22
|
tasks: {
|
|
12
23
|
'build:raw': {
|
|
13
|
-
env: [
|
|
24
|
+
env: [
|
|
25
|
+
'IS_CARD_CLOSED_DOMAIN',
|
|
26
|
+
'VIBECODING_CARD_TYPE',
|
|
27
|
+
...PROJECT_BUILD_ENV,
|
|
28
|
+
'AUDIT_DOUYIN_CLOUD_CLOSED_DOMAIN_SERVICE_ID',
|
|
29
|
+
],
|
|
14
30
|
inputs: ['../config/card-routing.json'],
|
|
15
31
|
},
|
|
16
32
|
},
|
|
@@ -19,7 +35,7 @@ const OPENCARD_DEFAULTS = {
|
|
|
19
35
|
const TARO_DEFAULTS = {
|
|
20
36
|
tasks: {
|
|
21
37
|
'//#build:tt:raw': {
|
|
22
|
-
env: ['IS_CARD_CLOSED_DOMAIN'],
|
|
38
|
+
env: ['IS_CARD_CLOSED_DOMAIN', 'VIBECODING_CARD_TYPE', ...PROJECT_BUILD_ENV],
|
|
23
39
|
inputs: ['config/card-routing.json'],
|
|
24
40
|
},
|
|
25
41
|
},
|
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ Commands:
|
|
|
20
20
|
build Build the selected target
|
|
21
21
|
lint Type-check, lint and validate source structure
|
|
22
22
|
verify Run static and/or runtime verification
|
|
23
|
-
validate <name> Run feed-card, routes, empty or opencard-structure
|
|
23
|
+
validate <name> Run feed-card, feed-background, routes, empty or opencard-structure
|
|
24
24
|
init-mysql-db Initialize the Taro server database
|
|
25
25
|
pack Build deployment archives
|
|
26
26
|
docs Locate the selected project's installed SDK reference
|
package/src/commands/build.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readEnvFile } from '../core/environment.mjs'
|
|
|
5
5
|
import { resolveProjectBinary, resolveSdkTaroEntry, runProcess, runTasks } from '../core/runner.mjs'
|
|
6
6
|
import { writeWebConfig } from '../core/web-config.mjs'
|
|
7
7
|
import { validateRoutes } from '../validators/routes.mjs'
|
|
8
|
+
import { validateImageInline } from '../validators/image-inline.mjs'
|
|
8
9
|
|
|
9
10
|
function taroEnvironment(root, extra = {}) {
|
|
10
11
|
const home = path.join(root, '.home')
|
|
@@ -101,6 +102,7 @@ export async function runBuild(context, options = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
if (context.target === 'opencard') {
|
|
103
104
|
await buildOpenCard(context, buildOptions)
|
|
105
|
+
if (!options.watch) validateImageInline(context)
|
|
104
106
|
return
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -138,4 +140,5 @@ export async function runBuild(context, options = {}) {
|
|
|
138
140
|
}
|
|
139
141
|
await runTasks(tasks, { label: 'build' })
|
|
140
142
|
validateRoutes(context)
|
|
143
|
+
if (!options.watch) validateImageInline(context)
|
|
141
144
|
}
|
package/src/commands/lint.mjs
CHANGED
|
@@ -7,12 +7,26 @@ import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
|
|
|
7
7
|
import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
|
|
8
8
|
import { validateSqlQueries } from '../validators/sql.mjs'
|
|
9
9
|
import { validateWidgetShell } from '../validators/widget-shell.mjs'
|
|
10
|
+
import { prepareCardLint, ensureCardLintSupport } from '../config/card-lint.mjs'
|
|
10
11
|
|
|
11
12
|
const globalConfigs = [
|
|
12
13
|
'eslint.config.mjs', 'tsconfig.json', 'package.json', 'pnpm-lock.yaml',
|
|
13
14
|
'opencard/eslint.config.mjs', 'opencard/tsconfig.json', 'opencard/package.json',
|
|
15
|
+
'config/card-routing.json',
|
|
14
16
|
]
|
|
15
17
|
|
|
18
|
+
async function lintProfile(projectRoot, lintRoot) {
|
|
19
|
+
// A programmatic legacy caller may not supply a complete project.
|
|
20
|
+
if (!existsSync(path.join(projectRoot, 'package.json')) &&
|
|
21
|
+
!existsSync(path.join(projectRoot, 'config/card-routing.json'))) {
|
|
22
|
+
return { cardType: 'interest', full: false, complete() {} }
|
|
23
|
+
}
|
|
24
|
+
const profile = prepareCardLint({ projectRoot, lintRoot })
|
|
25
|
+
await ensureCardLintSupport({ projectRoot, lintRoot, cardType: profile.cardType })
|
|
26
|
+
console.log(`[lint] cardType=${profile.cardType}; config=${profile.configPath}`)
|
|
27
|
+
return profile
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export async function changedFiles(root, patterns, capture = runCaptured) {
|
|
17
31
|
try {
|
|
18
32
|
const baseCandidates = ['origin/main', 'origin/master']
|
|
@@ -71,8 +85,8 @@ export function selectOpenCardEslintFiles(changed) {
|
|
|
71
85
|
return { runEslint: files.length > 0, files }
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
export function runLynxCss(root, lintProject = lintOpenCardProject) {
|
|
75
|
-
const findings = lintProject(root)
|
|
88
|
+
export function runLynxCss(root, lintProject = lintOpenCardProject, options = {}) {
|
|
89
|
+
const findings = lintProject(root, options)
|
|
76
90
|
if (findings.length === 0) return
|
|
77
91
|
const details = findings.map((finding) =>
|
|
78
92
|
`${finding.file}:${finding.line}:${finding.column} ${finding.message}`,
|
|
@@ -85,16 +99,18 @@ export function runLynxCss(root, lintProject = lintOpenCardProject) {
|
|
|
85
99
|
export async function lintOpenCard(context, options = {}) {
|
|
86
100
|
const root = context.target === 'opencard' ? context.root : context.opencardRoot
|
|
87
101
|
if (!root) return
|
|
102
|
+
const profile = await lintProfile(context.taroRoot ?? path.dirname(root), root)
|
|
88
103
|
const projectRoot = context.taroRoot && existsSync(path.join(context.taroRoot, 'node_modules/.bin/tsc'))
|
|
89
104
|
? context.taroRoot
|
|
90
105
|
: root
|
|
91
106
|
const tsc = resolveProjectBinary(projectRoot, 'tsc')
|
|
92
107
|
const eslint = resolveProjectBinary(projectRoot, 'eslint')
|
|
93
108
|
mkdirSync(path.join(root, 'node_modules/.cache/eslint'), { recursive: true })
|
|
94
|
-
const eslintArgs = ['--cache', '--cache-location',
|
|
109
|
+
const eslintArgs = ['--cache', '--cache-location', `node_modules/.cache/eslint/opencard-src${profile.cardType === 'post' ? '-post-v1' : ''}`]
|
|
95
110
|
let changed = []
|
|
96
|
-
if (options.incremental) {
|
|
97
|
-
changed = await changedFiles(context.taroRoot ?? root, ['opencard/src/**'])
|
|
111
|
+
if (options.incremental && !profile.full) {
|
|
112
|
+
changed = await changedFiles(context.taroRoot ?? root, ['opencard/src/**', ...globalConfigs])
|
|
113
|
+
if (changed.some(file => globalConfigs.includes(file))) changed = []
|
|
98
114
|
if (changed.length > 0) {
|
|
99
115
|
console.log(`[opencard lint] incremental mode (${changed.length} files)`)
|
|
100
116
|
}
|
|
@@ -105,29 +121,32 @@ export async function lintOpenCard(context, options = {}) {
|
|
|
105
121
|
const tasks = [
|
|
106
122
|
{ name: 'opencard-structure', run: async () => validateOpenCardStructure(validationContext) },
|
|
107
123
|
{ name: 'opencard-widget-shell', run: async () => validateWidgetShell(validationContext) },
|
|
108
|
-
{ name: 'opencard-lynx-css', run: () => runLynxCss(root, options.lintProject) },
|
|
124
|
+
{ name: 'opencard-lynx-css', run: () => runLynxCss(root, options.lintProject, { cardType: profile.cardType }) },
|
|
109
125
|
{ name: 'opencard-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: root }) },
|
|
110
126
|
]
|
|
111
127
|
if (eslintSelection.runEslint) {
|
|
112
128
|
tasks.push({
|
|
113
129
|
name: 'opencard-eslint',
|
|
114
|
-
run: () => runProcess(eslint, eslintArgs, { cwd: root }),
|
|
130
|
+
run: () => runProcess(eslint, eslintArgs, { cwd: root, env: { VIBECODING_CARD_TYPE: profile.cardType } }),
|
|
115
131
|
})
|
|
116
132
|
}
|
|
117
133
|
await runTasks(tasks, { label: 'opencard lint' })
|
|
134
|
+
profile.complete()
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
export async function runLint(context, options = {}) {
|
|
121
|
-
|
|
138
|
+
let incremental = options.incremental || process.env.LINT_INCREMENTAL === '1'
|
|
122
139
|
if (context.target === 'opencard') {
|
|
123
140
|
await lintOpenCard(context, { incremental })
|
|
124
141
|
return
|
|
125
142
|
}
|
|
126
143
|
|
|
144
|
+
const profile = await lintProfile(context.root, context.root)
|
|
145
|
+
if (profile.full) incremental = false
|
|
127
146
|
validateNonEmptyTaro(context)
|
|
128
147
|
const tsc = resolveProjectBinary(context.root, 'tsc')
|
|
129
148
|
const eslint = resolveProjectBinary(context.root, 'eslint')
|
|
130
|
-
const eslintArgs = ['--cache', '--cache-location',
|
|
149
|
+
const eslintArgs = ['--cache', '--cache-location', `node_modules/.cache/eslint/root-src${profile.cardType === 'post' ? '-post-v1' : ''}`]
|
|
131
150
|
const changed = incremental ? await incrementalLintFiles(context.root) : []
|
|
132
151
|
if (incremental) {
|
|
133
152
|
const files = changed.filter((file) => file.startsWith('src/'))
|
|
@@ -143,7 +162,9 @@ export async function runLint(context, options = {}) {
|
|
|
143
162
|
if (full || changed.some((file) => file.startsWith('src/'))) {
|
|
144
163
|
tasks.push(
|
|
145
164
|
{ name: 'taro-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.root }) },
|
|
146
|
-
{ name: 'taro-eslint', run: () => runProcess(eslint, eslintArgs, {
|
|
165
|
+
{ name: 'taro-eslint', run: () => runProcess(eslint, eslintArgs, {
|
|
166
|
+
cwd: context.root, env: { VIBECODING_CARD_TYPE: profile.cardType },
|
|
167
|
+
}) },
|
|
147
168
|
)
|
|
148
169
|
}
|
|
149
170
|
if (full || serverChanged || closedDomainChanged) {
|
|
@@ -173,4 +194,5 @@ export async function runLint(context, options = {}) {
|
|
|
173
194
|
tasks.push({ name: 'opencard-lint', run: () => lintOpenCard(context, { incremental: !full }) })
|
|
174
195
|
}
|
|
175
196
|
await runTasks(tasks, { label: 'lint' })
|
|
197
|
+
profile.complete()
|
|
176
198
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UsageError } from '../core/errors.mjs'
|
|
2
|
-
import { validateFeedCard } from '../validators/feed-card.mjs'
|
|
2
|
+
import { validateFeedCard, validateProjectCardBackground } from '../validators/feed-card.mjs'
|
|
3
3
|
import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
|
|
4
4
|
import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
|
|
5
5
|
import { validateRoutes } from '../validators/routes.mjs'
|
|
@@ -9,6 +9,9 @@ export async function runValidate(context, validator) {
|
|
|
9
9
|
case 'feed-card':
|
|
10
10
|
validateFeedCard(context)
|
|
11
11
|
return
|
|
12
|
+
case 'feed-background':
|
|
13
|
+
validateProjectCardBackground(context)
|
|
14
|
+
return
|
|
12
15
|
case 'routes':
|
|
13
16
|
validateRoutes(context)
|
|
14
17
|
return
|
|
@@ -20,7 +23,7 @@ export async function runValidate(context, validator) {
|
|
|
20
23
|
return
|
|
21
24
|
default:
|
|
22
25
|
throw new UsageError(
|
|
23
|
-
`Unknown validator: ${validator || '<missing>'}\nAvailable validators: feed-card, routes, empty, opencard-structure`,
|
|
26
|
+
`Unknown validator: ${validator || '<missing>'}\nAvailable validators: feed-card, feed-background, routes, empty, opencard-structure`,
|
|
24
27
|
)
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { readCardProfile } from './card-profile.mjs'
|
|
7
|
+
|
|
8
|
+
function stateFile(lintRoot) {
|
|
9
|
+
return path.join(lintRoot, 'node_modules/.cache/eslint/card-profile.json')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function commit(lintRoot, signature) {
|
|
13
|
+
const file = stateFile(lintRoot)
|
|
14
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
15
|
+
writeFileSync(file, JSON.stringify({ signature }) + '\n')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Only a successful lint records the configuration it actually checked. This
|
|
19
|
+
// also catches untracked/deleted routing files and switches outside a Git diff.
|
|
20
|
+
export function prepareCardLint({ projectRoot, lintRoot }) {
|
|
21
|
+
const profile = readCardProfile({ projectRoot })
|
|
22
|
+
let raw = 'missing'
|
|
23
|
+
try { raw = readFileSync(profile.configPath, 'utf8') } catch (error) {
|
|
24
|
+
if (error.code !== 'ENOENT') throw error
|
|
25
|
+
}
|
|
26
|
+
const signature = createHash('sha256').update(`card-rules-v1\n${raw}`).digest('hex')
|
|
27
|
+
let previous
|
|
28
|
+
try { previous = JSON.parse(readFileSync(stateFile(lintRoot), 'utf8')).signature } catch {}
|
|
29
|
+
return { ...profile, signature, full: previous !== signature, complete: () => commit(lintRoot, signature) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fail an explicit upgrade if its installed SDK cannot enforce post rules. */
|
|
33
|
+
export async function ensureCardLintSupport({ projectRoot, lintRoot, cardType }) {
|
|
34
|
+
if (cardType !== 'post') return
|
|
35
|
+
const entry = projectRoot === lintRoot
|
|
36
|
+
? '@open-dy/vibecoding-sdk-taro/eslint/taro'
|
|
37
|
+
: '@open-dy/vibecoding-sdk-opencard/eslint/opencard'
|
|
38
|
+
try {
|
|
39
|
+
const require = createRequire(path.join(lintRoot, 'package.json'))
|
|
40
|
+
const sdk = await import(pathToFileURL(require.resolve(entry)).href)
|
|
41
|
+
if (sdk.CARD_TYPE_RULES_VERSION !== 1) throw new Error('CARD_TYPE_RULES_VERSION must be 1')
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`作品卡 lint 需要升级配套 SDK:${entry}。${error.message}`, { cause: error })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
48
|
+
try {
|
|
49
|
+
const [action, projectRoot, lintRoot, signature] = process.argv.slice(2)
|
|
50
|
+
if (action === 'prepare') {
|
|
51
|
+
const state = prepareCardLint({ projectRoot, lintRoot })
|
|
52
|
+
await ensureCardLintSupport({ projectRoot, lintRoot, cardType: state.cardType })
|
|
53
|
+
console.log(`${state.cardType} ${state.full ? '1' : '0'} ${state.signature}`)
|
|
54
|
+
} else if (action === 'complete' && /^[a-f0-9]{64}$/.test(signature ?? '')) {
|
|
55
|
+
commit(lintRoot, signature)
|
|
56
|
+
} else throw new Error('Expected prepare or complete with project and lint roots')
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(error.message)
|
|
59
|
+
process.exitCode = 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @desc 校验卡片形态;缺少字段保留历史兴趣卡语义,不写回配置。
|
|
7
|
+
* @params {object} config 已解析的工程配置。
|
|
8
|
+
* @params {string} [source] 错误信息中的配置来源。
|
|
9
|
+
* @return {'interest'|'post'} 卡片形态。
|
|
10
|
+
* @example parseCardType({ cardType: 'post' })
|
|
11
|
+
*/
|
|
12
|
+
export function parseCardType(config, source = 'card-routing.json') {
|
|
13
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
14
|
+
throw new Error(`${source} must contain an object`)
|
|
15
|
+
}
|
|
16
|
+
if (!Object.hasOwn(config, 'cardType')) return 'interest'
|
|
17
|
+
if (config.cardType !== 'interest' && config.cardType !== 'post') {
|
|
18
|
+
throw new Error(`${source}: cardType must be interest or post; got ${JSON.stringify(config.cardType)}`)
|
|
19
|
+
}
|
|
20
|
+
return config.cardType
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @desc 从明确的宿主工程目录读取形态,供 CLI 与两端 SDK 共用。
|
|
25
|
+
* @params {{projectRoot: string}} options 宿主工程绝对路径;OpenCard 配置也传宿主目录。
|
|
26
|
+
* @return {{cardType: 'interest'|'post', configPath: string}} 形态及配置来源。
|
|
27
|
+
* @example readCardProfile({ projectRoot: '/workspace/project/card-app' })
|
|
28
|
+
*/
|
|
29
|
+
export function readCardProfile({ projectRoot }) {
|
|
30
|
+
if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
|
|
31
|
+
throw new Error('readCardProfile requires an absolute projectRoot')
|
|
32
|
+
}
|
|
33
|
+
const configPath = path.join(projectRoot, 'config/card-routing.json')
|
|
34
|
+
let raw
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(configPath, 'utf8')
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT') return { cardType: 'interest', configPath }
|
|
39
|
+
throw error
|
|
40
|
+
}
|
|
41
|
+
let config
|
|
42
|
+
try {
|
|
43
|
+
config = JSON.parse(raw)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`${configPath} must contain valid JSON: ${error.message}`)
|
|
46
|
+
}
|
|
47
|
+
return { cardType: parseCardType(config, configPath), configPath }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findEslintHost(filename, target) {
|
|
51
|
+
let directory = path.dirname(filename)
|
|
52
|
+
while (true) {
|
|
53
|
+
// Stop at the owning package/config instead of inheriting another project's
|
|
54
|
+
// routing. An OpenCard package belongs to its immediate host directory.
|
|
55
|
+
if (existsSync(path.join(directory, 'package.json')) ||
|
|
56
|
+
existsSync(path.join(directory, 'eslint.config.mjs')) ||
|
|
57
|
+
existsSync(path.join(directory, 'project.card.json'))) {
|
|
58
|
+
return target === 'opencard' ? path.dirname(directory) : directory
|
|
59
|
+
}
|
|
60
|
+
if (existsSync(path.join(directory, '.git'))) return undefined
|
|
61
|
+
const parent = path.dirname(directory)
|
|
62
|
+
if (parent === directory) return undefined
|
|
63
|
+
directory = parent
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @desc 创建作品卡文件匹配器;CLI 传入形态,直接 ESLint 从待检查文件定位工程。
|
|
69
|
+
* @params {{cardType?: 'interest'|'post', target: 'taro'|'opencard'}} options SDK 配置及目标端。
|
|
70
|
+
* @params {object} [environment] CLI 子进程环境。
|
|
71
|
+
* @return {(filename: string) => boolean} 是否启用作品卡配置。
|
|
72
|
+
* @example createPostCardMatcher({ target: 'taro' })('/workspace/app/src/index.ts')
|
|
73
|
+
*/
|
|
74
|
+
export function createPostCardMatcher(options, environment = process.env) {
|
|
75
|
+
const configured = options.cardType ?? environment.VIBECODING_CARD_TYPE
|
|
76
|
+
// An explicitly supplied null must not silently fall back to legacy rules.
|
|
77
|
+
const explicit = options.cardType !== undefined
|
|
78
|
+
if (explicit || configured !== undefined) {
|
|
79
|
+
const cardType = parseCardType({ cardType: explicit ? options.cardType : configured }, 'ESLint cardType')
|
|
80
|
+
return () => cardType === 'post'
|
|
81
|
+
}
|
|
82
|
+
return filename => {
|
|
83
|
+
const projectRoot = findEslintHost(filename, options.target)
|
|
84
|
+
return projectRoot ? readCardProfile({ projectRoot }).cardType === 'post' : false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The legacy shell entry uses the same parser as the SDK-owned ESLint configs.
|
|
89
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
90
|
+
try {
|
|
91
|
+
console.log(readCardProfile({ projectRoot: path.resolve(process.argv[2]) }).cardType)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(error.message)
|
|
94
|
+
process.exitCode = 1
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { watchFile, unwatchFile
|
|
1
|
+
import { watchFile, unwatchFile } from 'node:fs'
|
|
2
2
|
import { resolveCardRoutingPath, openCardBuildEnvironment } from '../build/opencard-environment.mjs'
|
|
3
3
|
|
|
4
4
|
// Polling follows atomic file replacements and also works when config/ is absent
|
|
@@ -6,13 +6,13 @@ import { resolveCardRoutingPath, openCardBuildEnvironment } from '../build/openc
|
|
|
6
6
|
export async function startRoutingPreview({ root, start, onError = console.error, signal, pollMs = 250, debounceMs = 150 }) {
|
|
7
7
|
const context = { root }
|
|
8
8
|
const file = resolveCardRoutingPath(context)
|
|
9
|
-
let mode = openCardBuildEnvironment(context)
|
|
9
|
+
let mode = openCardBuildEnvironment(context)
|
|
10
10
|
const controller = new AbortController()
|
|
11
11
|
const cancelStart = () => controller.abort()
|
|
12
12
|
signal?.addEventListener('abort', cancelStart, { once: true })
|
|
13
13
|
if (signal?.aborted) controller.abort()
|
|
14
14
|
async function launch(value) {
|
|
15
|
-
const runtime = await start({
|
|
15
|
+
const runtime = await start({ ...value }, controller.signal)
|
|
16
16
|
let closing
|
|
17
17
|
return { ...runtime, close: () => (closing ??= Promise.resolve().then(() => runtime.close())) }
|
|
18
18
|
}
|
|
@@ -30,10 +30,7 @@ export async function startRoutingPreview({ root, start, onError = console.error
|
|
|
30
30
|
|
|
31
31
|
function readMode() {
|
|
32
32
|
// During watching, deletion/incomplete saves must not become an open default.
|
|
33
|
-
|
|
34
|
-
const domain = routing?.cardData?.serviceDomain
|
|
35
|
-
if (!['closed', 'open', 'none'].includes(domain)) throw new Error(`${file}: cardData.serviceDomain must be closed, open, or none`)
|
|
36
|
-
return String(domain === 'closed')
|
|
33
|
+
return openCardBuildEnvironment(context, { allowMissing: false })
|
|
37
34
|
}
|
|
38
35
|
async function refresh() {
|
|
39
36
|
if (stopped) return
|
|
@@ -46,8 +43,9 @@ export async function startRoutingPreview({ root, start, onError = console.error
|
|
|
46
43
|
lastError = error.message
|
|
47
44
|
return
|
|
48
45
|
}
|
|
49
|
-
if (mode === next
|
|
50
|
-
|
|
46
|
+
if (mode.IS_CARD_CLOSED_DOMAIN === next.IS_CARD_CLOSED_DOMAIN &&
|
|
47
|
+
mode.VIBECODING_CARD_TYPE === next.VIBECODING_CARD_TYPE) return
|
|
48
|
+
console.log(`[opencard] Routing changed; restarting preview (closed=${next.IS_CARD_CLOSED_DOMAIN}, cardType=${next.VIBECODING_CARD_TYPE})`)
|
|
51
49
|
await current.close()
|
|
52
50
|
if (stopped) return
|
|
53
51
|
current = await launch(next)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
3
4
|
const Ajv = require("ajv");
|
|
4
5
|
|
|
5
6
|
function failFast(message) {
|
|
@@ -55,6 +56,37 @@ function resolveFeedCardSchema(projectRoot) {
|
|
|
55
56
|
return schemaPath;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function validateCardBackground(data) {
|
|
60
|
+
const validator = path.join(__dirname, "validate-card-background.py");
|
|
61
|
+
const result = spawnSync("python3", [
|
|
62
|
+
validator,
|
|
63
|
+
"--url", data.CardBackgroundUrl,
|
|
64
|
+
"--primary-color", data.PrimaryColor || "",
|
|
65
|
+
], { encoding: "utf8", timeout: 20_000 });
|
|
66
|
+
if (result.error) {
|
|
67
|
+
throw new Error(`Background image validation could not run: ${result.error.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
throw new Error((result.stderr || result.stdout || "Background image validation failed.").trim());
|
|
71
|
+
}
|
|
72
|
+
return JSON.parse(result.stdout);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateProjectCardBackground(context) {
|
|
76
|
+
if (context.target !== "taro") {
|
|
77
|
+
throw new Error("Feed background validation requires --target taro");
|
|
78
|
+
}
|
|
79
|
+
const jsonPath = path.join(context.root, "feed/feed-card.json");
|
|
80
|
+
if (!fs.existsSync(jsonPath)) return { skipped: true };
|
|
81
|
+
let data;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
failFast(`Invalid JSON syntax in ${jsonPath}: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
return validateCardBackground(data);
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
function validateFeedCard(context, options = {}) {
|
|
59
91
|
if (context.target !== "taro") {
|
|
60
92
|
throw new Error("Feed card validation requires --target taro");
|
|
@@ -140,6 +172,8 @@ function validateFeedCard(context, options = {}) {
|
|
|
140
172
|
reportAndExit(errors);
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
validateCardBackground(data);
|
|
176
|
+
|
|
143
177
|
if (options.touchTimestamp !== false) {
|
|
144
178
|
data.__timestamp = Date.now();
|
|
145
179
|
try {
|
|
@@ -153,4 +187,4 @@ function validateFeedCard(context, options = {}) {
|
|
|
153
187
|
return { skipped: false };
|
|
154
188
|
}
|
|
155
189
|
|
|
156
|
-
module.exports = { resolveFeedCardSchema, validateFeedCard };
|
|
190
|
+
module.exports = { resolveFeedCardSchema, validateFeedCard, validateCardBackground, validateProjectCardBackground };
|
|
@@ -25,8 +25,22 @@ export function resolveFeedCardSchema(projectRoot) {
|
|
|
25
25
|
* @params {object} context CLI 工程上下文。
|
|
26
26
|
* @params {object} [options] 校验选项。
|
|
27
27
|
* @return {unknown} 校验器返回的结果;发现错误时抛出 CliError。
|
|
28
|
-
* @example validateFeedCard(context
|
|
28
|
+
* @example validateFeedCard(context)
|
|
29
29
|
*/
|
|
30
30
|
export function validateFeedCard(context, options = {}) {
|
|
31
31
|
return runValidation(() => validator.validateFeedCard(context, options))
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
export function validateCardBackground(data) {
|
|
35
|
+
return runValidation(() => validator.validateCardBackground(data))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @desc 单独实测 feed-card.json 指向的最终背景图与 PrimaryColor 底部承接。
|
|
40
|
+
* @params {object} context CLI 工程上下文。
|
|
41
|
+
* @return {unknown} 图片尺寸和底部色彩/纹理指标;发现错误时抛出 CliError。
|
|
42
|
+
* @example validateProjectCardBackground(context)
|
|
43
|
+
*/
|
|
44
|
+
export function validateProjectCardBackground(context) {
|
|
45
|
+
return runValidation(() => validator.validateProjectCardBackground(context))
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IMAGE_INLINE_LIMIT = 4096;
|
|
5
|
+
const SKILL_GUIDANCE = "请查阅 microapp-optimize-interest-card/SKILL.md 中的图片规则";
|
|
6
|
+
|
|
7
|
+
/** Validate the image inventory after compilation. Findings only warn. */
|
|
8
|
+
export function validateImageInline(context) {
|
|
9
|
+
const cardRoot =
|
|
10
|
+
context.target === "opencard" ? context.root : context.opencardRoot;
|
|
11
|
+
if (!cardRoot || (context.target !== "opencard" && !context.hasOpenCard)) {
|
|
12
|
+
return { warnings: 0, unverified: 0 };
|
|
13
|
+
}
|
|
14
|
+
const reportFile = path.join(
|
|
15
|
+
cardRoot, "node_modules/.cache/vibecoding/image-inline-tt.json",
|
|
16
|
+
);
|
|
17
|
+
try {
|
|
18
|
+
const report = JSON.parse(readFileSync(reportFile, "utf8"));
|
|
19
|
+
if (report.schemaVersion !== 3 || report.status !== "complete" ||
|
|
20
|
+
report.target !== "tt" ||
|
|
21
|
+
realpathSync(report.projectRoot) !== realpathSync(cardRoot) ||
|
|
22
|
+
!Array.isArray(report.findings) || !Array.isArray(report.issues) ||
|
|
23
|
+
!report.findings.every(item => item && typeof item.file === "string" && Number.isInteger(item.line) && item.line > 0 &&
|
|
24
|
+
Number.isInteger(item.column) && item.column > 0 &&
|
|
25
|
+
(item.kind === "import" || item.kind === "data-url") &&
|
|
26
|
+
(item.kind === "import" ? typeof item.image === "string" : typeof item.dataUrl === "string")) ||
|
|
27
|
+
!report.issues.every(issue => typeof issue === "string")) {
|
|
28
|
+
throw new Error("图片检查报告格式或工程身份不匹配");
|
|
29
|
+
}
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const issues = [...report.issues];
|
|
32
|
+
for (const item of report.findings) {
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
if (item.kind === "import") {
|
|
36
|
+
const info = statSync(path.resolve(cardRoot, item.image));
|
|
37
|
+
if (!info.isFile()) throw new Error("图片路径不是文件");
|
|
38
|
+
bytes = info.size;
|
|
39
|
+
} else {
|
|
40
|
+
const match = /^data:image\/[a-z0-9.+-]+(?:;[a-z0-9_-]+=[^;,\s]+)*;base64,([a-z0-9+/]*={0,2})$/i.exec(item.dataUrl);
|
|
41
|
+
const encoded = match?.[1];
|
|
42
|
+
if (!encoded || encoded.length % 4 === 1) throw new Error("图片 Base64 编码无效");
|
|
43
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
44
|
+
if (decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "") ||
|
|
45
|
+
(encoded.includes("=") && encoded.length % 4 !== 0)) {
|
|
46
|
+
throw new Error("图片 Base64 编码无效");
|
|
47
|
+
}
|
|
48
|
+
bytes = decoded.length;
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
issues.push(`${item.file}:${item.line}:${item.column} 检查未完成:${error.message}。`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (bytes < IMAGE_INLINE_LIMIT) continue;
|
|
55
|
+
warnings.push(` ${item.file}:${item.line}:${item.column}\n` +
|
|
56
|
+
(item.kind === "import"
|
|
57
|
+
? ` 类型:图片 import\n 原图:${item.image}\n 大小:${bytes} bytes\n`
|
|
58
|
+
: ` 类型:固定 Base64 图片\n 解码大小:${bytes} bytes\n`));
|
|
59
|
+
}
|
|
60
|
+
if (warnings.length || issues.length) console.warn("=== 图片性能检测报告 ===\n");
|
|
61
|
+
if (warnings.length) {
|
|
62
|
+
console.warn(`检测到以下图片 import 或固定 Base64 图片大小 ≥${IMAGE_INLINE_LIMIT} 字节,存在内联图片性能风险。${SKILL_GUIDANCE}并在确认问题后上传图片以修复以下图片性能问题:\n`);
|
|
63
|
+
for (const warning of warnings) console.warn(warning);
|
|
64
|
+
console.warn(`共 ${warnings.length} 条可能的图片性能问题`);
|
|
65
|
+
}
|
|
66
|
+
if (issues.length) {
|
|
67
|
+
console.warn("图片检查未完成:");
|
|
68
|
+
for (const issue of issues) console.warn(` ${issue}`);
|
|
69
|
+
if (!warnings.length) console.warn(`${SKILL_GUIDANCE}。`);
|
|
70
|
+
}
|
|
71
|
+
return { warnings: warnings.length, unverified: issues.length };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn("=== 图片性能检测报告 ===\n");
|
|
74
|
+
console.warn(
|
|
75
|
+
"图片检查未完成:未取得有效报告。" +
|
|
76
|
+
"请检查 SDK 版本及构建日志。" +
|
|
77
|
+
` (${error.message})`,
|
|
78
|
+
);
|
|
79
|
+
console.warn(`${SKILL_GUIDANCE}。`);
|
|
80
|
+
return { warnings: 0, unverified: 1 };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -53,7 +53,7 @@ export function validateOpenCardStructure(context) {
|
|
|
53
53
|
`found ${preloadCount} preload call(s). ` +
|
|
54
54
|
'Use taro.preloadMiniApp({ appList }) on the Card instance provided by useCardTaro from @tarojs/taro (opencard-taro); ' +
|
|
55
55
|
'this is not a named export of @open-dy/vibecoding-sdk-opencard. ' +
|
|
56
|
-
'See ${
|
|
56
|
+
'See ${CAPABILITY_SKILL_DIR}/references/opencard/runtime.md, section "小程序跳转预加载", for the lifecycle guards and example.',
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the bottom continuation of a Feed Card background.
|
|
3
|
+
|
|
4
|
+
The current composer emits a 1440x2560 RGB PNG with an exact baseColor fill
|
|
5
|
+
below its hero. Existing cards also contain earlier valid variants with a
|
|
6
|
+
same-hue darkened or softly feathered continuation. This compatibility gate
|
|
7
|
+
therefore samples the whole bottom 30%, requiring a low-texture, same-hue
|
|
8
|
+
continuation while still rejecting raw heroes or attachments that run to the
|
|
9
|
+
host-button safety area.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
import urllib.request
|
|
18
|
+
import zlib
|
|
19
|
+
|
|
20
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
21
|
+
SAMPLE_W = 72
|
|
22
|
+
SAMPLE_H = 128
|
|
23
|
+
BOTTOM_START_RATIO = 0.70
|
|
24
|
+
MAX_HORIZONTAL_TEXTURE = 3.0
|
|
25
|
+
MAX_VERTICAL_TEXTURE = 4.0
|
|
26
|
+
MAX_HUE_DELTA = 12.0
|
|
27
|
+
LOW_SATURATION = 15.0
|
|
28
|
+
MAX_NEUTRAL_COLOR_DISTANCE = 24.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fail(message):
|
|
32
|
+
raise ValueError(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_color(value):
|
|
36
|
+
if not isinstance(value, str) or len(value) != 7 or not value.startswith("#"):
|
|
37
|
+
fail("PrimaryColor must be a #RRGGBB value for background validation.")
|
|
38
|
+
try:
|
|
39
|
+
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
|
|
40
|
+
except ValueError:
|
|
41
|
+
fail("PrimaryColor must be a #RRGGBB value for background validation.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def download(url):
|
|
45
|
+
request = urllib.request.Request(url, headers={"User-Agent": "vibecoding-feed-background-validator/1"})
|
|
46
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
47
|
+
content = response.read(12 * 1024 * 1024 + 1)
|
|
48
|
+
if len(content) > 12 * 1024 * 1024:
|
|
49
|
+
fail("CardBackgroundUrl exceeds the 12 MiB validation download limit.")
|
|
50
|
+
return content
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def paeth(a, b, c):
|
|
54
|
+
p = a + b - c
|
|
55
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
56
|
+
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_png(content):
|
|
60
|
+
if not content.startswith(PNG_SIGNATURE):
|
|
61
|
+
fail("CardBackgroundUrl must point to the final PNG produced by the background pipeline; received a non-PNG image.")
|
|
62
|
+
position = len(PNG_SIGNATURE)
|
|
63
|
+
width = height = bit_depth = color_type = interlace = None
|
|
64
|
+
idat = []
|
|
65
|
+
while position < len(content):
|
|
66
|
+
if position + 12 > len(content):
|
|
67
|
+
fail("CardBackgroundUrl PNG is truncated.")
|
|
68
|
+
size = struct.unpack(">I", content[position:position + 4])[0]
|
|
69
|
+
chunk_type = content[position + 4:position + 8]
|
|
70
|
+
chunk = content[position + 8:position + 8 + size]
|
|
71
|
+
position += size + 12
|
|
72
|
+
if chunk_type == b"IHDR":
|
|
73
|
+
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(">IIBBBBB", chunk)
|
|
74
|
+
if compression != 0 or filter_method != 0:
|
|
75
|
+
fail("CardBackgroundUrl PNG uses an unsupported PNG encoding.")
|
|
76
|
+
elif chunk_type == b"IDAT":
|
|
77
|
+
idat.append(chunk)
|
|
78
|
+
elif chunk_type == b"IEND":
|
|
79
|
+
break
|
|
80
|
+
if not width or not height or bit_depth != 8 or color_type not in (2, 6) or interlace != 0:
|
|
81
|
+
fail("CardBackgroundUrl must be a non-interlaced 8-bit RGB or RGBA PNG.")
|
|
82
|
+
channels = 4 if color_type == 6 else 3
|
|
83
|
+
row_bytes = width * channels
|
|
84
|
+
raw = zlib.decompress(b"".join(idat))
|
|
85
|
+
if len(raw) != height * (row_bytes + 1):
|
|
86
|
+
fail("CardBackgroundUrl PNG pixel data is invalid.")
|
|
87
|
+
rows, previous, cursor = [], bytearray(row_bytes), 0
|
|
88
|
+
for _ in range(height):
|
|
89
|
+
filter_type = raw[cursor]
|
|
90
|
+
cursor += 1
|
|
91
|
+
encoded = raw[cursor:cursor + row_bytes]
|
|
92
|
+
cursor += row_bytes
|
|
93
|
+
row = bytearray(row_bytes)
|
|
94
|
+
for index, value in enumerate(encoded):
|
|
95
|
+
left = row[index - channels] if index >= channels else 0
|
|
96
|
+
up = previous[index]
|
|
97
|
+
up_left = previous[index - channels] if index >= channels else 0
|
|
98
|
+
if filter_type == 0:
|
|
99
|
+
row[index] = value
|
|
100
|
+
elif filter_type == 1:
|
|
101
|
+
row[index] = (value + left) & 255
|
|
102
|
+
elif filter_type == 2:
|
|
103
|
+
row[index] = (value + up) & 255
|
|
104
|
+
elif filter_type == 3:
|
|
105
|
+
row[index] = (value + ((left + up) // 2)) & 255
|
|
106
|
+
elif filter_type == 4:
|
|
107
|
+
row[index] = (value + paeth(left, up, up_left)) & 255
|
|
108
|
+
else:
|
|
109
|
+
fail("CardBackgroundUrl PNG uses an unsupported row filter.")
|
|
110
|
+
rows.append(row)
|
|
111
|
+
previous = row
|
|
112
|
+
return width, height, channels, rows
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rgb_to_hsl(rgb):
|
|
116
|
+
r, g, b = (channel / 255.0 for channel in rgb)
|
|
117
|
+
maximum, minimum = max(r, g, b), min(r, g, b)
|
|
118
|
+
lightness = (maximum + minimum) / 2.0
|
|
119
|
+
if maximum == minimum:
|
|
120
|
+
return 0.0, 0.0, lightness * 100.0
|
|
121
|
+
delta = maximum - minimum
|
|
122
|
+
saturation = delta / (2.0 - maximum - minimum) if lightness > 0.5 else delta / (maximum + minimum)
|
|
123
|
+
if maximum == r:
|
|
124
|
+
hue = ((g - b) / delta + (6.0 if g < b else 0.0)) / 6.0
|
|
125
|
+
elif maximum == g:
|
|
126
|
+
hue = ((b - r) / delta + 2.0) / 6.0
|
|
127
|
+
else:
|
|
128
|
+
hue = ((r - g) / delta + 4.0) / 6.0
|
|
129
|
+
return hue * 360.0, saturation * 100.0, lightness * 100.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hue_delta(first, second):
|
|
133
|
+
return min(abs(first - second), 360.0 - abs(first - second))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def color_distance(first, second):
|
|
137
|
+
return sum(abs(first[index] - second[index]) for index in range(3)) / 3.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def inspect(content, primary):
|
|
141
|
+
width, height, channels, rows = decode_png(content)
|
|
142
|
+
if abs(width / height - 9 / 16) > 0.01:
|
|
143
|
+
fail(f"CardBackgroundUrl must be 9:16, got {width}x{height}.")
|
|
144
|
+
|
|
145
|
+
bottom_start = math.floor(height * BOTTOM_START_RATIO)
|
|
146
|
+
for y in range(bottom_start, height):
|
|
147
|
+
row = rows[y]
|
|
148
|
+
for x in range(width):
|
|
149
|
+
offset = x * channels
|
|
150
|
+
if channels == 4 and row[offset + 3] != 255:
|
|
151
|
+
fail("CardBackgroundUrl bottom continuation must be fully opaque.")
|
|
152
|
+
|
|
153
|
+
sample_x = [round(index * (width - 1) / (SAMPLE_W - 1)) for index in range(SAMPLE_W)]
|
|
154
|
+
sample_y = [round(index * (height - 1) / (SAMPLE_H - 1)) for index in range(SAMPLE_H)]
|
|
155
|
+
pixels = [
|
|
156
|
+
[tuple(rows[y][x * channels + channel] for channel in range(3)) for x in sample_x]
|
|
157
|
+
for y in sample_y
|
|
158
|
+
]
|
|
159
|
+
sample_start = math.floor(SAMPLE_H * BOTTOM_START_RATIO)
|
|
160
|
+
bottom = pixels[sample_start:]
|
|
161
|
+
horizontal = [color_distance(row[index], row[index - 1]) for row in bottom for index in range(1, SAMPLE_W)]
|
|
162
|
+
vertical = [
|
|
163
|
+
color_distance(bottom[row_index][column], bottom[row_index - 1][column])
|
|
164
|
+
for row_index in range(1, len(bottom))
|
|
165
|
+
for column in range(SAMPLE_W)
|
|
166
|
+
]
|
|
167
|
+
average = tuple(round(sum(pixel[channel] for row in bottom for pixel in row) / (len(bottom) * SAMPLE_W)) for channel in range(3))
|
|
168
|
+
average_hue, _, _ = rgb_to_hsl(average)
|
|
169
|
+
primary_hue, primary_saturation, _ = rgb_to_hsl(primary)
|
|
170
|
+
horizontal_texture = sum(horizontal) / len(horizontal)
|
|
171
|
+
vertical_texture = sum(vertical) / len(vertical)
|
|
172
|
+
issues = []
|
|
173
|
+
if horizontal_texture > MAX_HORIZONTAL_TEXTURE:
|
|
174
|
+
issues.append(f"horizontal texture={horizontal_texture:.1f}>{MAX_HORIZONTAL_TEXTURE}")
|
|
175
|
+
if vertical_texture > MAX_VERTICAL_TEXTURE:
|
|
176
|
+
issues.append(f"vertical texture={vertical_texture:.1f}>{MAX_VERTICAL_TEXTURE}")
|
|
177
|
+
if primary_saturation < LOW_SATURATION:
|
|
178
|
+
neutral_distance = color_distance(average, primary)
|
|
179
|
+
if neutral_distance > MAX_NEUTRAL_COLOR_DISTANCE:
|
|
180
|
+
issues.append(f"neutral color distance={neutral_distance:.1f}>{MAX_NEUTRAL_COLOR_DISTANCE}")
|
|
181
|
+
else:
|
|
182
|
+
delta = hue_delta(average_hue, primary_hue)
|
|
183
|
+
if delta > MAX_HUE_DELTA:
|
|
184
|
+
issues.append(f"hue delta={delta:.1f}>{MAX_HUE_DELTA}")
|
|
185
|
+
if issues:
|
|
186
|
+
fail(
|
|
187
|
+
"CardBackgroundUrl bottom 30% is not a low-texture PrimaryColor continuation (" +
|
|
188
|
+
", ".join(issues) +
|
|
189
|
+
"). Re-run the background four-step pipeline or regenerate a same-hue solid/gradient extension."
|
|
190
|
+
)
|
|
191
|
+
return {
|
|
192
|
+
"width": width,
|
|
193
|
+
"height": height,
|
|
194
|
+
"bottomStartY": bottom_start,
|
|
195
|
+
"bottomHorizontalTexture": round(horizontal_texture, 2),
|
|
196
|
+
"bottomVerticalTexture": round(vertical_texture, 2),
|
|
197
|
+
"bottomAverage": "#" + "".join(f"{channel:02x}" for channel in average),
|
|
198
|
+
"primaryColor": "#" + "".join(f"{channel:02x}" for channel in primary),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main():
|
|
203
|
+
parser = argparse.ArgumentParser()
|
|
204
|
+
parser.add_argument("--url", required=True)
|
|
205
|
+
parser.add_argument("--primary-color", required=True)
|
|
206
|
+
args = parser.parse_args()
|
|
207
|
+
result = inspect(download(args.url), parse_color(args.primary_color))
|
|
208
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
try:
|
|
213
|
+
main()
|
|
214
|
+
except Exception as error:
|
|
215
|
+
print(f"[Background Validation Failed] {error}", file=sys.stderr)
|
|
216
|
+
sys.exit(1)
|