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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +47 -0
  2. package/assets/schemas/custom-card-schema.json +102 -0
  3. package/bin/vibecoding +89 -0
  4. package/bin/vibecoding-dev +308 -0
  5. package/bin/vibecoding-interest-card.mjs +35 -0
  6. package/bin/vibecoding.mjs +9 -0
  7. package/package.json +58 -0
  8. package/src/adapters/interest-card/opencard/commands.mjs +62 -0
  9. package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
  10. package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
  11. package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
  12. package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
  13. package/src/adapters/interest-card/opencard/lint.sh +131 -0
  14. package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
  15. package/src/adapters/interest-card/opencard/pack.sh +27 -0
  16. package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
  17. package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
  18. package/src/adapters/interest-card/opencard/validate.mjs +11 -0
  19. package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
  20. package/src/build/opencard-environment.mjs +49 -0
  21. package/src/build/turbo-config.mjs +140 -0
  22. package/src/cli.mjs +104 -0
  23. package/src/commands/build.mjs +138 -0
  24. package/src/commands/db.mjs +14 -0
  25. package/src/commands/dev.mjs +319 -0
  26. package/src/commands/lint.mjs +176 -0
  27. package/src/commands/pack.mjs +169 -0
  28. package/src/commands/turbo.mjs +36 -0
  29. package/src/commands/validate.mjs +26 -0
  30. package/src/commands/verify.mjs +602 -0
  31. package/src/core/args.mjs +101 -0
  32. package/src/core/context.mjs +101 -0
  33. package/src/core/environment.mjs +55 -0
  34. package/src/core/errors.mjs +14 -0
  35. package/src/core/ports.mjs +76 -0
  36. package/src/core/process-output.mjs +25 -0
  37. package/src/core/runner.mjs +90 -0
  38. package/src/core/service-log-context.mjs +27 -0
  39. package/src/core/web-config.mjs +13 -0
  40. package/src/lint/lint-lynx-css.mjs +244 -0
  41. package/src/lint/lynx-css-rules.mjs +138 -0
  42. package/src/preview/dev-watch.mjs +38 -0
  43. package/src/preview/start-preview.mjs +213 -0
  44. package/src/preview/watch-card-routing.mjs +89 -0
  45. package/src/scripts/pack.mjs +15 -0
  46. package/src/validators/dependencies.mjs +34 -0
  47. package/src/validators/empty-project.mjs +29 -0
  48. package/src/validators/feed-card.cjs +156 -0
  49. package/src/validators/feed-card.mjs +32 -0
  50. package/src/validators/opencard-structure.mjs +58 -0
  51. package/src/validators/routes.cjs +470 -0
  52. package/src/validators/routes.mjs +17 -0
  53. package/src/validators/sql.mjs +57 -0
  54. package/src/validators/widget-shell.mjs +107 -0
