@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.
- package/README.md +47 -0
- package/assets/schemas/custom-card-schema.json +102 -0
- package/bin/vibecoding +89 -0
- package/bin/vibecoding-dev +308 -0
- package/bin/vibecoding-interest-card.mjs +35 -0
- package/bin/vibecoding.mjs +9 -0
- package/package.json +58 -0
- package/src/adapters/interest-card/opencard/commands.mjs +62 -0
- package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
- package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
- package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
- package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
- package/src/adapters/interest-card/opencard/lint.sh +131 -0
- package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
- package/src/adapters/interest-card/opencard/pack.sh +27 -0
- package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
- package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
- package/src/adapters/interest-card/opencard/validate.mjs +11 -0
- package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
- package/src/build/opencard-environment.mjs +49 -0
- package/src/build/turbo-config.mjs +140 -0
- package/src/cli.mjs +104 -0
- package/src/commands/build.mjs +138 -0
- package/src/commands/db.mjs +14 -0
- package/src/commands/dev.mjs +319 -0
- package/src/commands/lint.mjs +176 -0
- package/src/commands/pack.mjs +169 -0
- package/src/commands/turbo.mjs +36 -0
- package/src/commands/validate.mjs +26 -0
- package/src/commands/verify.mjs +602 -0
- package/src/core/args.mjs +101 -0
- package/src/core/context.mjs +101 -0
- package/src/core/environment.mjs +55 -0
- package/src/core/errors.mjs +14 -0
- package/src/core/ports.mjs +76 -0
- package/src/core/process-output.mjs +25 -0
- package/src/core/runner.mjs +90 -0
- package/src/core/service-log-context.mjs +27 -0
- package/src/core/web-config.mjs +13 -0
- package/src/lint/lint-lynx-css.mjs +244 -0
- package/src/lint/lynx-css-rules.mjs +138 -0
- package/src/preview/dev-watch.mjs +38 -0
- package/src/preview/start-preview.mjs +213 -0
- package/src/preview/watch-card-routing.mjs +89 -0
- package/src/scripts/pack.mjs +15 -0
- package/src/validators/dependencies.mjs +34 -0
- package/src/validators/empty-project.mjs +29 -0
- package/src/validators/feed-card.cjs +156 -0
- package/src/validators/feed-card.mjs +32 -0
- package/src/validators/opencard-structure.mjs +58 -0
- package/src/validators/routes.cjs +470 -0
- package/src/validators/routes.mjs +17 -0
- package/src/validators/sql.mjs +57 -0
- package/src/validators/widget-shell.mjs +107 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { startPreview } from './start-preview.mjs'
|
|
2
|
+
|
|
3
|
+
// Standalone compatibility entry: preview only, without installing dependencies
|
|
4
|
+
// or starting the backend/database services owned by `vibecoding dev`.
|
|
5
|
+
/**
|
|
6
|
+
* @desc 启动仅前端预览的开发监听流程,不安装依赖或启动后端服务。
|
|
7
|
+
* @params {object} options 预览根目录、目标模式及可选退出回调。
|
|
8
|
+
* @return {Promise<void>} 预览进程结束后的 Promise。
|
|
9
|
+
* @example runDevWatch({ root: process.cwd(), cardMode: false })
|
|
10
|
+
*/
|
|
11
|
+
export async function runDevWatch(options) {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
let preview
|
|
14
|
+
let failure
|
|
15
|
+
let finish
|
|
16
|
+
const lifetime = new Promise((resolve) => { finish = resolve })
|
|
17
|
+
const stop = (error) => {
|
|
18
|
+
failure ??= error
|
|
19
|
+
controller.abort()
|
|
20
|
+
finish()
|
|
21
|
+
}
|
|
22
|
+
const onSignal = () => stop()
|
|
23
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
24
|
+
for (const signal of signals) process.once(signal, onSignal)
|
|
25
|
+
try {
|
|
26
|
+
preview = await startPreview({ ...options, signal: controller.signal, onExit: stop })
|
|
27
|
+
await lifetime
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!controller.signal.aborted) failure = error
|
|
30
|
+
} finally {
|
|
31
|
+
await preview?.close()
|
|
32
|
+
for (const signal of signals) process.removeListener(signal, onSignal)
|
|
33
|
+
}
|
|
34
|
+
if (failure) {
|
|
35
|
+
console.error(`[dev-watch] ${failure.message}`)
|
|
36
|
+
process.exitCode = 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { startRoutingPreview } from './watch-card-routing.mjs'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { CliError } from '../core/errors.mjs'
|
|
8
|
+
import { attachLabeledOutput } from '../core/process-output.mjs'
|
|
9
|
+
import { resolveSdkTaroEntry } from '../core/runner.mjs'
|
|
10
|
+
import { withServiceLogLabel } from '../core/service-log-context.mjs'
|
|
11
|
+
|
|
12
|
+
const META_PATH = '/opt/gem/ai_coding_app/.meta/ai_coding_app_meta.json'
|
|
13
|
+
const READY_TIMEOUT_MS = 5 * 60 * 1000
|
|
14
|
+
|
|
15
|
+
export function conversationId() {
|
|
16
|
+
try { return JSON.parse(readFileSync(META_PATH, 'utf8')).conversation_id ?? '' } catch { return '' }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadPreviewRunner(projectRoot, env = process.env) {
|
|
20
|
+
// Only PPE's explicit offline dependency overrides the platform runtime.
|
|
21
|
+
let offlineRunner = false
|
|
22
|
+
try {
|
|
23
|
+
const manifest = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf8'))
|
|
24
|
+
offlineRunner = manifest.devDependencies?.['@byted/microapp-preview-runner']?.startsWith('file:patches/') === true
|
|
25
|
+
} catch {}
|
|
26
|
+
const base = offlineRunner ? projectRoot : env.AI_CODING_BUILTIN_PACKAGES_DIR || projectRoot
|
|
27
|
+
const requireFromTarget = createRequire(path.join(path.resolve(base), 'package.json'))
|
|
28
|
+
let entry
|
|
29
|
+
try {
|
|
30
|
+
entry = requireFromTarget.resolve('@byted/microapp-preview-runner')
|
|
31
|
+
} catch {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
'Cannot resolve @byted/microapp-preview-runner. Set AI_CODING_BUILTIN_PACKAGES_DIR or install it in the project.',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return import(pathToFileURL(entry).href)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function waitForFile(file, label, { signal, timeoutMs = READY_TIMEOUT_MS, pollMs = 500 } = {}) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const deadline = Date.now() + timeoutMs
|
|
42
|
+
const finish = (error) => {
|
|
43
|
+
clearInterval(timer)
|
|
44
|
+
signal?.removeEventListener('abort', abort)
|
|
45
|
+
if (error) reject(error)
|
|
46
|
+
else resolve()
|
|
47
|
+
}
|
|
48
|
+
const abort = () => finish(new CliError('Preview startup cancelled'))
|
|
49
|
+
const timer = setInterval(() => {
|
|
50
|
+
if (existsSync(file)) {
|
|
51
|
+
finish()
|
|
52
|
+
} else if (Date.now() > deadline) {
|
|
53
|
+
finish(new CliError(`Timed out waiting for ${label}: ${file}`))
|
|
54
|
+
}
|
|
55
|
+
}, pollMs)
|
|
56
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
57
|
+
if (signal?.aborted) abort()
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveOpenCardPreviewUrl(root, env = process.env, id = conversationId()) {
|
|
62
|
+
const configured = env.OPEN_CARD_PREVIEW_URL?.trim()
|
|
63
|
+
if (configured) return configured
|
|
64
|
+
if (!existsSync(path.join(root, 'opencard/package.json'))) return undefined
|
|
65
|
+
const localCardPreview = `http://127.0.0.1:${resolvePreviewPort(env.OPENCARD_PORT, 10087)}`
|
|
66
|
+
return id
|
|
67
|
+
? `${localCardPreview}/api/apps/open_ai_coding_chat_server/aio_fe/${id}/card_preview`
|
|
68
|
+
: localCardPreview
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolvePreviewPort(value, fallback) {
|
|
72
|
+
const port = Number.parseInt(value ?? String(fallback), 10)
|
|
73
|
+
return Number.isNaN(port) ? fallback : port
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function killDetachedChild(child) {
|
|
77
|
+
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
|
|
78
|
+
return new Promise(resolve => {
|
|
79
|
+
child.once('exit', resolve)
|
|
80
|
+
try {
|
|
81
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
82
|
+
} catch {
|
|
83
|
+
try { if (!child.kill?.('SIGKILL')) resolve() } catch { resolve() }
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function startPreview(options) {
|
|
89
|
+
if (!options.cardMode) return startPreviewOnce(options)
|
|
90
|
+
let session
|
|
91
|
+
session = await startRoutingPreview({
|
|
92
|
+
root: options.root,
|
|
93
|
+
signal: options.signal,
|
|
94
|
+
onError: error => options.onExit ? options.onExit(error) : console.error(error),
|
|
95
|
+
start: (env, signal) => startPreviewOnce({ ...options, signal, env: { ...options.env, ...env },
|
|
96
|
+
onExit: error => { void session?.close(); options.onExit?.(error) },
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
return session
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function startPreviewOnce({
|
|
103
|
+
root,
|
|
104
|
+
cardMode = false,
|
|
105
|
+
env = {},
|
|
106
|
+
onExit,
|
|
107
|
+
label = cardMode ? 'opencard' : 'taro',
|
|
108
|
+
outputDir: requestedOutputDir,
|
|
109
|
+
cacheDir: requestedCacheDir,
|
|
110
|
+
spawnProcess = spawn,
|
|
111
|
+
loadPreviewRunnerFn = loadPreviewRunner,
|
|
112
|
+
waitForFileFn = waitForFile,
|
|
113
|
+
resolveSdkTaroEntryFn = resolveSdkTaroEntry,
|
|
114
|
+
killChildFn = killDetachedChild,
|
|
115
|
+
conversationIdFn = conversationId,
|
|
116
|
+
signal,
|
|
117
|
+
}) {
|
|
118
|
+
root = path.resolve(root)
|
|
119
|
+
env = { ...process.env, ...env }
|
|
120
|
+
const inputName = cardMode ? 'dist-tt' : 'dist'
|
|
121
|
+
const outputName = cardMode ? 'opencard-dist' : 'app-dist'
|
|
122
|
+
const port = cardMode
|
|
123
|
+
? resolvePreviewPort(env.OPENCARD_PORT, 10087)
|
|
124
|
+
: resolvePreviewPort(env.DEPLOY_RUN_PORT, 10086)
|
|
125
|
+
const route = cardMode ? 'card_preview' : 'preview'
|
|
126
|
+
const outputDir = requestedOutputDir ?? `/tmp/microapp_dist/${outputName}`
|
|
127
|
+
const cacheDir = requestedCacheDir ?? `/tmp/microapp_dist/${outputName}-cache`
|
|
128
|
+
const { startRspuckPreview } = await loadPreviewRunnerFn(root, env)
|
|
129
|
+
if (signal?.aborted) throw new CliError('Preview startup cancelled')
|
|
130
|
+
mkdirSync(path.join(root, '.home'), { recursive: true })
|
|
131
|
+
rmSync(path.join(root, inputName), { recursive: true, force: true })
|
|
132
|
+
rmSync(outputDir, { recursive: true, force: true })
|
|
133
|
+
|
|
134
|
+
const child = spawnProcess(cardMode ? process.execPath : 'pnpm', cardMode ? [
|
|
135
|
+
resolveSdkTaroEntryFn(root), 'build', '--type', 'tt', '--watch', '--mode', 'dev',
|
|
136
|
+
] : ['dev:tt'], {
|
|
137
|
+
cwd: root,
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
...env,
|
|
141
|
+
HOME: path.join(root, '.home'),
|
|
142
|
+
...(cardMode ? { TT_OUTPUT_ROOT: 'dist-tt' } : {}),
|
|
143
|
+
},
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
detached: true,
|
|
146
|
+
})
|
|
147
|
+
let starting = true
|
|
148
|
+
let cleaningUp = false
|
|
149
|
+
let rejectEarlyExit
|
|
150
|
+
const earlyExit = new Promise((_, reject) => { rejectEarlyExit = reject })
|
|
151
|
+
const waitController = new AbortController()
|
|
152
|
+
const abort = () => rejectEarlyExit(new CliError('Preview startup cancelled'))
|
|
153
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
154
|
+
if (signal?.aborted) abort()
|
|
155
|
+
attachLabeledOutput(child, label)
|
|
156
|
+
child.on('error', (error) => {
|
|
157
|
+
if (cleaningUp) return
|
|
158
|
+
onExit?.(error)
|
|
159
|
+
rejectEarlyExit(error)
|
|
160
|
+
})
|
|
161
|
+
child.on('exit', (code, signal) => {
|
|
162
|
+
if (cleaningUp) return
|
|
163
|
+
const error = code === 0 && !signal
|
|
164
|
+
? null
|
|
165
|
+
: new CliError(`taro watch exited (${signal ?? code})`)
|
|
166
|
+
onExit?.(error)
|
|
167
|
+
if (starting) {
|
|
168
|
+
rejectEarlyExit(error ?? new CliError('taro watch exited before preview was ready'))
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await Promise.race([
|
|
174
|
+
waitForFileFn(path.join(root, inputName, 'app.json'), `${inputName}/app.json`, { signal: waitController.signal }),
|
|
175
|
+
earlyExit,
|
|
176
|
+
])
|
|
177
|
+
const id = conversationIdFn()
|
|
178
|
+
const runtimePromise = withServiceLogLabel(label, () => startRspuckPreview({
|
|
179
|
+
projectRoot: root,
|
|
180
|
+
inputDir: path.join(root, inputName),
|
|
181
|
+
outputDir,
|
|
182
|
+
cacheDir,
|
|
183
|
+
port,
|
|
184
|
+
appid: env.APP_ID ?? process.env.APP_ID,
|
|
185
|
+
previewBasePath: id
|
|
186
|
+
? `/api/apps/open_ai_coding_chat_server/aio_fe/${id}/${route}`
|
|
187
|
+
: '',
|
|
188
|
+
readyTimeoutMs: READY_TIMEOUT_MS,
|
|
189
|
+
...(!cardMode ? { openCardPreviewUrl: resolveOpenCardPreviewUrl(root, env, id) } : {}),
|
|
190
|
+
...(!cardMode && env.OPEN_CARD_ID?.trim()
|
|
191
|
+
? { associatedCardAppIds: [env.OPEN_CARD_ID.trim()] }
|
|
192
|
+
: {}),
|
|
193
|
+
...(cardMode ? { cardMode: true } : {}),
|
|
194
|
+
})).then(async (runtime) => {
|
|
195
|
+
if (cleaningUp) await runtime.close().catch(() => {})
|
|
196
|
+
return runtime
|
|
197
|
+
})
|
|
198
|
+
const runtime = await Promise.race([runtimePromise, earlyExit])
|
|
199
|
+
starting = false
|
|
200
|
+
return { child, runtime, port, close: async () => {
|
|
201
|
+
cleaningUp = true
|
|
202
|
+
await killChildFn(child)
|
|
203
|
+
await runtime.close().catch(() => {})
|
|
204
|
+
} }
|
|
205
|
+
} catch (error) {
|
|
206
|
+
cleaningUp = true
|
|
207
|
+
await killChildFn(child)
|
|
208
|
+
throw error
|
|
209
|
+
} finally {
|
|
210
|
+
waitController.abort()
|
|
211
|
+
signal?.removeEventListener('abort', abort)
|
|
212
|
+
}
|
|
213
|
+
}
|