@capybara-hq/plugin-sdk 0.1.0-rc.7 → 0.1.0-rc.9
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/agent-docs/extension-points/sandbox.md +2 -2
- package/agent-docs/extensions.md +1 -1
- package/agent-docs/plugin-format.md +6 -2
- package/dist/ctx.d.ts +8 -3
- package/dist/define-extension.d.ts +5 -1
- package/dist/define-extension.js +1 -0
- package/dist/device-protocol.d.ts +1 -1
- package/dist/device-protocol.js +1 -1
- package/dist/extension-point.js +1 -1
- package/dist/file-preview.d.ts +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +7 -1
- package/dist/permissions.d.ts +9 -4
- package/dist/permissions.js +38 -15
- package/dist/plugin-manifest.d.ts +10 -3
- package/dist/plugin-manifest.js +24 -5
- package/dist/sandbox.d.ts +9 -9
- package/dist/sandbox.js +3 -3
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +449 -0
- package/dist/theme-readability.d.ts +37 -0
- package/dist/theme-readability.js +145 -0
- package/dist/theme-tokens.generated.d.ts +275 -0
- package/dist/theme-tokens.generated.js +167 -0
- package/dist/theme.d.ts +152 -0
- package/dist/theme.js +392 -0
- package/dist/tool.d.ts +3 -3
- package/dist/ui-resolve.d.ts +40 -0
- package/dist/ui-resolve.js +54 -0
- package/dist/ui-slots.d.ts +43 -0
- package/dist/ui-slots.js +168 -0
- package/dist/ui.d.ts +114 -39
- package/dist/ui.js +0 -4
- package/package.json +2 -1
- package/schemas/plugin.v1.schema.json +19 -0
- package/schemas/theme.v1.schema.json +225 -0
package/dist/theme.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CATALOG_ITEM_NAME_RE, isSafePackagePath } from "./plugin-package.js";
|
|
3
|
+
import { checkThemeReadability } from "./theme-readability.js";
|
|
4
|
+
import { THEME_OVERRIDABLE, THEME_TOKENS } from "./theme-tokens.generated.js";
|
|
5
|
+
/**
|
|
6
|
+
* A theme file `themes/<id>.json` (themes spec §4): a partial override of the design tokens per
|
|
7
|
+
* scheme, optional fonts and an optional logo. The id is the file's base name
|
|
8
|
+
* ({@link themeIdOf}, `CATALOG_ITEM_NAME_RE`).
|
|
9
|
+
*
|
|
10
|
+
* {@link parseThemeFile} is the CSS-injection boundary: every token value is parsed against the
|
|
11
|
+
* grammar `tokens.json` uses (`#RRGGBB`, `rgba(r,g,b,a)`, shadows `<x> <y> <blur> [<spread>]
|
|
12
|
+
* <color>`) and re-rendered from its parts in canonical form. Asset paths (`fonts[].file`,
|
|
13
|
+
* `logo.*`) are relative to the plugin root (an optional leading `./`) and every segment is limited
|
|
14
|
+
* to `[A-Za-z0-9._-]`, so they need no escaping inside `url(...)`. Font families are limited to
|
|
15
|
+
* letters, digits, spaces and `-`. Free text (`name`, `description`, `logo.wordmark`) carries no
|
|
16
|
+
* control, format, line / paragraph separator or lone surrogate characters; it is text for the UI,
|
|
17
|
+
* never CSS. Whether the files exist and their size / signature / SVG rules are checked by
|
|
18
|
+
* the reader of the package, with {@link THEME_LIMITS}.
|
|
19
|
+
*
|
|
20
|
+
* The committed JSON Schema (`schemas/theme.v1.schema.json`) is generated from
|
|
21
|
+
* {@link ThemeFileSchema} (the shape only) by `pnpm gen:package-schemas`; token names, value
|
|
22
|
+
* grammar, path safety and readability are decided only by {@link parseThemeFile}.
|
|
23
|
+
*/
|
|
24
|
+
/** Limits of theme files and their assets. */
|
|
25
|
+
export const THEME_LIMITS = {
|
|
26
|
+
/** UTF-8 bytes of one theme file. */
|
|
27
|
+
fileBytes: 64 * 1024,
|
|
28
|
+
/** Themes per plugin. */
|
|
29
|
+
perPlugin: 10,
|
|
30
|
+
/** Total bytes of a plugin's theme assets (fonts and logos). */
|
|
31
|
+
assetsBytes: 16 * 1024 * 1024,
|
|
32
|
+
/** Fonts per theme. */
|
|
33
|
+
fonts: 8,
|
|
34
|
+
/** Bytes of one woff2 font. */
|
|
35
|
+
fontBytes: 2 * 1024 * 1024,
|
|
36
|
+
/** Bytes of a logo SVG. */
|
|
37
|
+
svgBytes: 128 * 1024,
|
|
38
|
+
/** Bytes of a logo PNG. */
|
|
39
|
+
pngBytes: 256 * 1024,
|
|
40
|
+
/** Minimum pixel height of a logo PNG. */
|
|
41
|
+
pngMinHeight: 64,
|
|
42
|
+
/** Characters of the wordmark. */
|
|
43
|
+
wordmark: 24,
|
|
44
|
+
};
|
|
45
|
+
/** `themes/night-city.json` → `night-city`. */
|
|
46
|
+
export function themeIdOf(path) {
|
|
47
|
+
return (path.split("/").pop() ?? "").replace(/\.json$/, "");
|
|
48
|
+
}
|
|
49
|
+
const MAX_ERRORS = 50;
|
|
50
|
+
const FAMILY_RE = /^[A-Za-z0-9 -]{1,64}$/;
|
|
51
|
+
/** Control, format, line / paragraph separator and lone surrogate characters. */
|
|
52
|
+
const FORBIDDEN_CHARS_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
|
|
53
|
+
const PATH_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
54
|
+
const FORBIDDEN_CHARS_MESSAGE = "must not contain control, format, line / paragraph separator or lone surrogate characters";
|
|
55
|
+
const IMAGE_RE = /\.(?:svg|png)$/i;
|
|
56
|
+
const MAX_VALUE_CHARS = 256;
|
|
57
|
+
const text = (max, what) => z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.max(max)
|
|
61
|
+
.regex(/\S/, { message: `${what} must not be blank` })
|
|
62
|
+
.refine((t) => !FORBIDDEN_CHARS_RE.test(t), { message: `${what} ${FORBIDDEN_CHARS_MESSAGE}` });
|
|
63
|
+
/** A plugin-root relative path (optional `./`), each segment `[A-Za-z0-9._-]`, no `.` / `..`. */
|
|
64
|
+
const isPlainAssetPath = (p) => {
|
|
65
|
+
const rest = p.replace(/^\.\//, "");
|
|
66
|
+
return (isSafePackagePath(rest) &&
|
|
67
|
+
rest.split("/").every((seg) => PATH_SEGMENT_RE.test(seg) && seg !== "." && seg !== ".."));
|
|
68
|
+
};
|
|
69
|
+
const assetPath = (what, ext, kind) => z
|
|
70
|
+
.string()
|
|
71
|
+
.max(512)
|
|
72
|
+
.refine((p) => isPlainAssetPath(p) && ext.test(p), {
|
|
73
|
+
message: `${what} must be a relative ${kind} path inside the plugin, each segment of letters, digits, ".", "_" or "-"`,
|
|
74
|
+
});
|
|
75
|
+
const tokenValues = z.record(z.string().max(64), z.string().max(MAX_VALUE_CHARS));
|
|
76
|
+
const radius = z.number().int().min(0).max(16);
|
|
77
|
+
const schemeOverride = z.strictObject({
|
|
78
|
+
color: tokenValues.optional(),
|
|
79
|
+
shadow: tokenValues.optional(),
|
|
80
|
+
radius: z
|
|
81
|
+
.strictObject({ sm: radius.optional(), md: radius.optional(), bubble: radius.optional() })
|
|
82
|
+
.optional(),
|
|
83
|
+
});
|
|
84
|
+
const shape = z.strictObject({
|
|
85
|
+
$schema: z.string().max(512).optional(),
|
|
86
|
+
name: z.strictObject({ zh: text(100, "name.zh"), en: text(100, "name.en") }),
|
|
87
|
+
description: z
|
|
88
|
+
.strictObject({
|
|
89
|
+
en: text(1024, "description.en"),
|
|
90
|
+
zh: text(1024, "description.zh").optional(),
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
schemes: z
|
|
94
|
+
.strictObject({ dark: schemeOverride.optional(), light: schemeOverride.optional() })
|
|
95
|
+
.refine((s) => s.dark !== undefined || s.light !== undefined, {
|
|
96
|
+
message: "schemes must define dark, light or both",
|
|
97
|
+
}),
|
|
98
|
+
fonts: z
|
|
99
|
+
.array(z.strictObject({
|
|
100
|
+
family: z.string().regex(FAMILY_RE, {
|
|
101
|
+
message: "family must be 1-64 letters, digits, spaces or '-'",
|
|
102
|
+
}),
|
|
103
|
+
file: assetPath("file", /\.woff2$/i, ".woff2"),
|
|
104
|
+
weight: z.number().int().min(100).max(900),
|
|
105
|
+
style: z.enum(["normal", "italic"]).optional(),
|
|
106
|
+
role: z.enum(["prose", "mono"]),
|
|
107
|
+
}))
|
|
108
|
+
.max(THEME_LIMITS.fonts)
|
|
109
|
+
.optional(),
|
|
110
|
+
logo: z
|
|
111
|
+
.strictObject({
|
|
112
|
+
mark: assetPath("mark", IMAGE_RE, ".svg or .png").optional(),
|
|
113
|
+
wordmark: z
|
|
114
|
+
.string()
|
|
115
|
+
.regex(/\S/, { message: "wordmark must not be blank" })
|
|
116
|
+
.refine((w) => [...w].length <= THEME_LIMITS.wordmark, {
|
|
117
|
+
message: `wordmark must be at most ${THEME_LIMITS.wordmark} characters`,
|
|
118
|
+
})
|
|
119
|
+
.refine((w) => !FORBIDDEN_CHARS_RE.test(w), {
|
|
120
|
+
message: `wordmark ${FORBIDDEN_CHARS_MESSAGE}`,
|
|
121
|
+
})
|
|
122
|
+
.optional(),
|
|
123
|
+
favicon: assetPath("favicon", IMAGE_RE, ".svg or .png").optional(),
|
|
124
|
+
})
|
|
125
|
+
.optional(),
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* The zod schema of a theme file's shape (strict objects). Token names and values are only
|
|
129
|
+
* strings here; see {@link parseThemeFile}. The JSON Schema is generated with
|
|
130
|
+
* `z.toJSONSchema(ThemeFileSchema, { io: "input" })`.
|
|
131
|
+
*/
|
|
132
|
+
export const ThemeFileSchema = shape;
|
|
133
|
+
const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
|
|
134
|
+
const RGBA_RE = /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0|1|0?\.\d{1,4})\s*\)$/;
|
|
135
|
+
const LENGTH_RE = /^(-?)(\d{1,3})(px)?$/;
|
|
136
|
+
/** `#RRGGBB` uppercase or `rgba(r,g,b,a)` rendered from its parts; undefined outside the grammar. */
|
|
137
|
+
function parseColor(v) {
|
|
138
|
+
const hex = HEX_RE.exec(v);
|
|
139
|
+
if (hex)
|
|
140
|
+
return `#${hex[1].toUpperCase()}`;
|
|
141
|
+
const m = RGBA_RE.exec(v);
|
|
142
|
+
if (!m)
|
|
143
|
+
return undefined;
|
|
144
|
+
const ch = [m[1], m[2], m[3]].map(Number);
|
|
145
|
+
if (ch.some((c) => c > 255))
|
|
146
|
+
return undefined;
|
|
147
|
+
return `rgba(${ch.join(",")},${String(Number(m[4]))})`;
|
|
148
|
+
}
|
|
149
|
+
/** A shadow length: `0` or `<n>px` (unitless only for 0), rendered as `0` / `<n>px`. */
|
|
150
|
+
function parseLength(v, allowNegative) {
|
|
151
|
+
const m = LENGTH_RE.exec(v);
|
|
152
|
+
if (!m)
|
|
153
|
+
return undefined;
|
|
154
|
+
const n = Number(m[2]);
|
|
155
|
+
if (n !== 0 && m[3] === undefined)
|
|
156
|
+
return undefined;
|
|
157
|
+
if (n === 0)
|
|
158
|
+
return "0";
|
|
159
|
+
if (m[1] === "-" && !allowNegative)
|
|
160
|
+
return undefined;
|
|
161
|
+
return `${m[1]}${n}px`;
|
|
162
|
+
}
|
|
163
|
+
/** `<x> <y> <blur> [<spread>] <color>`, rendered canonically; undefined outside the grammar. */
|
|
164
|
+
function parseShadow(v) {
|
|
165
|
+
const s = v.trim();
|
|
166
|
+
const at = s.endsWith(")") ? s.lastIndexOf("rgba(") : s.lastIndexOf(" ") + 1;
|
|
167
|
+
if (at <= 0 || !/\s/.test(s.charAt(at - 1)))
|
|
168
|
+
return undefined;
|
|
169
|
+
const color = parseColor(s.slice(at));
|
|
170
|
+
const lengths = s.slice(0, at).trim().split(/\s+/);
|
|
171
|
+
if (color === undefined || lengths.length < 3 || lengths.length > 4)
|
|
172
|
+
return undefined;
|
|
173
|
+
// x, y and spread may be negative; blur may not.
|
|
174
|
+
const parts = lengths.map((l, i) => parseLength(l, i !== 2));
|
|
175
|
+
if (parts.some((p) => p === undefined))
|
|
176
|
+
return undefined;
|
|
177
|
+
return `${parts.join(" ")} ${color}`;
|
|
178
|
+
}
|
|
179
|
+
const SCHEMES = ["dark", "light"];
|
|
180
|
+
const OVERRIDABLE = {
|
|
181
|
+
color: new Set(THEME_OVERRIDABLE.color),
|
|
182
|
+
shadow: new Set(THEME_OVERRIDABLE.shadow),
|
|
183
|
+
};
|
|
184
|
+
const GRAMMAR = {
|
|
185
|
+
color: { parse: parseColor, form: "#RRGGBB or rgba(r,g,b,a)" },
|
|
186
|
+
shadow: {
|
|
187
|
+
parse: parseShadow,
|
|
188
|
+
form: "<x> <y> <blur> [<spread>] <color> with px lengths and a #RRGGBB or rgba(r,g,b,a) color",
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* The canonical form of a token value under the grammar of `group` (the one {@link parseThemeFile}
|
|
193
|
+
* applies), or undefined when `value` is not a string within it. For readers of stored themes that
|
|
194
|
+
* emit values again (the kernel's stylesheet, themes spec §8): a stored value is re-validated
|
|
195
|
+
* before it reaches CSS, so a changed database row cannot inject CSS.
|
|
196
|
+
*/
|
|
197
|
+
export function canonicalThemeValue(group, value) {
|
|
198
|
+
if (typeof value !== "string" || value.length > MAX_VALUE_CHARS)
|
|
199
|
+
return undefined;
|
|
200
|
+
return GRAMMAR[group].parse(value);
|
|
201
|
+
}
|
|
202
|
+
/** Whether `v` is a font family a theme may name: 1-64 letters, digits, spaces or `-`. */
|
|
203
|
+
export function isThemeFontFamily(v) {
|
|
204
|
+
return typeof v === "string" && FAMILY_RE.test(v);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether `v` is an asset path a theme may name: plugin-root relative (an optional leading `./`),
|
|
208
|
+
* every segment of `[A-Za-z0-9._-]`, no `.` / `..` segments (the extension is not checked).
|
|
209
|
+
*/
|
|
210
|
+
export function isThemeAssetPath(v) {
|
|
211
|
+
return typeof v === "string" && v.length <= 512 && isPlainAssetPath(v);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Each scheme the theme defines, merged onto the current built-in values (for swatches, the
|
|
215
|
+
* readability check and anything else that needs full values). A stored theme is read leniently:
|
|
216
|
+
* an override whose token name the built-in tokens no longer know (renamed in core), that is not
|
|
217
|
+
* overridable, or whose value falls outside the grammar is skipped, as the stylesheet skips it.
|
|
218
|
+
*/
|
|
219
|
+
export function mergeThemeSchemes(theme) {
|
|
220
|
+
const out = {};
|
|
221
|
+
const schemes = theme?.schemes;
|
|
222
|
+
if (schemes === null || typeof schemes !== "object")
|
|
223
|
+
return out;
|
|
224
|
+
for (const scheme of SCHEMES) {
|
|
225
|
+
const over = schemes[scheme];
|
|
226
|
+
if (over === null || typeof over !== "object")
|
|
227
|
+
continue;
|
|
228
|
+
const m = builtIn(scheme);
|
|
229
|
+
for (const group of ["color", "shadow"]) {
|
|
230
|
+
const values = over[group];
|
|
231
|
+
if (values === null || typeof values !== "object")
|
|
232
|
+
continue;
|
|
233
|
+
for (const [name, raw] of Object.entries(values)) {
|
|
234
|
+
if (!OVERRIDABLE[group].has(name))
|
|
235
|
+
continue;
|
|
236
|
+
const v = canonicalThemeValue(group, raw);
|
|
237
|
+
if (v !== undefined)
|
|
238
|
+
m[group][name] = v;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const radii = over.radius;
|
|
242
|
+
if (radii !== null && typeof radii === "object") {
|
|
243
|
+
for (const key of ["sm", "md", "bubble"]) {
|
|
244
|
+
const v = radii[key];
|
|
245
|
+
if (Number.isInteger(v) && v >= 0 && v <= 16) {
|
|
246
|
+
m.radius[key] = v;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
out[scheme] = m;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/** The built-in values of one scheme. */
|
|
255
|
+
function builtIn(scheme) {
|
|
256
|
+
const pick = (group) => Object.fromEntries(Object.entries(group).map(([n, v]) => [n, v[scheme]]));
|
|
257
|
+
return {
|
|
258
|
+
color: pick(THEME_TOKENS.color),
|
|
259
|
+
shadow: pick(THEME_TOKENS.shadow),
|
|
260
|
+
radius: { ...THEME_TOKENS.radius },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function readJson(json) {
|
|
264
|
+
let text;
|
|
265
|
+
if (typeof json === "string") {
|
|
266
|
+
text = json;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// An already parsed value is measured by its JSON serialization.
|
|
270
|
+
try {
|
|
271
|
+
text = JSON.stringify(json) ?? "";
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return { errors: ["theme file must be a JSON object"] };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (new TextEncoder().encode(text).length > THEME_LIMITS.fileBytes) {
|
|
278
|
+
return { errors: [`theme file is larger than ${THEME_LIMITS.fileBytes} bytes`] };
|
|
279
|
+
}
|
|
280
|
+
if (typeof json !== "string")
|
|
281
|
+
return { value: json };
|
|
282
|
+
try {
|
|
283
|
+
return { value: JSON.parse(json.startsWith("\uFEFF") ? json.slice(1) : json) };
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
const first = (e instanceof Error ? e.message : String(e)).split("\n")[0];
|
|
287
|
+
return { errors: [`theme file is not valid JSON: ${first}`] };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Parses and validates a theme file: the parsed JSON value or the file text (larger than
|
|
292
|
+
* {@link THEME_LIMITS}`.fileBytes`, measured as text or as the value's JSON, is refused). Checks the id, the shape, token names against
|
|
293
|
+
* the generated allowlist (an unknown or non-overridable name is an error), the value grammar,
|
|
294
|
+
* fonts and logo; then (unless `options.readability` is false) merges every defined scheme onto
|
|
295
|
+
* the built-in tokens ({@link mergeThemeSchemes}) and reports each readability issue as an error
|
|
296
|
+
* starting `unreadable:`. Returned values are canonical (uppercase
|
|
297
|
+
* hex, rgba and shadows re-rendered from their parts). Invalid input returns `{ errors }`
|
|
298
|
+
* (`<path>: <reason>`, English, at most 50); never throws.
|
|
299
|
+
*/
|
|
300
|
+
export function parseThemeFile(id, json, options = {}) {
|
|
301
|
+
if (!CATALOG_ITEM_NAME_RE.test(id)) {
|
|
302
|
+
return { errors: [`theme id "${id}" must match ^[a-z0-9][a-z0-9-]{0,63}$`] };
|
|
303
|
+
}
|
|
304
|
+
const read = readJson(json);
|
|
305
|
+
if ("errors" in read)
|
|
306
|
+
return read;
|
|
307
|
+
const data = read.value;
|
|
308
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
309
|
+
return { errors: ["theme file must be a JSON object"] };
|
|
310
|
+
}
|
|
311
|
+
const r = shape.safeParse(data);
|
|
312
|
+
if (!r.success) {
|
|
313
|
+
return {
|
|
314
|
+
errors: r.error.issues.slice(0, MAX_ERRORS).map((i) => {
|
|
315
|
+
const path = i.path.map(String).join(".");
|
|
316
|
+
return path ? `${path}: ${i.message}` : i.message;
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const file = r.data;
|
|
321
|
+
const errors = [];
|
|
322
|
+
const seenFonts = new Set();
|
|
323
|
+
file.fonts?.forEach((f, i) => {
|
|
324
|
+
const key = `${f.family.toLowerCase()}|${f.weight}|${f.style ?? "normal"}|${f.role}`;
|
|
325
|
+
if (seenFonts.has(key)) {
|
|
326
|
+
errors.push(`fonts.${i}: duplicate font ${f.family} ${f.weight} ${f.style ?? "normal"} (${f.role})`);
|
|
327
|
+
}
|
|
328
|
+
seenFonts.add(key);
|
|
329
|
+
});
|
|
330
|
+
const schemes = {};
|
|
331
|
+
for (const scheme of SCHEMES) {
|
|
332
|
+
const over = file.schemes[scheme];
|
|
333
|
+
if (over === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
const out = {};
|
|
336
|
+
// Token names come from the raw object's own keys: the zod record drops `__proto__`.
|
|
337
|
+
const rawScheme = data.schemes[scheme];
|
|
338
|
+
for (const group of ["color", "shadow"]) {
|
|
339
|
+
const values = over[group];
|
|
340
|
+
if (values === undefined)
|
|
341
|
+
continue;
|
|
342
|
+
const rawGroup = rawScheme?.[group];
|
|
343
|
+
const names = rawGroup !== null && typeof rawGroup === "object" ? Object.keys(rawGroup) : [];
|
|
344
|
+
const canonical = {};
|
|
345
|
+
for (const name of names) {
|
|
346
|
+
const path = `schemes.${scheme}.${group}.${name}`;
|
|
347
|
+
if (!OVERRIDABLE[group].has(name)) {
|
|
348
|
+
errors.push(Object.hasOwn(THEME_TOKENS[group], name)
|
|
349
|
+
? `${path}: token ${group}.${name} is not overridable`
|
|
350
|
+
: `${path}: unknown token ${group}.${name}`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const raw = values[name];
|
|
354
|
+
const v = raw === undefined ? undefined : GRAMMAR[group].parse(raw);
|
|
355
|
+
if (v === undefined) {
|
|
356
|
+
errors.push(`${path}: must be ${GRAMMAR[group].form}`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
canonical[name] = v;
|
|
360
|
+
}
|
|
361
|
+
out[group] = canonical;
|
|
362
|
+
}
|
|
363
|
+
if (over.radius !== undefined)
|
|
364
|
+
out.radius = { ...over.radius };
|
|
365
|
+
schemes[scheme] = out;
|
|
366
|
+
}
|
|
367
|
+
if (errors.length > 0)
|
|
368
|
+
return { errors: errors.slice(0, MAX_ERRORS) };
|
|
369
|
+
const merged = options.readability === false ? {} : mergeThemeSchemes({ schemes });
|
|
370
|
+
for (const scheme of SCHEMES) {
|
|
371
|
+
const m = merged[scheme];
|
|
372
|
+
if (m === undefined)
|
|
373
|
+
continue;
|
|
374
|
+
for (const issue of checkThemeReadability(scheme, m)) {
|
|
375
|
+
errors.push(issue.kind === "unevaluable"
|
|
376
|
+
? `unreadable: schemes.${scheme}: ${issue.pair} has a value the check cannot evaluate`
|
|
377
|
+
: issue.kind === "separation-order"
|
|
378
|
+
? `unreadable: schemes.${scheme}: ${issue.pair} must stand further from the surface than hover (${issue.ratio.toFixed(1)} ≤ ${issue.floor.toFixed(1)})`
|
|
379
|
+
: `unreadable: schemes.${scheme}: ${issue.pair} ${issue.kind} ${issue.ratio.toFixed(2)} is below ${issue.floor}${issue.detail === undefined ? "" : ` (${issue.detail})`}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (errors.length > 0)
|
|
383
|
+
return { errors: errors.slice(0, MAX_ERRORS) };
|
|
384
|
+
const defined = SCHEMES.filter((s) => schemes[s] !== undefined);
|
|
385
|
+
const { $schema: _schema, ...rest } = file;
|
|
386
|
+
return {
|
|
387
|
+
...rest,
|
|
388
|
+
schemes,
|
|
389
|
+
id,
|
|
390
|
+
singleScheme: defined.length === 1 ? defined[0] : null,
|
|
391
|
+
};
|
|
392
|
+
}
|
package/dist/tool.d.ts
CHANGED
|
@@ -72,9 +72,9 @@ export interface ToolResult {
|
|
|
72
72
|
display?: ToolDisplay;
|
|
73
73
|
/**
|
|
74
74
|
* Optional: an inline card under this call in the conversation: `view` is a ui contribution of
|
|
75
|
-
* the same extension
|
|
76
|
-
* (recorded on the `tool_result` event, never in the model's
|
|
77
|
-
* with a warning and the result is kept.
|
|
75
|
+
* the same extension in the `conversation.tool-result.card` slot, rendered with
|
|
76
|
+
* `card: { callId, key }`. Display only (recorded on the `tool_result` event, never in the model's
|
|
77
|
+
* context); an unknown view is dropped with a warning and the result is kept.
|
|
78
78
|
*/
|
|
79
79
|
card?: {
|
|
80
80
|
view: string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { UiContribution } from "./ui.js";
|
|
2
|
+
import { type UiSlotSpec } from "./ui-slots.js";
|
|
3
|
+
/** A contribution whose slot the catalog knows at the version it names (UI slots spec §3.2). */
|
|
4
|
+
export interface ResolvedUiContribution {
|
|
5
|
+
contribution: UiContribution;
|
|
6
|
+
slot: UiSlotSpec;
|
|
7
|
+
version: number;
|
|
8
|
+
/**
|
|
9
|
+
* For a slot with anchors: the anchor it goes after (the contribution's own `after` when it is one
|
|
10
|
+
* of the slot's anchors, else the slot's first anchor). Absent for a slot without anchors.
|
|
11
|
+
*/
|
|
12
|
+
after?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Why a contribution was dropped: its slot is not in the catalog, or not at that version. */
|
|
15
|
+
export type UiDropReason = "unknown-slot" | "unsupported-version";
|
|
16
|
+
/** A contribution left out of the loaded extension; `ref` is the slot reference it names. */
|
|
17
|
+
export interface DroppedUiContribution {
|
|
18
|
+
id: string;
|
|
19
|
+
ref: string;
|
|
20
|
+
reason: UiDropReason;
|
|
21
|
+
}
|
|
22
|
+
/** The slot reference a contribution names: its `slot`, or the slot of its deprecated `place`. */
|
|
23
|
+
export declare function slotRefOf(c: UiContribution): string;
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the slots of a manifest's `ui` against the catalog (UI slots spec §3.2). Assumes
|
|
26
|
+
* `validateUiContributions` passed; never throws. A contribution to an unknown slot or to a version
|
|
27
|
+
* other than the slot's is dropped (an alias whose target is dropped is dropped with the target's
|
|
28
|
+
* reason and its own `ref`); an `after` that is not an anchor of the slot is listed in
|
|
29
|
+
* `unknownAnchors` and the contribution keeps the slot's default position. `deprecatedPlace` lists
|
|
30
|
+
* the ids that still say `place`. Both lists keep manifest order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveUiSlots(ui: readonly UiContribution[] | undefined): {
|
|
33
|
+
resolved: ResolvedUiContribution[];
|
|
34
|
+
dropped: DroppedUiContribution[];
|
|
35
|
+
unknownAnchors: {
|
|
36
|
+
id: string;
|
|
37
|
+
after: string;
|
|
38
|
+
}[];
|
|
39
|
+
deprecatedPlace: string[];
|
|
40
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PLACE_SLOTS, parseSlotRef, uiSlot } from "./ui-slots.js";
|
|
2
|
+
/** The slot reference a contribution names: its `slot`, or the slot of its deprecated `place`. */
|
|
3
|
+
export function slotRefOf(c) {
|
|
4
|
+
// biome-ignore lint/style/noNonNullAssertion: validateUiContributions requires slot or place
|
|
5
|
+
return c.slot ?? PLACE_SLOTS[c.place];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the slots of a manifest's `ui` against the catalog (UI slots spec §3.2). Assumes
|
|
9
|
+
* `validateUiContributions` passed; never throws. A contribution to an unknown slot or to a version
|
|
10
|
+
* other than the slot's is dropped (an alias whose target is dropped is dropped with the target's
|
|
11
|
+
* reason and its own `ref`); an `after` that is not an anchor of the slot is listed in
|
|
12
|
+
* `unknownAnchors` and the contribution keeps the slot's default position. `deprecatedPlace` lists
|
|
13
|
+
* the ids that still say `place`. Both lists keep manifest order.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveUiSlots(ui) {
|
|
16
|
+
const list = ui ?? [];
|
|
17
|
+
const own = new Map();
|
|
18
|
+
for (const c of list) {
|
|
19
|
+
const ref = parseSlotRef(slotRefOf(c));
|
|
20
|
+
const slot = ref === null ? undefined : uiSlot(ref.id);
|
|
21
|
+
own.set(c.id, ref === null || slot === undefined
|
|
22
|
+
? { ok: false, reason: "unknown-slot" }
|
|
23
|
+
: ref.version !== slot.version
|
|
24
|
+
? { ok: false, reason: "unsupported-version" }
|
|
25
|
+
: { ok: true, slot, version: ref.version });
|
|
26
|
+
}
|
|
27
|
+
const resolved = [];
|
|
28
|
+
const dropped = [];
|
|
29
|
+
const unknownAnchors = [];
|
|
30
|
+
const deprecatedPlace = [];
|
|
31
|
+
for (const c of list) {
|
|
32
|
+
if (c.slot === undefined && c.place !== undefined)
|
|
33
|
+
deprecatedPlace.push(c.id);
|
|
34
|
+
const mine = own.get(c.id);
|
|
35
|
+
const target = c.aliasOf === undefined ? undefined : own.get(c.aliasOf);
|
|
36
|
+
const r = mine.ok && target !== undefined && !target.ok ? target : mine;
|
|
37
|
+
if (!r.ok) {
|
|
38
|
+
dropped.push({ id: c.id, ref: slotRefOf(c), reason: r.reason });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const anchors = r.slot.anchors ?? [];
|
|
42
|
+
if (c.after !== undefined && !anchors.includes(c.after)) {
|
|
43
|
+
unknownAnchors.push({ id: c.id, after: c.after });
|
|
44
|
+
}
|
|
45
|
+
const after = c.after !== undefined && anchors.includes(c.after) ? c.after : anchors[0];
|
|
46
|
+
resolved.push({
|
|
47
|
+
contribution: c,
|
|
48
|
+
slot: r.slot,
|
|
49
|
+
version: r.version,
|
|
50
|
+
...(anchors.length > 0 && { after }),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { resolved, dropped, unknownAnchors, deprecatedPlace };
|
|
54
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { UiKind, UiPlace } from "./ui.js";
|
|
2
|
+
export type UiSlotScope = "conversation" | "agent" | "org" | "platform" | "file";
|
|
3
|
+
export type UiSlotRole = "guest" | "member" | "admin" | "superAdmin";
|
|
4
|
+
/** Every slot id of the catalog, in catalog order (UI slots spec §3). */
|
|
5
|
+
export declare const UI_SLOT_IDS: readonly ["conversation.panel", "conversation.properties.section", "conversation.tool-result.card", "org.page", "file.preview", "agent.detail.section", "org.settings.section", "platform.page"];
|
|
6
|
+
export type UiSlotId = (typeof UI_SLOT_IDS)[number];
|
|
7
|
+
/** One slot of the catalog: where extension UI may go and what it receives (UI slots spec §3.1). */
|
|
8
|
+
export interface UiSlotSpec {
|
|
9
|
+
id: UiSlotId;
|
|
10
|
+
/** Bumped only on an incompatible change of the slot's contract. */
|
|
11
|
+
version: number;
|
|
12
|
+
/** Path relative to `apps/web/src` of the component that renders the slot. */
|
|
13
|
+
owner: string;
|
|
14
|
+
scope: UiSlotScope;
|
|
15
|
+
accepts: readonly UiKind[];
|
|
16
|
+
cardinality: "list";
|
|
17
|
+
minRole: UiSlotRole;
|
|
18
|
+
/** Context ids passed to render (`conversationId`, `agentId`, …). */
|
|
19
|
+
args: readonly string[];
|
|
20
|
+
density: "dense" | "regular";
|
|
21
|
+
icon: "required" | "optional" | "none";
|
|
22
|
+
/** Built-in parts a contribution may be placed after; the first is the default. */
|
|
23
|
+
anchors?: readonly string[];
|
|
24
|
+
limits?: {
|
|
25
|
+
maxShown?: number;
|
|
26
|
+
};
|
|
27
|
+
description: {
|
|
28
|
+
en: string;
|
|
29
|
+
zh: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare const UI_SLOTS: readonly UiSlotSpec[];
|
|
33
|
+
export declare function uiSlot(id: string): UiSlotSpec | undefined;
|
|
34
|
+
/** Reads `<id>` or `<id>@<version>` (version 1 when omitted); does not check the catalog. */
|
|
35
|
+
export declare function parseSlotRef(ref: string): {
|
|
36
|
+
id: string;
|
|
37
|
+
version: number;
|
|
38
|
+
} | null;
|
|
39
|
+
/** @deprecated step A of the UI slots spec §4 */
|
|
40
|
+
export declare const PLACE_SLOTS: Readonly<Record<UiPlace, UiSlotId>>;
|
|
41
|
+
export declare const UI_ARRANGEABLE_SLOTS: readonly UiSlotId[];
|
|
42
|
+
export declare const UI_CONTRIBUTION_KEY_RE: RegExp;
|
|
43
|
+
export declare function uiContributionKey(plugin: string, id: string): string;
|