@@ -0,0 +1,62 @@
1
+ import { openCardBuildEnvironment } from '../../../build/opencard-environment.mjs'
2
+ import { buildOpenCard } from '../../../commands/build.mjs'
3
+ import { spawn } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { CliError, UsageError } from '../../../core/errors.mjs'
7
+ import { resolveSdkTaroEntry } from '../../../core/runner.mjs'
8
+
9
+ const directory = path.dirname(fileURLToPath(import.meta.url))
10
+
11
+ // Keep the original scripts' exit codes and forward termination to the child.
12
+ function execute(command, args, root, env = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(command, args, { cwd: root, env: { ...process.env, ...env }, stdio: 'inherit' })
15
+ const handlers = new Map(['SIGINT', 'SIGTERM', 'SIGHUP'].map(signal => [signal, () => child.kill(signal)]))
16
+ for (const [signal, handler] of handlers) process.on(signal, handler)
17
+ const cleanup = () => { for (const [signal, handler] of handlers) process.off(signal, handler) }
18
+ child.once('error', error => { cleanup(); reject(error) })
19
+ child.once('exit', (code, signal) => {
20
+ cleanup()
21
+ if (code === 0) resolve()
22
+ else reject(new CliError(`${command} failed${signal ? ` (${signal})` : ''}`, code ?? 1))
23
+ })
24
+ })
25
+ }
26
+
27
+ /**
28
+ * @desc 调度兴趣卡 OpenCard 的开发、构建、校验和打包命令。
29
+ * @params {object} context 兴趣卡工程上下文。
30
+ * @params {string} command 要执行的命令名。
31
+ * @params {string} name 校验器名称;非校验命令可为空。
32
+ * @params {object} [flags] 增量检查等命令选项。
33
+ * @return {Promise<void>} 子进程完成后的 Promise。
34
+ * @example runInterestCard(context, 'lint', '', { incremental: true })
35
+ */
36
+ export async function runInterestCard(context, command, name, flags = {}) {
37
+ const root = context.root
38
+ if (['dev', 'lint', 'pack'].includes(command)) {
39
+ const script = command === 'dev' ? 'dev_run.sh' : `${command}.sh`
40
+ return execute('bash', [path.join(directory, script), root], root,
41
+ { ...(flags.incremental ? { LINT_INCREMENTAL: '1' } : {}),
42
+ ...(command === 'lint' ? {} : openCardBuildEnvironment(context)) })
43
+ }
44
+ if (command === 'dev-watch') {
45
+ process.env.VIBECODING_OPENCARD_ROOT = root
46
+ return import('./dev-watch.mjs')
47
+ }
48
+ if (command === 'dev-tt') {
49
+ return buildOpenCard(context, { watch: true, mode: 'dev' })
50
+ }
51
+ if (command === 'build') {
52
+ await execute(process.execPath, [resolveSdkTaroEntry(root), 'build', '--type', 'tt'], root, openCardBuildEnvironment(context))
53
+ return execute(process.execPath, [path.join(directory, 'write-web-config.mjs'), 'dist'], root)
54
+ }
55
+ if (command === 'validate') {
56
+ if (!['opencard-structure', 'widget-shell', 'lynx-css'].includes(name)) {
57
+ throw new UsageError('Interest-card validators: opencard-structure, widget-shell, lynx-css')
58
+ }
59
+ return execute(process.execPath, [path.join(directory, 'validate.mjs'), root, name], root)
60
+ }
61
+ throw new UsageError(`Unsupported interest-card command: ${command}`)
62
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { startRoutingPreview } from '../../../preview/watch-card-routing.mjs';
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ const __dirname = path.join(process.env.VIBECODING_OPENCARD_ROOT || process.cwd(), 'scripts');
10
+ const root = path.resolve(__dirname, '..');
11
+ const TT_OUTPUT_DIR = 'dist-tt';
12
+
13
+ const RSPUCK_OUTPUT_DIR = '/tmp/microapp_dist/opencard-dist';
14
+ const RSPUCK_CACHE_DIR = '/tmp/microapp_dist/opencard-dist-cache';
15
+ const TARO_READY_FILE = path.join(root, TT_OUTPUT_DIR, 'app.json');
16
+ const META_PATH = '/opt/gem/ai_coding_app/.meta/ai_coding_app_meta.json';
17
+ const READY_TIMEOUT_MS = 5 * 60 * 1000;
18
+ const READY_POLL_MS = 500;
19
+ const DEFAULT_PORT = 10087;
20
+ const AI_CODING_BUILTIN_PACKAGES_DIR = process.env.AI_CODING_BUILTIN_PACKAGES_DIR;
21
+
22
+ const children = [];
23
+ let shuttingDown = false;
24
+ let previewRuntime;
25
+ let routingPreview;
26
+ const retiringChildren = new WeakSet();
27
+
28
+ function log(message) {
29
+ console.log(`[opencard-dev-watch] ${message}`);
30
+ }
31
+
32
+ async function loadPreviewRunner() {
33
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
34
+ const offlineRunner = manifest.devDependencies?.['@byted/microapp-preview-runner']?.startsWith('file:patches/') === true;
35
+ if (offlineRunner || !AI_CODING_BUILTIN_PACKAGES_DIR) {
36
+ return import(pathToFileURL(createRequire(path.join(root, 'package.json')).resolve('@byted/microapp-preview-runner')).href);
37
+ }
38
+
39
+ const requireFromTarget = createRequire(
40
+ path.join(path.resolve(AI_CODING_BUILTIN_PACKAGES_DIR), 'package.json'),
41
+ );
42
+ const runnerEntry = requireFromTarget.resolve('@byted/microapp-preview-runner');
43
+
44
+ log(`using preview runner from ${AI_CODING_BUILTIN_PACKAGES_DIR}`);
45
+ return import(pathToFileURL(runnerEntry).href);
46
+ }
47
+
48
+ function readMeta() {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(META_PATH, 'utf-8'));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function getConversationId() {
57
+ return (
58
+ readMeta()?.conversation_id ??
59
+ ''
60
+ );
61
+ }
62
+
63
+ function getPreviewBasePath() {
64
+ const conversationId = getConversationId();
65
+
66
+ return conversationId
67
+ ? `/api/apps/open_ai_coding_chat_server/aio_fe/${conversationId}/card_preview`
68
+ : '';
69
+ }
70
+
71
+ function getPreviewPort() {
72
+ const port = Number.parseInt(process.env.OPENCARD_PORT ?? `${DEFAULT_PORT}`, 10);
73
+
74
+ return Number.isNaN(port) ? DEFAULT_PORT : port;
75
+ }
76
+
77
+ function spawnChild(name, command, args, extraEnv = {}, spawnOptions = {}) {
78
+ log(`starting ${name}: ${command} ${args.join(' ')}`);
79
+ const child = spawn(command, args, {
80
+ cwd: root,
81
+ stdio: spawnOptions.stdio ?? 'inherit',
82
+ env: { ...process.env, ...extraEnv },
83
+ detached: true,
84
+ });
85
+ children.push({ name, child });
86
+
87
+ child.on('exit', (code, signal) => {
88
+ if (shuttingDown || retiringChildren.has(child)) {
89
+ return;
90
+ }
91
+ log(`${name} exited (code=${code}, signal=${signal ?? 'none'}), shutting down`);
92
+ shutdown(code ?? 1);
93
+ });
94
+
95
+ return child;
96
+ }
97
+
98
+ function shutdown(exitCode) {
99
+ if (shuttingDown) {
100
+ return;
101
+ }
102
+ shuttingDown = true;
103
+ void routingPreview?.close().catch(() => {});
104
+
105
+ for (const { child } of children) {
106
+ if (child.pid && child.exitCode === null && child.signalCode === null) {
107
+ try {
108
+ process.kill(-child.pid, 'SIGKILL');
109
+ } catch {
110
+ // Process may have already exited.
111
+ }
112
+ }
113
+ }
114
+
115
+ if (previewRuntime) {
116
+ void previewRuntime.close().catch(() => {});
117
+ }
118
+
119
+ process.exit(exitCode);
120
+ }
121
+
122
+ function waitForFile(label, filePath, signal) {
123
+ return new Promise((resolve, reject) => {
124
+ const startedAt = Date.now();
125
+ const abort = () => { clearInterval(timer); reject(new Error('Preview startup cancelled')); };
126
+ const cleanup = () => { clearInterval(timer); signal?.removeEventListener('abort', abort); };
127
+
128
+ const timer = setInterval(() => {
129
+ if (shuttingDown) {
130
+ cleanup();
131
+ return;
132
+ }
133
+ if (fs.existsSync(filePath)) {
134
+ cleanup();
135
+ log(`${label} ready: ${path.relative(root, filePath)}`);
136
+ resolve();
137
+ return;
138
+ }
139
+ if (Date.now() - startedAt > READY_TIMEOUT_MS) {
140
+ cleanup();
141
+ reject(new Error(`Timed out waiting for ${label} (${filePath})`));
142
+ }
143
+ }, READY_POLL_MS);
144
+ signal?.addEventListener('abort', abort, { once: true });
145
+ if (signal?.aborted) abort();
146
+ });
147
+ }
148
+
149
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
150
+ process.on(signal, () => shutdown(0));
151
+ }
152
+
153
+ async function startCycle(env, signal) {
154
+ const { startRspuckPreview } = await loadPreviewRunner();
155
+
156
+ fs.rmSync(path.join(root, TT_OUTPUT_DIR), { recursive: true, force: true });
157
+ fs.rmSync(RSPUCK_OUTPUT_DIR, { recursive: true, force: true });
158
+
159
+ const child = spawnChild('taro', 'pnpm', ['dev:tt'], env);
160
+ try {
161
+ await waitForFile(`taro ${TT_OUTPUT_DIR}`, TARO_READY_FILE, signal);
162
+ } catch (error) {
163
+ retiringChildren.add(child);
164
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
165
+ throw error;
166
+ }
167
+
168
+ previewRuntime = await startRspuckPreview({
169
+ projectRoot: root,
170
+ inputDir: path.join(root, TT_OUTPUT_DIR),
171
+ outputDir: RSPUCK_OUTPUT_DIR,
172
+ cacheDir: RSPUCK_CACHE_DIR,
173
+ port: getPreviewPort(),
174
+ appid: process.env.APP_ID,
175
+ previewBasePath: getPreviewBasePath(),
176
+ readyTimeoutMs: READY_TIMEOUT_MS,
177
+ cardMode: true
178
+ });
179
+ const runtime = previewRuntime;
180
+ return { child, runtime, close: async () => {
181
+ retiringChildren.add(child);
182
+ if (child.pid && child.exitCode === null && child.signalCode === null) {
183
+ const exited = new Promise(resolve => child.once('exit', resolve));
184
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
185
+ await exited;
186
+ }
187
+ await runtime.close();
188
+ const index = children.findIndex(entry => entry.child === child);
189
+ if (index >= 0) children.splice(index, 1);
190
+ } };
191
+ }
192
+
193
+ async function main() {
194
+ routingPreview = await startRoutingPreview({ root, start: startCycle, onError: error => {
195
+ console.error(`[opencard-dev-watch] ${error.message}`);
196
+ shutdown(1);
197
+ } });
198
+ }
199
+
200
+ main().catch((error) => {
201
+ console.error(`[opencard-dev-watch] ${error.message}`);
202
+ shutdown(1);
203
+ });
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="${1:?Missing project root}"
5
+ shift
6
+ MICROAPP_ROOT="$(cd "${ROOT}/.." && pwd)"
7
+ OPENCARD_PORT="${OPENCARD_PORT:-10087}"
8
+
9
+ # 与 microapp/scripts/dev_run.sh 的 RUNTIME_ROOT 对齐,便于单独 pnpm dev 时也写入统一 dev.log
10
+ if [[ "$(uname -s)" == "Linux" ]]; then
11
+ DEFAULT_LOG_FILE="/workspace/tmp/microapp-logs/dev.log"
12
+ else
13
+ DEFAULT_LOG_FILE="${MICROAPP_ROOT}/.tmp/microapp-dev/microapp-logs/dev.log"
14
+ fi
15
+ LOG_FILE="${LOG_FILE:-${DEFAULT_LOG_FILE}}"
16
+
17
+ iso_timestamp() {
18
+ TZ=Asia/Shanghai date "+%Y-%m-%d %H:%M:%S"
19
+ }
20
+
21
+ strip_ansi() {
22
+ # shellcheck disable=SC2001
23
+ printf '%s' "$1" | sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g'
24
+ }
25
+
26
+ is_compile_error_start() {
27
+ local plain=$1
28
+ [[ "${plain}" =~ Unexpected[[:space:]]+token ]] && return 0
29
+ [[ "${plain}" =~ SyntaxError|TypeError|ReferenceError|EvalError ]] && return 0
30
+ [[ "${plain}" =~ ELIFECYCLE|Failed[[:space:]]+to[[:space:]]+compile|Build[[:space:]]+failed ]] && return 0
31
+ [[ "${plain}" =~ error[[:space:]]+TS[0-9]+ ]] && return 0
32
+ [[ "${plain}" =~ ERROR[[:space:]]+in[[:space:]] ]] && return 0
33
+ [[ "${plain}" =~ Module[[:space:]]+not[[:space:]]+found|Cannot[[:space:]]+find[[:space:]]+module ]] && return 0
34
+ [[ "${plain}" =~ \[commonjs\].*:[[:space:]].+ ]] && return 0
35
+ [[ "${plain}" =~ \[vite:[^]]+\].+:\ .+ ]] && return 0
36
+ [[ "${plain}" =~ Internal[[:space:]]+server[[:space:]]+error ]] && return 0
37
+ [[ "${plain}" =~ [Ee]rror:[[:space:]] ]] && return 0
38
+ return 1
39
+ }
40
+
41
+ is_compile_error_cont() {
42
+ local plain=$1
43
+ [[ -z "${plain// }" ]] && return 0
44
+ [[ "${plain}" =~ [0-9]+[[:space:]]+\| ]] && return 0
45
+ [[ "${plain}" =~ \>[[:space:]]*[0-9]+[[:space:]]+\| ]] && return 0
46
+ [[ "${plain}" =~ \|[[:space:]]+\^ ]] && return 0
47
+ [[ "${plain}" =~ file:[[:space:]]*/ ]] && return 0
48
+ [[ "${plain}" =~ ^[[:space:]]*\^[[:space:]]*$ ]] && return 0
49
+ return 1
50
+ }
51
+
52
+ # 终端 + 日志:[<时间>] [stdout|error] [opencard] <原行>
53
+ opencard_prefix_log_stream() {
54
+ local stream=$1
55
+ local line plain label
56
+ local in_error_block=0
57
+
58
+ while IFS= read -r line || [[ -n "${line}" ]]; do
59
+ plain="$(strip_ansi "${line}")"
60
+ label="stdout"
61
+
62
+ if [[ "${stream}" == "stderr" ]]; then
63
+ label="error"
64
+ in_error_block=1
65
+ elif is_compile_error_start "${plain}"; then
66
+ label="error"
67
+ in_error_block=1
68
+ elif [[ "${in_error_block}" -eq 1 ]] && is_compile_error_cont "${plain}"; then
69
+ label="error"
70
+ else
71
+ in_error_block=0
72
+ label="stdout"
73
+ fi
74
+
75
+ printf '[%s] [%s] [opencard] %s\n' "$(iso_timestamp)" "${label}" "${line}"
76
+ done
77
+ }
78
+
79
+ kill_process_tree() {
80
+ local pid=$1
81
+ local children
82
+
83
+ children=$(pgrep -P "${pid}" 2>/dev/null || true)
84
+ for child in ${children}; do
85
+ kill_process_tree "${child}"
86
+ done
87
+
88
+ if kill -0 "${pid}" 2>/dev/null; then
89
+ kill -9 "${pid}" 2>/dev/null || true
90
+ fi
91
+ }
92
+
93
+ kill_port_if_listening() {
94
+ local port=$1
95
+ local pids
96
+
97
+ pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u | paste -sd' ' - || true)
98
+ if [[ -z "${pids}" ]]; then
99
+ echo "[opencard:dev] port ${port} is free"
100
+ return
101
+ fi
102
+
103
+ echo "[opencard:dev] clearing port ${port}, pids: ${pids}"
104
+ for pid in ${pids}; do
105
+ kill_process_tree "${pid}"
106
+ done
107
+ }
108
+
109
+ init_opencard_logging() {
110
+ # 父级 microapp dev_run 已负责 dev.log 与 [error] 分类
111
+ if [[ "${MICROAPP_NESTED_LOGGING:-0}" == "1" ]]; then
112
+ return 0
113
+ fi
114
+
115
+ mkdir -p "$(dirname "${LOG_FILE}")"
116
+ printf '[%s] [stdout] [opencard] [opencard:dev] mirroring output to %s\n' "$(iso_timestamp)" "${LOG_FILE}" >> "${LOG_FILE}"
117
+
118
+ exec > >(opencard_prefix_log_stream stdout | tee -a "${LOG_FILE}")
119
+ exec 2> >(opencard_prefix_log_stream stderr | tee -a "${LOG_FILE}" >&2)
120
+ }
121
+
122
+ kill_port_if_listening "${OPENCARD_PORT}"
123
+
124
+ cd "${ROOT}"
125
+ init_opencard_logging
126
+ exec env OPENCARD_PORT="${OPENCARD_PORT}" LOG_FILE="${LOG_FILE}" pnpm dev:h5
@@ -0,0 +1,278 @@
1
+ import {
2
+ isTextContainerDeclaration,
3
+ lintDeclaration,
4
+ } from './lynx-css-rules.mjs';
5
+
6
+ const FORBIDDEN_APP_ENTRY_FILES = new Set([
7
+ 'app.ts',
8
+ 'app.tsx',
9
+ 'app.js',
10
+ 'app.jsx',
11
+ ]);
12
+
13
+ const FORBIDDEN_HTML_TAGS = new Set([
14
+ 'div',
15
+ 'span',
16
+ 'section',
17
+ 'main',
18
+ 'img',
19
+ 'svg',
20
+ 'button',
21
+ 'input',
22
+ 'textarea',
23
+ 'ul',
24
+ 'li',
25
+ 'p',
26
+ ]);
27
+
28
+ const noAppEntryRule = {
29
+ meta: {
30
+ type: 'problem',
31
+ docs: {
32
+ description: '禁止在自定义 Feed 卡中创建 Taro App 入口文件(app.ts / app.tsx)。',
33
+ },
34
+ schema: [],
35
+ },
36
+ create(context) {
37
+ const filename = context.filename || context.getFilename?.() || '';
38
+ const base = filename.split(/[/\\]/).pop() || '';
39
+ if (!FORBIDDEN_APP_ENTRY_FILES.has(base)) {
40
+ return {};
41
+ }
42
+
43
+ return {
44
+ Program(node) {
45
+ context.report({
46
+ node,
47
+ message:
48
+ '自定义 Feed 卡不支持 app.ts / app.tsx 等 Taro App 入口。请删除该文件,' +
49
+ '应用级使用 app.config.ts + app.css,页面逻辑写在 src/pages/index/index.tsx。',
50
+ });
51
+ },
52
+ };
53
+ },
54
+ };
55
+
56
+ const noHtmlTagsRule = {
57
+ meta: {
58
+ type: 'problem',
59
+ docs: {
60
+ description: '禁止在 opencard TSX 中使用浏览器 HTML 标签,须使用 Taro 组件。',
61
+ },
62
+ schema: [],
63
+ },
64
+ create(context) {
65
+ return {
66
+ JSXOpeningElement(node) {
67
+ const name = node.name;
68
+ if (name.type !== 'JSXIdentifier') return;
69
+ const tagName = name.name;
70
+ // HTML 标签在 JSX 中为小写;PascalCase(Input、Button、View 等)为 Taro 组件,不应拦截。
71
+ if (tagName !== tagName.toLowerCase()) return;
72
+ if (!FORBIDDEN_HTML_TAGS.has(tagName)) return;
73
+ context.report({
74
+ node,
75
+ message: `禁止使用 HTML 标签 <${tagName}>;请改用 Taro 组件(见 taro-dsl.md / components.md)。`,
76
+ });
77
+ },
78
+ };
79
+ },
80
+ };
81
+
82
+ const WIDGET_REGIONS = new Set([
83
+ 'widget-header',
84
+ 'widget-body',
85
+ 'widget-footer',
86
+ ]);
87
+
88
+ function collectStaticClassNames(node) {
89
+ if (!node) return [];
90
+ if (node.type === 'Literal' && typeof node.value === 'string') {
91
+ return node.value.split(/\s+/).filter(Boolean);
92
+ }
93
+ if (node.type === 'TemplateLiteral') {
94
+ return node.quasis.flatMap((quasi) =>
95
+ (quasi.value.cooked ?? '').split(/\s+/).filter(Boolean),
96
+ );
97
+ }
98
+ if (node.type === 'ConditionalExpression') {
99
+ return [
100
+ ...collectStaticClassNames(node.consequent),
101
+ ...collectStaticClassNames(node.alternate),
102
+ ];
103
+ }
104
+ if (node.type === 'LogicalExpression') {
105
+ return [
106
+ ...collectStaticClassNames(node.left),
107
+ ...collectStaticClassNames(node.right),
108
+ ];
109
+ }
110
+ return [];
111
+ }
112
+
113
+ function jsxClassNames(openingElement) {
114
+ const className = openingElement.attributes.find(
115
+ (attribute) =>
116
+ attribute.type === 'JSXAttribute' && attribute.name?.name === 'className',
117
+ );
118
+ if (!className?.value) return [];
119
+ if (className.value.type === 'Literal') {
120
+ return collectStaticClassNames(className.value);
121
+ }
122
+ if (className.value.type === 'JSXExpressionContainer') {
123
+ return collectStaticClassNames(className.value.expression);
124
+ }
125
+ return [];
126
+ }
127
+
128
+ const noNestedWidgetRegionsRule = {
129
+ meta: {
130
+ type: 'problem',
131
+ docs: {
132
+ description:
133
+ '要求 widget-header、widget-body、widget-footer 保持同级,避免固定区吞掉自适应区。',
134
+ },
135
+ schema: [],
136
+ },
137
+ create(context) {
138
+ return {
139
+ JSXOpeningElement(node) {
140
+ const region = jsxClassNames(node).find((name) =>
141
+ WIDGET_REGIONS.has(name),
142
+ );
143
+ if (!region) return;
144
+
145
+ let ancestor = node.parent?.parent;
146
+ while (ancestor) {
147
+ if (ancestor.type === 'JSXElement') {
148
+ const parentRegion = jsxClassNames(ancestor.openingElement).find(
149
+ (name) => WIDGET_REGIONS.has(name),
150
+ );
151
+ if (parentRegion) {
152
+ context.report({
153
+ node,
154
+ message:
155
+ `.${region} 不能嵌套在 .${parentRegion} 内。` +
156
+ '.widget-header、.widget-body、.widget-footer 必须是同级子区。',
157
+ });
158
+ return;
159
+ }
160
+ }
161
+ ancestor = ancestor.parent;
162
+ }
163
+ },
164
+ };
165
+ },
166
+ };
167
+
168
+ function staticStyleValue(node) {
169
+ if (!node) return null;
170
+ if (node.type === 'Literal') {
171
+ if (typeof node.value === 'string' || typeof node.value === 'number') {
172
+ return String(node.value);
173
+ }
174
+ return null;
175
+ }
176
+ if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument?.type === 'Literal') {
177
+ return `-${node.argument.value}`;
178
+ }
179
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
180
+ return node.quasis[0]?.value.cooked ?? null;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function stylePropertyName(key) {
186
+ if (key.type === 'Identifier') return key.name;
187
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
188
+ return '';
189
+ }
190
+
191
+ const noUnsupportedLynxStyleRule = {
192
+ meta: {
193
+ type: 'problem',
194
+ docs: {
195
+ description: '禁止在 opencard inline style 中使用 Lynx 不支持的 CSS。',
196
+ },
197
+ schema: [],
198
+ },
199
+ create(context) {
200
+ return {
201
+ JSXAttribute(node) {
202
+ if (node.name?.name !== 'style') return;
203
+ const expr = node.value?.type === 'JSXExpressionContainer' ? node.value.expression : null;
204
+ if (!expr || expr.type !== 'ObjectExpression') return;
205
+
206
+ const opening = node.parent;
207
+ const tagName =
208
+ opening?.type === 'JSXOpeningElement' && opening.name?.type === 'JSXIdentifier'
209
+ ? opening.name.name
210
+ : '';
211
+
212
+ for (const prop of expr.properties) {
213
+ if (prop.type !== 'Property' || prop.computed) continue;
214
+ const cssProp = stylePropertyName(prop.key);
215
+ const cssValue = staticStyleValue(prop.value);
216
+ if (!cssProp || cssValue == null) continue;
217
+
218
+ for (const item of lintDeclaration(cssProp, cssValue)) {
219
+ context.report({ node: prop, message: item.message });
220
+ }
221
+ if (tagName === 'Text' && isTextContainerDeclaration(cssProp, cssValue)) {
222
+ context.report({
223
+ node: prop,
224
+ message:
225
+ `Text 不是 Flex/Grid 容器,不要写 ${cssProp}: ${cssValue}。多个节点的排列由外层 View 承担。`,
226
+ });
227
+ }
228
+ }
229
+ },
230
+ };
231
+ },
232
+ };
233
+
234
+ const noMatchMediaRule = {
235
+ meta: {
236
+ type: 'problem',
237
+ docs: {
238
+ description: '禁止 window.matchMedia;Lynx 的 @media 不生效。',
239
+ },
240
+ schema: [],
241
+ },
242
+ create(context) {
243
+ function report(node) {
244
+ context.report({
245
+ node,
246
+ message:
247
+ '禁止 matchMedia。Lynx 的 @media 不生效;文字保持 DESIGN.md 的 px 基线,容器用 flex、vw/vh 适配。',
248
+ });
249
+ }
250
+ return {
251
+ CallExpression(node) {
252
+ const callee = node.callee;
253
+ if (callee.type === 'Identifier' && callee.name === 'matchMedia') {
254
+ report(node);
255
+ return;
256
+ }
257
+ if (
258
+ callee.type === 'MemberExpression' &&
259
+ !callee.computed &&
260
+ callee.property.type === 'Identifier' &&
261
+ callee.property.name === 'matchMedia'
262
+ ) {
263
+ report(node);
264
+ }
265
+ },
266
+ };
267
+ },
268
+ };
269
+
270
+ export default {
271
+ rules: {
272
+ 'no-app-entry': noAppEntryRule,
273
+ 'no-html-tags': noHtmlTagsRule,
274
+ 'no-nested-widget-regions': noNestedWidgetRegionsRule,
275
+ 'no-unsupported-lynx-style': noUnsupportedLynxStyleRule,
276
+ 'no-match-media': noMatchMediaRule,
277
+ },
278
+ };