agentcss 0.25.6 → 0.27.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/01-tokens/README.md +1 -1
- package/01-tokens/behavior/labels.js +44 -0
- package/01-tokens/responsive-demo.html +1 -1
- package/01-tokens/tokens.core.css +1 -1
- package/01-tokens/tokens.css +2 -2
- package/02-primitives/combobox/combobox.js +26 -3
- package/02-primitives/date-range/date-range.js +28 -6
- package/02-primitives/popover/popover.js +22 -2
- package/02-primitives/select/select.js +23 -2
- package/02-primitives/timeline/demo.html +1 -1
- package/02-primitives/upload/upload.js +50 -17
- package/03-patterns/content/content.css +13 -1
- package/03-patterns/content/demo.html +1 -1
- package/03-patterns/drawer/demo.html +1 -1
- package/03-patterns/drawer/drawer.js +44 -6
- package/03-patterns/dropdown/dropdown.js +21 -1
- package/03-patterns/overlay/overlay.css +1 -1
- package/03-patterns/overlay/overlay.js +39 -2
- package/03-patterns/pagination/pagination.js +37 -10
- package/03-patterns/states/states.css +1 -1
- package/03-patterns/tooltip/tooltip.js +17 -2
- package/03-patterns/tree/tree.js +21 -1
- package/04-recipes/analysis-report/demo.html +1 -1
- package/04-recipes/app-shell/README.md +1 -1
- package/04-recipes/app-shell/demo.html +1 -1
- package/04-recipes/data-showcase/demo.html +2 -2
- package/04-recipes/longform/content.html +993 -78
- package/04-recipes/longform/longform.html +3 -3
- package/04-recipes/report/demo.html +3 -3
- package/04-recipes/table/README.md +23 -1
- package/04-recipes/table/demo.html +7 -7
- package/09-assets/bar/bar.css +1 -1
- package/09-assets/bar/bar.js +28 -4
- package/09-assets/model-viewer/model-viewer.js +45 -13
- package/09-assets/scientific-plot/demo.html +6 -6
- package/AGENTS.md +3 -1
- package/API.md +32 -0
- package/CHANGELOG.md +174 -5
- package/CONTRIBUTING.md +44 -3
- package/README.md +19 -0
- package/README.zh-CN.md +18 -0
- package/SECURITY.md +16 -4
- package/START-HERE.md +25 -18
- package/ai/START-HERE.md +40 -6
- package/ai/check-html.js +658 -0
- package/ai/cli.js +557 -67
- package/ai/components.json +9 -4
- package/ai/pdf.js +52 -10
- package/ai/recipes.json +86 -0
- package/ai/scaffold.js +195 -0
- package/ai/shot.js +345 -0
- package/ai/validate.js +65 -6
- package/dist/01-tokens/behavior/labels.js +11 -0
- package/dist/02-primitives/combobox/combobox.js +25 -3
- package/dist/02-primitives/date-range/date-range.js +26 -5
- package/dist/02-primitives/popover/popover.js +14 -1
- package/dist/02-primitives/select/select.js +22 -2
- package/dist/02-primitives/upload/upload.js +47 -17
- package/dist/03-patterns/content/content.css +1 -1
- package/dist/03-patterns/drawer/drawer.js +32 -4
- package/dist/03-patterns/dropdown/dropdown.js +20 -1
- package/dist/03-patterns/overlay/overlay.js +25 -1
- package/dist/03-patterns/pagination/pagination.js +34 -10
- package/dist/03-patterns/tooltip/tooltip.js +14 -2
- package/dist/03-patterns/tree/tree.js +20 -1
- package/dist/09-assets/bar/bar.js +23 -4
- package/dist/09-assets/model-viewer/model-viewer.js +38 -12
- package/dist/COSTS.md +20 -20
- package/docs/ACCESSIBILITY.md +3 -3
- package/docs/ARCHITECTURE.md +116 -0
- package/docs/L10N.md +92 -0
- package/docs/LAYER-BASELINE.md +2 -2
- package/docs/PRINT.md +3 -3
- package/docs/RELEASE-PROCESS.md +59 -3
- package/docs/STABILITY.md +1 -1
- package/docs/VERIFY-RELEASE.md +24 -2
- package/index.html +3 -3
- package/llms-full.txt +18 -4
- package/llms.txt +26 -5
- package/package.json +4 -2
- package/types/index.d.ts +218 -13
package/ai/shot.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* shot.js — 把页面截成 PNG(给定宽度、可选整页)
|
|
3
|
+
*
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* 🔴 为什么不用 `chrome --screenshot --window-size=390,844`
|
|
6
|
+
* ----------------------------------------------------------------------------
|
|
7
|
+
* 因为它**会撒谎**。Windows 上窗口有最小宽度,`--window-size=390` 拿到的
|
|
8
|
+
* 实际视口是 **500px**(实测 innerWidth=500),而 `--screenshot` 只按 390 裁一刀。
|
|
9
|
+
* 结果是一张"看起来是 390 宽"的图:文字在右边缘被切掉、媒体查询按 500 生效 ——
|
|
10
|
+
* 你据此判断"窄屏没问题",其实从没看过 390。
|
|
11
|
+
*
|
|
12
|
+
* 这类失败最贵的地方在于**它不报错**:图是好的、能打开、只是内容不对。
|
|
13
|
+
* 所以这里走 Chrome 的调试管道(`--remote-debugging-pipe`,零依赖、不开端口),
|
|
14
|
+
* 用 `Emulation.setDeviceMetricsOverride` 把视口**设成**要的那个数,再截。
|
|
15
|
+
*
|
|
16
|
+
* ============================================================================
|
|
17
|
+
* 为什么是管道而不是 `--remote-debugging-port`
|
|
18
|
+
* ----------------------------------------------------------------------------
|
|
19
|
+
* 端口版要处理:端口占用、防火墙弹窗(Windows 首次监听会弹)、启动竞态
|
|
20
|
+
* (等调试端口文件出现的时机)、以及同一个 profile 被复用时的锁。
|
|
21
|
+
* 管道版把这些全绕开:读写走子进程的 fd 3/4,消息以 `\0` 分隔的 JSON。
|
|
22
|
+
* 代价是自己拼十几行帧处理 —— 比处理上面四件事便宜得多。
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
* 它**不是**视觉基线
|
|
26
|
+
* ----------------------------------------------------------------------------
|
|
27
|
+
* 基线(逐字节比对)由 `05-audit/shot-baseline.py` 管,那套要考虑字体、
|
|
28
|
+
* 双平台产物与指纹。这里的产物是给人/给 Agent **看一眼**的:
|
|
29
|
+
* 同一台机器、同样参数,两次逐字节一致(门禁验的就是这条);
|
|
30
|
+
* 换一台机器不保证 —— 字体光栅化不一样,别拿它当跨机器判据。
|
|
31
|
+
*/
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
var fs = require('fs');
|
|
35
|
+
var os = require('os');
|
|
36
|
+
var path = require('path');
|
|
37
|
+
var { spawn } = require('child_process');
|
|
38
|
+
var { pathToFileURL } = require('url');
|
|
39
|
+
var pdf = require('./pdf.js');
|
|
40
|
+
|
|
41
|
+
var DEFAULT_WIDTHS = [1440];
|
|
42
|
+
var DEFAULT_HEIGHT = 900;
|
|
43
|
+
var DEFAULT_WAIT = 800;
|
|
44
|
+
/* 整页截图的高度上限。PNG 面积是 W×H×scale²,超了内存与耗时都会失控;
|
|
45
|
+
截断**不静默**:结果里带 truncatedAt,人/Agent 能看见。 */
|
|
46
|
+
var MAX_FULL_HEIGHT = 10000;
|
|
47
|
+
|
|
48
|
+
/** 极简 CDP 客户端:一个子进程、两个 fd、以 \0 分隔的 JSON。 */
|
|
49
|
+
function connect(chromePath, profile) {
|
|
50
|
+
var child = spawn(chromePath, [
|
|
51
|
+
'--headless=new',
|
|
52
|
+
'--disable-gpu',
|
|
53
|
+
'--disable-extensions',
|
|
54
|
+
'--no-first-run',
|
|
55
|
+
'--no-default-browser-check',
|
|
56
|
+
/* 滚动条会占掉 15px 视口,且它本身不是页面内容 */
|
|
57
|
+
'--hide-scrollbars',
|
|
58
|
+
'--remote-debugging-pipe',
|
|
59
|
+
'--user-data-dir=' + profile,
|
|
60
|
+
'about:blank'
|
|
61
|
+
], {
|
|
62
|
+
/* fd 0/1/2 给普通输出,3 = 命令(Chrome 读),4 = 事件(Chrome 写) */
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
64
|
+
windowsHide: true
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
var buf = Buffer.alloc(0);
|
|
68
|
+
var pending = Object.create(null);
|
|
69
|
+
var waiters = [];
|
|
70
|
+
var listeners = [];
|
|
71
|
+
var nextId = 0;
|
|
72
|
+
var dead = null;
|
|
73
|
+
|
|
74
|
+
function dispatch(msg) {
|
|
75
|
+
if (msg.id !== undefined && pending[msg.id]) {
|
|
76
|
+
var p = pending[msg.id];
|
|
77
|
+
delete pending[msg.id];
|
|
78
|
+
clearTimeout(p.timer);
|
|
79
|
+
if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
|
|
80
|
+
else p.resolve(msg.result);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!msg.method) return;
|
|
84
|
+
for (var L = 0; L < listeners.length; L++) {
|
|
85
|
+
if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
|
|
86
|
+
}
|
|
87
|
+
for (var i = waiters.length - 1; i >= 0; i--) {
|
|
88
|
+
var w = waiters[i];
|
|
89
|
+
if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
|
|
90
|
+
waiters.splice(i, 1);
|
|
91
|
+
clearTimeout(w.timer);
|
|
92
|
+
w.resolve(msg.params);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
child.stdio[4].on('data', function (chunk) {
|
|
98
|
+
buf = Buffer.concat([buf, chunk]);
|
|
99
|
+
var i;
|
|
100
|
+
while ((i = buf.indexOf(0)) >= 0) {
|
|
101
|
+
var raw = buf.subarray(0, i).toString('utf8');
|
|
102
|
+
buf = buf.subarray(i + 1);
|
|
103
|
+
var msg = null;
|
|
104
|
+
try { msg = JSON.parse(raw); } catch (e) { continue; }
|
|
105
|
+
dispatch(msg);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
child.stdio[1].on('data', function () { /* 吞掉:Chrome 的正常输出没有信息量 */ });
|
|
109
|
+
child.stdio[2].on('data', function () { /* 同上;错误通过 CDP 的 error 字段回来 */ });
|
|
110
|
+
child.on('error', function (e) { dead = e; });
|
|
111
|
+
child.on('exit', function (code) {
|
|
112
|
+
dead = dead || new Error('Chrome 提前退出(退出码 ' + code + ')');
|
|
113
|
+
Object.keys(pending).forEach(function (k) {
|
|
114
|
+
clearTimeout(pending[k].timer);
|
|
115
|
+
pending[k].reject(dead);
|
|
116
|
+
});
|
|
117
|
+
pending = Object.create(null);
|
|
118
|
+
waiters.splice(0).forEach(function (w) { clearTimeout(w.timer); w.reject(dead); });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function send(method, params, sessionId, timeoutMs) {
|
|
122
|
+
return new Promise(function (resolve, reject) {
|
|
123
|
+
if (dead) return reject(dead);
|
|
124
|
+
var id = ++nextId;
|
|
125
|
+
var timer = setTimeout(function () {
|
|
126
|
+
delete pending[id];
|
|
127
|
+
reject(new Error(method + ' 超时(' + (timeoutMs || 15000) + 'ms 没有回应)'));
|
|
128
|
+
}, timeoutMs || 15000);
|
|
129
|
+
pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
|
|
130
|
+
child.stdio[3].write(JSON.stringify({
|
|
131
|
+
id: id, method: method, params: params || {}, sessionId: sessionId
|
|
132
|
+
}) + '\0');
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function waitEvent(method, sessionId, timeoutMs) {
|
|
137
|
+
return new Promise(function (resolve, reject) {
|
|
138
|
+
var timer = setTimeout(function () {
|
|
139
|
+
var at = waiters.findIndex(function (w) { return w.method === method && w.sessionId === sessionId; });
|
|
140
|
+
if (at >= 0) waiters.splice(at, 1);
|
|
141
|
+
reject(new Error('等 ' + method + ' 超时(' + (timeoutMs || 15000) + 'ms)'));
|
|
142
|
+
}, timeoutMs || 15000);
|
|
143
|
+
waiters.push({ method: method, sessionId: sessionId, resolve: resolve, reject: reject, timer: timer });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
send: send, waitEvent: waitEvent,
|
|
149
|
+
/* 常驻监听:异常/控制台消息可能在任意时刻到达,用一次性 waiter 会漏掉第二条起的所有 */
|
|
150
|
+
on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
|
|
151
|
+
close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
|
|
152
|
+
child: child
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 页面在某个宽度下的量:溢出多少、谁溢出、有多高。 */
|
|
157
|
+
var PROBE_JS = [
|
|
158
|
+
'(function(){',
|
|
159
|
+
' var d = document.documentElement;',
|
|
160
|
+
' var vw = d.clientWidth;',
|
|
161
|
+
' var over = [];',
|
|
162
|
+
' var all = document.querySelectorAll("body *");',
|
|
163
|
+
' for (var i = 0; i < all.length && over.length < 40; i++) {',
|
|
164
|
+
' var e = all[i], r = e.getBoundingClientRect();',
|
|
165
|
+
' if (r.width === 0 && r.height === 0) continue;',
|
|
166
|
+
' if (r.right > vw + 1 || r.left < -1) {',
|
|
167
|
+
' var cls = typeof e.className === "string" ? e.className : "";',
|
|
168
|
+
' over.push(e.tagName.toLowerCase() + (cls ? "." + cls.trim().split(/\\s+/).join(".") : "")',
|
|
169
|
+
' + " @" + Math.round(r.right));',
|
|
170
|
+
' }',
|
|
171
|
+
' }',
|
|
172
|
+
' return JSON.stringify({',
|
|
173
|
+
' viewport: vw, scrollWidth: d.scrollWidth, scrollHeight: d.scrollHeight,',
|
|
174
|
+
' overflow: over.slice(0, 6), overflowCount: over.length',
|
|
175
|
+
' });',
|
|
176
|
+
'})()'
|
|
177
|
+
].join('\n');
|
|
178
|
+
|
|
179
|
+
function slugWidth(w) { return String(w); }
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {object} o
|
|
183
|
+
* @param {string} o.src 本地 HTML 路径或 http(s) URL
|
|
184
|
+
* @param {string} o.out 输出文件;多宽度时须含 `{width}`
|
|
185
|
+
* @param {number[]} [o.widths] 视口宽度,默认 [1440]
|
|
186
|
+
* @param {number} [o.height] 视口高度,默认 900
|
|
187
|
+
* @param {boolean} [o.full] 整页(否则只截首屏)
|
|
188
|
+
* @param {number} [o.scale] 设备像素比,默认 1
|
|
189
|
+
* @param {number} [o.wait] 加载后额外等待(毫秒),默认 800
|
|
190
|
+
* @param {string} [o.chrome]
|
|
191
|
+
* @param {number} [o.timeoutMs]
|
|
192
|
+
* @returns {Promise<object>}
|
|
193
|
+
*/
|
|
194
|
+
async function shoot(o) {
|
|
195
|
+
o = o || {};
|
|
196
|
+
var t0 = Date.now();
|
|
197
|
+
if (!o.src) return { ok: false, code: 'usage', message: '缺少 src' };
|
|
198
|
+
if (!o.out) return { ok: false, code: 'usage', message: '缺少 out' };
|
|
199
|
+
|
|
200
|
+
var widths = o.widths && o.widths.length ? o.widths : DEFAULT_WIDTHS;
|
|
201
|
+
for (var i = 0; i < widths.length; i++) {
|
|
202
|
+
if (!Number.isInteger(widths[i]) || widths[i] < 200 || widths[i] > 4000) {
|
|
203
|
+
return { ok: false, code: 'usage',
|
|
204
|
+
message: '宽度必须是 200–4000 的整数,收到 ' + JSON.stringify(widths[i]) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
var height = o.height || DEFAULT_HEIGHT;
|
|
208
|
+
if (!Number.isInteger(height) || height < 200 || height > 4000) {
|
|
209
|
+
return { ok: false, code: 'usage',
|
|
210
|
+
message: '高度必须是 200–4000 的整数,收到 ' + JSON.stringify(o.height) };
|
|
211
|
+
}
|
|
212
|
+
var scale = o.scale === undefined ? 1 : Number(o.scale);
|
|
213
|
+
if (!(scale > 0 && scale <= 3)) {
|
|
214
|
+
return { ok: false, code: 'usage', message: '--scale 必须在 (0, 3],收到 ' + JSON.stringify(o.scale) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var remote = /^https?:\/\//i.test(o.src);
|
|
218
|
+
if (!remote) {
|
|
219
|
+
var srcAbs = path.resolve(o.src);
|
|
220
|
+
if (!fs.existsSync(srcAbs)) return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
|
|
221
|
+
o.src = srcAbs;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var outTpl = String(o.out);
|
|
225
|
+
if (widths.length > 1 && outTpl.indexOf('{width}') < 0) {
|
|
226
|
+
return { ok: false, code: 'usage',
|
|
227
|
+
message: '截多个宽度时 --out 必须含 {width}(例:shot-{width}.png),'
|
|
228
|
+
+ '否则 ' + widths.length + ' 张图会写进同一个文件,最后只剩一张' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
var chrome = pdf.resolveChrome(o.chrome);
|
|
232
|
+
if (chrome.error) return { ok: false, code: 'no-chrome', message: chrome.error };
|
|
233
|
+
|
|
234
|
+
var profile = fs.mkdtempSync(path.join(os.tmpdir(), 'agentcss-shot-'));
|
|
235
|
+
var cdp = connect(chrome.path, profile);
|
|
236
|
+
var shots = [];
|
|
237
|
+
var consoleErrors = [];
|
|
238
|
+
var budget = o.timeoutMs || 90000;
|
|
239
|
+
var deadline = Date.now() + budget;
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
var target = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
243
|
+
var attached = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
|
|
244
|
+
var sid = attached.sessionId;
|
|
245
|
+
|
|
246
|
+
await cdp.send('Page.enable', {}, sid);
|
|
247
|
+
await cdp.send('Runtime.enable', {}, sid);
|
|
248
|
+
/* JS 异常要收集:截出一张"布局正确但内容没渲染"的图是最坏的产物,
|
|
249
|
+
而它通常就是一行 TypeError 造成的。 */
|
|
250
|
+
cdp.on('Runtime.exceptionThrown', function (p) {
|
|
251
|
+
if (consoleErrors.length >= 20) return;
|
|
252
|
+
var d = (p && p.exceptionDetails) || {};
|
|
253
|
+
var txt = (d.exception && d.exception.description) || d.text || '未捕获异常';
|
|
254
|
+
consoleErrors.push(String(txt).split('\n')[0]);
|
|
255
|
+
});
|
|
256
|
+
cdp.on('Runtime.consoleAPICalled', function (p) {
|
|
257
|
+
if (consoleErrors.length >= 20) return;
|
|
258
|
+
if (!p || p.type !== 'error') return;
|
|
259
|
+
var parts = (p.args || []).map(function (a) {
|
|
260
|
+
return a.value === undefined ? (a.description || a.type) : String(a.value);
|
|
261
|
+
});
|
|
262
|
+
consoleErrors.push('console.error: ' + parts.join(' '));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
for (var w = 0; w < widths.length; w++) {
|
|
266
|
+
if (Date.now() > deadline) {
|
|
267
|
+
return { ok: false, code: 'timeout', message: '超出时间预算 ' + budget + 'ms', chrome: chrome.path, shots: shots };
|
|
268
|
+
}
|
|
269
|
+
var width = widths[w];
|
|
270
|
+
var w0 = Date.now();
|
|
271
|
+
|
|
272
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
273
|
+
width: width, height: height, deviceScaleFactor: scale, mobile: false
|
|
274
|
+
}, sid);
|
|
275
|
+
|
|
276
|
+
var loaded = cdp.waitEvent('Page.loadEventFired', sid, 30000);
|
|
277
|
+
var nav = await cdp.send('Page.navigate', { url: remote ? o.src : pathToFileURL(o.src).href }, sid);
|
|
278
|
+
if (nav && nav.errorText) {
|
|
279
|
+
return { ok: false, code: 'render',
|
|
280
|
+
message: '页面加载失败(' + nav.errorText + '):' + o.src, chrome: chrome.path, shots: shots };
|
|
281
|
+
}
|
|
282
|
+
await loaded;
|
|
283
|
+
if (o.wait) await new Promise(function (r) { setTimeout(r, o.wait); });
|
|
284
|
+
|
|
285
|
+
var probe = await cdp.send('Runtime.evaluate', {
|
|
286
|
+
expression: PROBE_JS, returnByValue: true
|
|
287
|
+
}, sid);
|
|
288
|
+
var info = {};
|
|
289
|
+
try { info = JSON.parse(probe.result.value); } catch (e) { info = {}; }
|
|
290
|
+
|
|
291
|
+
var clipH = height;
|
|
292
|
+
var truncated = null;
|
|
293
|
+
if (o.full) {
|
|
294
|
+
var want = info.scrollHeight || height;
|
|
295
|
+
clipH = Math.min(want, MAX_FULL_HEIGHT);
|
|
296
|
+
if (want > MAX_FULL_HEIGHT) truncated = want;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
var shot = await cdp.send('Page.captureScreenshot', {
|
|
300
|
+
format: 'png',
|
|
301
|
+
captureBeyondViewport: !!o.full,
|
|
302
|
+
clip: { x: 0, y: 0, width: width, height: clipH, scale: 1 }
|
|
303
|
+
}, sid);
|
|
304
|
+
var png = Buffer.from(shot.data, 'base64');
|
|
305
|
+
if (png.length < 8 || png.readUInt32BE(0) !== 0x89504e47) {
|
|
306
|
+
return { ok: false, code: 'render', message: '产出的不是 PNG(' + png.length + ' 字节)',
|
|
307
|
+
chrome: chrome.path, shots: shots };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var outFile = path.resolve(outTpl.replace(/\{width\}/g, slugWidth(width)));
|
|
311
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
312
|
+
fs.writeFileSync(outFile, png);
|
|
313
|
+
|
|
314
|
+
shots.push({
|
|
315
|
+
width: width, out: outFile, bytes: png.length, ms: Date.now() - w0,
|
|
316
|
+
png: { width: png.readUInt32BE(16), height: png.readUInt32BE(20) },
|
|
317
|
+
full: !!o.full,
|
|
318
|
+
truncatedAt: truncated,
|
|
319
|
+
viewport: info.viewport === undefined ? null : info.viewport,
|
|
320
|
+
scrollWidth: info.scrollWidth === undefined ? null : info.scrollWidth,
|
|
321
|
+
scrollHeight: info.scrollHeight === undefined ? null : info.scrollHeight,
|
|
322
|
+
overflow: info.overflow || [],
|
|
323
|
+
overflowCount: info.overflowCount || 0
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
ok: true, code: null, message: null, chrome: chrome.path,
|
|
329
|
+
scale: scale, wait: o.wait === undefined ? DEFAULT_WAIT : o.wait,
|
|
330
|
+
shots: shots, consoleErrors: consoleErrors, ms: Date.now() - t0
|
|
331
|
+
};
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return { ok: false, code: 'render', message: String(e && e.message || e), chrome: chrome.path, shots: shots };
|
|
334
|
+
} finally {
|
|
335
|
+
cdp.close();
|
|
336
|
+
/* 清理必须**在进程退出前**做完:CLI 随后就 exit,挂 setTimeout 等于每截一张图
|
|
337
|
+
就在系统临时目录里留一个 Chrome profile(pdf.js 那边实测漏过 6 个)。
|
|
338
|
+
先等一下再删:Windows 上子进程刚 kill 时还握着文件句柄。 */
|
|
339
|
+
await new Promise(function (r) { setTimeout(r, 120); });
|
|
340
|
+
try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 }); }
|
|
341
|
+
catch (e) { /* 偶发 EBUSY,交给系统清理 */ }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = { shoot: shoot, DEFAULT_WIDTHS: DEFAULT_WIDTHS, DEFAULT_HEIGHT: DEFAULT_HEIGHT, DEFAULT_WAIT: DEFAULT_WAIT };
|
package/ai/validate.js
CHANGED
|
@@ -32,7 +32,11 @@ var path = require('path');
|
|
|
32
32
|
var AI_DIR = __dirname;
|
|
33
33
|
|
|
34
34
|
// ---------------------------------------------------------------- 能力白名单
|
|
35
|
-
// ⚠️ 改这个表 =
|
|
35
|
+
// ⚠️ 改这个表 = 改工具的能力边界。
|
|
36
|
+
// 🔴 表里有的关键字**必须真的实现**:白名单只负责"允许出现",
|
|
37
|
+
// 不负责"校验它"。两者不一致 ⇒ schema 里写了、校验器当没看见 ⇒ **静默假绿**。
|
|
38
|
+
// (本轮审计 F4:`uniqueItems` / `not` 在表里挂了很久但没实现,
|
|
39
|
+
// `validate({type:'array',uniqueItems:true},[1,1])` 返回 ok:true。)
|
|
36
40
|
var SUPPORTED_KEYWORDS = [
|
|
37
41
|
'$schema', '$id', '$comment', '$ref', 'definitions', 'title',
|
|
38
42
|
'type', 'required', 'properties', 'additionalProperties', 'patternProperties',
|
|
@@ -57,15 +61,30 @@ function isPlainObject(v) {
|
|
|
57
61
|
*/
|
|
58
62
|
// 哪些键的"值"本身还是 schema
|
|
59
63
|
var SUBSCHEMA_KEYS = [
|
|
60
|
-
'items', 'additionalProperties', '
|
|
64
|
+
'items', 'additionalProperties', 'allOf', 'anyOf',
|
|
61
65
|
'oneOf', 'not', 'propertyNames', 'contains', 'if', 'then', 'else'
|
|
62
66
|
];
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
/* 哪些键是"名字 → schema"的容器:它们的**键**不是关键字。
|
|
68
|
+
🔴 `patternProperties` 必须在这里,不能当普通子 schema ——
|
|
69
|
+
它的键是**正则**(`^--[a-z][a-z0-9-]*$`),
|
|
70
|
+
当成关键字检查就会报"用了没实现的关键字",而 schema 本身完全合法。
|
|
71
|
+
这个错 0.26.0 就有,只是预扫当时只覆盖 4 份文件、碰巧都没用 patternProperties
|
|
72
|
+
⇒ 直到本轮把预扫扩到全部 8 份才暴露(`tokens.audience` 第一个撞上)。 */
|
|
73
|
+
var CONTAINER_KEYS = ['properties', 'definitions', '$defs', 'dependentSchemas',
|
|
74
|
+
'patternProperties'];
|
|
75
|
+
|
|
76
|
+
/* 扫哪些文件:**目录里现找**,不手写清单。
|
|
77
|
+
手写清单必漂 —— 0.26.0 时 `ai/` 下有 8 份 schema,这里只列了 4 份
|
|
78
|
+
(behaviors / deprecations / tokensAudience / tokensTree 从来没被扫过),
|
|
79
|
+
于是那四份里出现未知关键字也不会有人知道。 */
|
|
80
|
+
function schemaFiles() {
|
|
81
|
+
return fs.readdirSync(AI_DIR).filter(function (f) {
|
|
82
|
+
return /\.schema\.json$/.test(f);
|
|
83
|
+
}).sort();
|
|
84
|
+
}
|
|
65
85
|
|
|
66
86
|
function assertSchemasUseOnlySupported() {
|
|
67
|
-
var files =
|
|
68
|
-
'tokens.schema.json', 'patch.schema.json'];
|
|
87
|
+
var files = schemaFiles();
|
|
69
88
|
var unknown = [];
|
|
70
89
|
|
|
71
90
|
function checkNode(schema, f, ptr) {
|
|
@@ -118,6 +137,27 @@ function typeOk(expected, value) {
|
|
|
118
137
|
return true;
|
|
119
138
|
}
|
|
120
139
|
|
|
140
|
+
/* JSON Schema 的"相等"是**深比较**:`{a:1}` 与 `{a:1}` 是两个对象但相等。
|
|
141
|
+
用 `===` 判重会把重复项全放过,而且是静默的。 */
|
|
142
|
+
function deepEqual(a, b) {
|
|
143
|
+
if (a === b) return true;
|
|
144
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
145
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
146
|
+
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
150
|
+
var ka = Object.keys(a), kb = Object.keys(b);
|
|
151
|
+
if (ka.length !== kb.length) return false;
|
|
152
|
+
for (var j = 0; j < ka.length; j++) {
|
|
153
|
+
if (!Object.prototype.hasOwnProperty.call(b, ka[j])) return false;
|
|
154
|
+
if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
function resolveRef(root, ref) {
|
|
122
162
|
if (ref.charAt(0) !== '#' || ref.indexOf('#/') !== 0) {
|
|
123
163
|
throw new Error('只支持文档内 $ref:' + ref);
|
|
@@ -178,6 +218,18 @@ function validate(schema, data, root, ptr, errors) {
|
|
|
178
218
|
if (schema.maxItems !== undefined && data.length > schema.maxItems) {
|
|
179
219
|
errors.push({ path: ptr, msg: '元素多于 ' + schema.maxItems + ' 个' });
|
|
180
220
|
}
|
|
221
|
+
/* `uniqueItems: true` = 元素两两不相等。O(n²) 深比较 —— 这里的数据都是
|
|
222
|
+
几十个以内,可读性比复杂度重要;真要上千个再换哈希。 */
|
|
223
|
+
if (schema.uniqueItems === true) {
|
|
224
|
+
for (var ui = 0; ui < data.length; ui++) {
|
|
225
|
+
for (var uj = ui + 1; uj < data.length; uj++) {
|
|
226
|
+
if (deepEqual(data[ui], data[uj])) {
|
|
227
|
+
errors.push({ path: ptr + '/' + uj,
|
|
228
|
+
msg: '第 ' + uj + ' 个元素与第 ' + ui + ' 个重复(uniqueItems)' });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
181
233
|
if (schema.items) {
|
|
182
234
|
data.forEach(function (v, i) {
|
|
183
235
|
validate(schema.items, v, root, ptr + '/' + i, errors);
|
|
@@ -228,6 +280,13 @@ function validate(schema, data, root, ptr, errors) {
|
|
|
228
280
|
errors.push({ path: ptr, msg: '必须恰好满足 oneOf 中的一条,实际满足 ' + n + ' 条' });
|
|
229
281
|
}
|
|
230
282
|
}
|
|
283
|
+
/* `not`:**必须不**满足里面的约束。子校验用独立的 errors 数组 ——
|
|
284
|
+
借用外层数组的话,"不满足 not" 会被记成"满足了"的错,方向正好反了。 */
|
|
285
|
+
if (schema.not !== undefined) {
|
|
286
|
+
if (validate(schema.not, data, root, ptr, []).length === 0) {
|
|
287
|
+
errors.push({ path: ptr, msg: '不应满足 not 里的约束(实际满足了)' });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
231
290
|
return errors;
|
|
232
291
|
}
|
|
233
292
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
var flLabels = function (defaults, src) {
|
|
2
|
+
src = src || {};
|
|
3
|
+
var out = {};
|
|
4
|
+
var k;
|
|
5
|
+
for (k in defaults) {
|
|
6
|
+
if (Object.prototype.hasOwnProperty.call(defaults, k)) {
|
|
7
|
+
out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
};
|
|
@@ -119,6 +119,28 @@ return true;
|
|
|
119
119
|
var uid = 0;
|
|
120
120
|
|
|
121
121
|
function create(root, opt) {
|
|
122
|
+
|
|
123
|
+
var DEFAULT_LABELS = {
|
|
124
|
+
suggest: '建议',
|
|
125
|
+
input: '输入',
|
|
126
|
+
noMatch: function (q) { return '没有匹配「' + q + '」的选项'; },
|
|
127
|
+
empty: '暂无可选'
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
var flLabels = function (defaults, src) {
|
|
131
|
+
src = src || {};
|
|
132
|
+
var out = {};
|
|
133
|
+
var k;
|
|
134
|
+
for (k in defaults) {
|
|
135
|
+
if (Object.prototype.hasOwnProperty.call(defaults, k)) {
|
|
136
|
+
out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
|
|
143
|
+
|
|
122
144
|
opt = opt || {};
|
|
123
145
|
var input = root.querySelector('[data-combo-input]');
|
|
124
146
|
var list = root.querySelector('[data-combo-list]');
|
|
@@ -147,14 +169,14 @@ var activeIdx = -1;
|
|
|
147
169
|
var shown = [];
|
|
148
170
|
|
|
149
171
|
list.setAttribute('role', 'listbox');
|
|
150
|
-
list.setAttribute('aria-label', opt.label ||
|
|
172
|
+
list.setAttribute('aria-label', opt.label || L.suggest);
|
|
151
173
|
input.setAttribute('role', 'combobox');
|
|
152
174
|
input.setAttribute('aria-autocomplete', 'list');
|
|
153
175
|
input.setAttribute('aria-expanded', 'false');
|
|
154
176
|
input.setAttribute('aria-controls', list.id);
|
|
155
177
|
if (!input.id) input.id = 'combo-input-' + seq;
|
|
156
178
|
if (!input.getAttribute('aria-label') && !root.querySelector('label[for="' + input.id + '"]')) {
|
|
157
|
-
input.setAttribute('aria-label', opt.label ||
|
|
179
|
+
input.setAttribute('aria-label', opt.label || L.input);
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
function esc(s) {
|
|
@@ -190,7 +212,7 @@ return String(s).replace(/[&<>"]/g, function (c) {
|
|
|
190
212
|
if (!shown.length) {
|
|
191
213
|
/* @html-sink escaped — 插值只有 q,且过了 esc() */
|
|
192
214
|
list.innerHTML = '<li class="combo__empty" role="presentation">' +
|
|
193
|
-
(q ?
|
|
215
|
+
(q ? L.noMatch(esc(q)) : L.empty) + '</li>';
|
|
194
216
|
return;
|
|
195
217
|
}
|
|
196
218
|
var ql = q.toLowerCase();
|
|
@@ -94,6 +94,27 @@ return { from: iso(start), to: iso(end) };
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function create(root, opt) {
|
|
97
|
+
|
|
98
|
+
var flLabels = function (defaults, src) {
|
|
99
|
+
src = src || {};
|
|
100
|
+
var out = {};
|
|
101
|
+
var k;
|
|
102
|
+
for (k in defaults) {
|
|
103
|
+
if (Object.prototype.hasOwnProperty.call(defaults, k)) {
|
|
104
|
+
out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
var DEFAULT_LABELS = {
|
|
111
|
+
requiredEnd: '请填写结束日期。',
|
|
112
|
+
requiredStart: '请填写开始日期。',
|
|
113
|
+
order: function (f, t) { return '开始日期不能晚于结束日期(当前:' + f + ' → ' + t + ')。'; },
|
|
114
|
+
min: function (min) { return '开始日期不能早于 ' + min + '。'; },
|
|
115
|
+
max: function (max) { return '结束日期不能晚于 ' + max + '。'; }
|
|
116
|
+
};
|
|
117
|
+
var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
|
|
97
118
|
opt = opt || {};
|
|
98
119
|
var from = root.querySelector('[data-dr-from]');
|
|
99
120
|
var to = root.querySelector('[data-dr-to]');
|
|
@@ -132,14 +153,14 @@ return Math.round((b - a) / 86400000) + 1;
|
|
|
132
153
|
function validate() {
|
|
133
154
|
var f = from.value, t = to.value;
|
|
134
155
|
if (!f && !t) { setError(''); paintSummary(''); return true; }
|
|
135
|
-
if (f && !t) { setError(
|
|
136
|
-
if (!f && t) { setError(
|
|
156
|
+
if (f && !t) { setError(L.requiredEnd); return false; }
|
|
157
|
+
if (!f && t) { setError(L.requiredStart); return false; }
|
|
137
158
|
if (f > t) {
|
|
138
|
-
setError(
|
|
159
|
+
setError(L.order(f, t));
|
|
139
160
|
return false;
|
|
140
161
|
}
|
|
141
|
-
if (opt.min && f < opt.min) { setError(
|
|
142
|
-
if (opt.max && t > opt.max) { setError(
|
|
162
|
+
if (opt.min && f < opt.min) { setError(L.min(opt.min)); return false; }
|
|
163
|
+
if (opt.max && t > opt.max) { setError(L.max(opt.max)); return false; }
|
|
143
164
|
setError('');
|
|
144
165
|
paintSummary(f + ' → ' + t + '(' + days(f, t) + ' 天)');
|
|
145
166
|
return true;
|
|
@@ -148,10 +148,11 @@ box.setAttribute('inert', '');
|
|
|
148
148
|
|
|
149
149
|
box.setAttribute('role', 'dialog');
|
|
150
150
|
box.setAttribute('aria-label', opts.label || '');
|
|
151
|
+
var bodyHtml = sanitizeHtml(opts.html || '', opts.sanitize);
|
|
151
152
|
|
|
152
153
|
box.innerHTML =
|
|
153
154
|
'<span class="popover__arrow" aria-hidden="true"></span>'+
|
|
154
|
-
'<div class="popover__body">'+
|
|
155
|
+
'<div class="popover__body">'+ bodyHtml + '</div>';
|
|
155
156
|
|
|
156
157
|
positionArrow(box, trigger);
|
|
157
158
|
anchor.appendChild(box);
|
|
@@ -230,6 +231,17 @@ el: box,
|
|
|
230
231
|
return api;
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
var SANITIZER = null;
|
|
235
|
+
|
|
236
|
+
function setSanitizer(fn) {
|
|
237
|
+
SANITIZER = (typeof fn === 'function') ? fn : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sanitizeHtml(html, perCall) {
|
|
241
|
+
var fn = (typeof perCall === 'function') ? perCall : SANITIZER;
|
|
242
|
+
return fn ? String(fn(String(html))) : String(html);
|
|
243
|
+
}
|
|
244
|
+
|
|
233
245
|
function positionArrow(box, trigger) {
|
|
234
246
|
var arrow = box.querySelector('.popover__arrow');
|
|
235
247
|
if (!arrow) return;
|
|
@@ -256,5 +268,6 @@ closeAll: function () { if (current) current.close(); },
|
|
|
256
268
|
get current() { return current; },
|
|
257
269
|
};
|
|
258
270
|
|
|
271
|
+
Popover.setSanitizer = setSanitizer;
|
|
259
272
|
global.Popover = Popover;
|
|
260
273
|
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -213,6 +213,26 @@ return true;
|
|
|
213
213
|
var uid = 0;
|
|
214
214
|
|
|
215
215
|
function create(root, opt) {
|
|
216
|
+
|
|
217
|
+
var DEFAULT_LABELS = {
|
|
218
|
+
option: '选项',
|
|
219
|
+
placeholder: '请选择'
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
var flLabels = function (defaults, src) {
|
|
223
|
+
src = src || {};
|
|
224
|
+
var out = {};
|
|
225
|
+
var k;
|
|
226
|
+
for (k in defaults) {
|
|
227
|
+
if (Object.prototype.hasOwnProperty.call(defaults, k)) {
|
|
228
|
+
out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
|
|
235
|
+
|
|
216
236
|
opt = opt || {};
|
|
217
237
|
var btn = root.querySelector('[data-select-btn]');
|
|
218
238
|
var list = root.querySelector('[data-select-list]');
|
|
@@ -253,7 +273,7 @@ seed(opt.value);
|
|
|
253
273
|
|
|
254
274
|
list.setAttribute('role', 'listbox');
|
|
255
275
|
if (isMulti) list.setAttribute('aria-multiselectable', 'true');
|
|
256
|
-
list.setAttribute('aria-label', opt.label ||
|
|
276
|
+
list.setAttribute('aria-label', opt.label || L.option);
|
|
257
277
|
btn.setAttribute('aria-haspopup', 'listbox');
|
|
258
278
|
btn.setAttribute('aria-expanded', 'false');
|
|
259
279
|
btn.setAttribute('aria-controls', list.id);
|
|
@@ -293,7 +313,7 @@ var texts = [];
|
|
|
293
313
|
for (var i = 0; i < options.length; i++) {
|
|
294
314
|
if (hasValue(valueOfOpt(options[i]))) texts.push(optText(options[i]));
|
|
295
315
|
}
|
|
296
|
-
return texts.length ? texts.join('、') : (opt.placeholder ||
|
|
316
|
+
return texts.length ? texts.join('、') : (opt.placeholder || L.placeholder);
|
|
297
317
|
}
|
|
298
318
|
|
|
299
319
|
function optText(o) {
|