@velumo/core 0.1.0-beta.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/dist/background-validation.d.ts +3 -0
- package/dist/background-validation.d.ts.map +1 -0
- package/dist/background-validation.js +177 -0
- package/dist/background-validation.js.map +1 -0
- package/dist/builders.d.ts +134 -0
- package/dist/builders.d.ts.map +1 -0
- package/dist/builders.js +332 -0
- package/dist/builders.js.map +1 -0
- package/dist/capabilities.d.ts +7 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +59 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-validation.d.ts +4 -0
- package/dist/layout-validation.d.ts.map +1 -0
- package/dist/layout-validation.js +745 -0
- package/dist/layout-validation.js.map +1 -0
- package/dist/manifest-schema.d.ts +1328 -0
- package/dist/manifest-schema.d.ts.map +1 -0
- package/dist/manifest-schema.js +774 -0
- package/dist/manifest-schema.js.map +1 -0
- package/dist/theme-validation.d.ts +3 -0
- package/dist/theme-validation.d.ts.map +1 -0
- package/dist/theme-validation.js +239 -0
- package/dist/theme-validation.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +398 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation-issue.d.ts +18 -0
- package/dist/validation-issue.d.ts.map +1 -0
- package/dist/validation-issue.js +30 -0
- package/dist/validation-issue.js.map +1 -0
- package/dist/validation.d.ts +4 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +274 -0
- package/dist/validation.js.map +1 -0
- package/package.json +16 -0
- package/src/background-validation.ts +315 -0
- package/src/builders.ts +626 -0
- package/src/capabilities.ts +99 -0
- package/src/index.ts +147 -0
- package/src/layout-validation.ts +1385 -0
- package/src/manifest-schema.ts +823 -0
- package/src/theme-validation.ts +410 -0
- package/src/types.ts +560 -0
- package/src/validation-issue.ts +43 -0
- package/src/validation.ts +468 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type { ValidationIssue } from "./types.js";
|
|
2
|
+
import { describeType, issue } from "./validation-issue.js";
|
|
3
|
+
|
|
4
|
+
type IndexedRecord = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
const FORBIDDEN_RENDERER_KEYS = new Set(["style", "className", "element"]);
|
|
7
|
+
const RENDERER_EVENT_HANDLER_PATTERN = /^on[A-Z]/;
|
|
8
|
+
|
|
9
|
+
type TokenValidator = (
|
|
10
|
+
value: unknown,
|
|
11
|
+
path: string,
|
|
12
|
+
) => readonly ValidationIssue[];
|
|
13
|
+
type TokenFieldValidators = Partial<Record<string, TokenValidator>>;
|
|
14
|
+
type TokenFamilyValidators = Partial<Record<string, TokenValidator>>;
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is IndexedRecord {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function typeIssue(
|
|
25
|
+
path: string,
|
|
26
|
+
message: string,
|
|
27
|
+
expected: string,
|
|
28
|
+
received: unknown,
|
|
29
|
+
): ValidationIssue {
|
|
30
|
+
return issue(path, "wrong_type", message, {
|
|
31
|
+
expected,
|
|
32
|
+
received: describeType(received),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateSerializableValue(
|
|
37
|
+
value: unknown,
|
|
38
|
+
path: string,
|
|
39
|
+
): readonly ValidationIssue[] {
|
|
40
|
+
const issues: ValidationIssue[] = [];
|
|
41
|
+
|
|
42
|
+
if (typeof value === "function") {
|
|
43
|
+
return [
|
|
44
|
+
issue(path, "not_serializable", `${path} must be JSON-serializable`, {
|
|
45
|
+
received: "function",
|
|
46
|
+
suggestion: "Remove functions from theme tokens; they must be JSON.",
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
return [
|
|
53
|
+
issue(path, "not_serializable", `${path} must not be undefined`, {
|
|
54
|
+
received: "undefined",
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
60
|
+
return [
|
|
61
|
+
issue(path, "not_serializable", `${path} must be a finite JSON number`, {
|
|
62
|
+
received: String(value),
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
value.forEach((item, index) => {
|
|
69
|
+
issues.push(...validateSerializableValue(item, `${path}[${index}]`));
|
|
70
|
+
});
|
|
71
|
+
return issues;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isRecord(value)) {
|
|
75
|
+
for (const [key, child] of Object.entries(value)) {
|
|
76
|
+
issues.push(...validateSerializableValue(child, `${path}.${key}`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return issues;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateRendererNeutralFields(
|
|
84
|
+
value: unknown,
|
|
85
|
+
path: string,
|
|
86
|
+
): readonly ValidationIssue[] {
|
|
87
|
+
const issues: ValidationIssue[] = [];
|
|
88
|
+
|
|
89
|
+
if (!isRecord(value)) {
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const [key, child] of Object.entries(value)) {
|
|
94
|
+
if (
|
|
95
|
+
FORBIDDEN_RENDERER_KEYS.has(key) ||
|
|
96
|
+
RENDERER_EVENT_HANDLER_PATTERN.test(key)
|
|
97
|
+
) {
|
|
98
|
+
issues.push(
|
|
99
|
+
issue(
|
|
100
|
+
`${path}.${key}`,
|
|
101
|
+
"renderer_specific",
|
|
102
|
+
`${path}.${key} is renderer-specific and not allowed`,
|
|
103
|
+
{
|
|
104
|
+
suggestion:
|
|
105
|
+
"Keep theme tokens renderer-neutral; renderers map tokens to CSS.",
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
issues.push(...validateRendererNeutralFields(child, `${path}.${key}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return issues;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateStringToken(
|
|
118
|
+
value: unknown,
|
|
119
|
+
path: string,
|
|
120
|
+
): readonly ValidationIssue[] {
|
|
121
|
+
const serializableIssues = validateSerializableValue(value, path);
|
|
122
|
+
if (serializableIssues.length > 0) {
|
|
123
|
+
return serializableIssues;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof value !== "string") {
|
|
127
|
+
return [typeIssue(path, `${path} must be a string`, "string", value)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validateStringOrNumberToken(
|
|
134
|
+
value: unknown,
|
|
135
|
+
path: string,
|
|
136
|
+
): readonly ValidationIssue[] {
|
|
137
|
+
const serializableIssues = validateSerializableValue(value, path);
|
|
138
|
+
if (serializableIssues.length > 0) {
|
|
139
|
+
return serializableIssues;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
143
|
+
return [
|
|
144
|
+
typeIssue(
|
|
145
|
+
path,
|
|
146
|
+
`${path} must be a string or number`,
|
|
147
|
+
"string | number",
|
|
148
|
+
value,
|
|
149
|
+
),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateRecordFamily(
|
|
157
|
+
value: unknown,
|
|
158
|
+
path: string,
|
|
159
|
+
validateToken: TokenValidator,
|
|
160
|
+
): readonly ValidationIssue[] {
|
|
161
|
+
const issues: ValidationIssue[] = [];
|
|
162
|
+
|
|
163
|
+
if (!isRecord(value)) {
|
|
164
|
+
return [typeIssue(path, `${path} must be an object`, "object", value)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const [tokenName, tokenValue] of Object.entries(value)) {
|
|
168
|
+
issues.push(...validateToken(tokenValue, `${path}.${tokenName}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return issues;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateTypedTokenObject(
|
|
175
|
+
value: unknown,
|
|
176
|
+
path: string,
|
|
177
|
+
fieldValidators: TokenFieldValidators,
|
|
178
|
+
): readonly ValidationIssue[] {
|
|
179
|
+
const issues: ValidationIssue[] = [];
|
|
180
|
+
|
|
181
|
+
if (!isRecord(value)) {
|
|
182
|
+
return [typeIssue(path, `${path} must be an object`, "object", value)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const [key, child] of Object.entries(value)) {
|
|
186
|
+
const validateField = fieldValidators[key];
|
|
187
|
+
|
|
188
|
+
if (validateField === undefined) {
|
|
189
|
+
issues.push(
|
|
190
|
+
issue(
|
|
191
|
+
`${path}.${key}`,
|
|
192
|
+
"unknown_field",
|
|
193
|
+
`${path}.${key} is not allowed`,
|
|
194
|
+
{
|
|
195
|
+
expected: Object.keys(fieldValidators).join(", "),
|
|
196
|
+
suggestion: `Remove \`${key}\`; it is not a recognized token field.`,
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
issues.push(...validateField(child, `${path}.${key}`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return issues;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateTypographyToken(
|
|
210
|
+
value: unknown,
|
|
211
|
+
path: string,
|
|
212
|
+
): readonly ValidationIssue[] {
|
|
213
|
+
return validateTypedTokenObject(value, path, {
|
|
214
|
+
fontFamily: validateStringToken,
|
|
215
|
+
fontSize: validateStringOrNumberToken,
|
|
216
|
+
lineHeight: validateStringOrNumberToken,
|
|
217
|
+
fontWeight: validateStringOrNumberToken,
|
|
218
|
+
letterSpacing: validateStringOrNumberToken,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateMotionToken(
|
|
223
|
+
value: unknown,
|
|
224
|
+
path: string,
|
|
225
|
+
): readonly ValidationIssue[] {
|
|
226
|
+
return validateTypedTokenObject(value, path, {
|
|
227
|
+
duration: validateStringOrNumberToken,
|
|
228
|
+
easing: validateStringToken,
|
|
229
|
+
delay: validateStringOrNumberToken,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateStringRecordFamily(
|
|
234
|
+
value: unknown,
|
|
235
|
+
path: string,
|
|
236
|
+
): readonly ValidationIssue[] {
|
|
237
|
+
return validateRecordFamily(value, path, validateStringToken);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateStringOrNumberRecordFamily(
|
|
241
|
+
value: unknown,
|
|
242
|
+
path: string,
|
|
243
|
+
): readonly ValidationIssue[] {
|
|
244
|
+
return validateRecordFamily(value, path, validateStringOrNumberToken);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateTypographyRecordFamily(
|
|
248
|
+
value: unknown,
|
|
249
|
+
path: string,
|
|
250
|
+
): readonly ValidationIssue[] {
|
|
251
|
+
return validateRecordFamily(value, path, validateTypographyToken);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateMotionRecordFamily(
|
|
255
|
+
value: unknown,
|
|
256
|
+
path: string,
|
|
257
|
+
): readonly ValidationIssue[] {
|
|
258
|
+
return validateRecordFamily(value, path, validateMotionToken);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const TOKEN_FAMILY_VALIDATORS: TokenFamilyValidators = {
|
|
262
|
+
colors: validateStringRecordFamily,
|
|
263
|
+
typography: validateTypographyRecordFamily,
|
|
264
|
+
spacing: validateStringOrNumberRecordFamily,
|
|
265
|
+
radii: validateStringOrNumberRecordFamily,
|
|
266
|
+
shadows: validateStringRecordFamily,
|
|
267
|
+
motion: validateMotionRecordFamily,
|
|
268
|
+
surfaces: validateStringRecordFamily,
|
|
269
|
+
text: validateStringRecordFamily,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function validateThemeTokens(
|
|
273
|
+
value: unknown,
|
|
274
|
+
path: string,
|
|
275
|
+
): readonly ValidationIssue[] {
|
|
276
|
+
const issues: ValidationIssue[] = [];
|
|
277
|
+
|
|
278
|
+
if (!isRecord(value)) {
|
|
279
|
+
return [typeIssue(path, `${path} must be an object`, "object", value)];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
issues.push(...validateRendererNeutralFields(value, path));
|
|
283
|
+
|
|
284
|
+
for (const [family, tokens] of Object.entries(value)) {
|
|
285
|
+
const validateFamily = TOKEN_FAMILY_VALIDATORS[family];
|
|
286
|
+
|
|
287
|
+
if (validateFamily === undefined) {
|
|
288
|
+
issues.push(
|
|
289
|
+
issue(
|
|
290
|
+
`${path}.${family}`,
|
|
291
|
+
"unknown_field",
|
|
292
|
+
`${path}.${family} is not allowed`,
|
|
293
|
+
{
|
|
294
|
+
expected: Object.keys(TOKEN_FAMILY_VALIDATORS).join(", "),
|
|
295
|
+
suggestion: `Remove \`${family}\`; it is not a recognized token family.`,
|
|
296
|
+
},
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
issues.push(...validateFamily(tokens, `${path}.${family}`));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return issues;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const FONT_FACE_FIELD_VALIDATORS: TokenFieldValidators = {
|
|
309
|
+
family: validateStringToken,
|
|
310
|
+
source: validateStringToken,
|
|
311
|
+
format: validateStringToken,
|
|
312
|
+
weight: validateStringOrNumberToken,
|
|
313
|
+
style: validateStringToken,
|
|
314
|
+
display: validateStringToken,
|
|
315
|
+
unicodeRange: validateStringToken,
|
|
316
|
+
stretch: validateStringToken,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// `family` and `source` are required non-empty strings. The shared
|
|
320
|
+
// `validateTypedTokenObject` helper rejects unknown fields and type-checks every
|
|
321
|
+
// present field, but has no required-field machinery — so the required checks
|
|
322
|
+
// are layered around it (mirroring how `validateTheme` requires `id`). A present
|
|
323
|
+
// non-string family/source is left to the field validator (`wrong_type`); only a
|
|
324
|
+
// missing or empty value yields `required`, so the two never double up.
|
|
325
|
+
function isMissingOrEmpty(value: unknown): boolean {
|
|
326
|
+
return (
|
|
327
|
+
value === undefined ||
|
|
328
|
+
(typeof value === "string" && value.trim().length === 0)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateThemeFontFace(
|
|
333
|
+
value: unknown,
|
|
334
|
+
path: string,
|
|
335
|
+
): readonly ValidationIssue[] {
|
|
336
|
+
if (!isRecord(value)) {
|
|
337
|
+
return [typeIssue(path, `${path} must be an object`, "object", value)];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const issues: ValidationIssue[] = [];
|
|
341
|
+
|
|
342
|
+
for (const field of ["family", "source"] as const) {
|
|
343
|
+
if (isMissingOrEmpty(value[field])) {
|
|
344
|
+
issues.push(
|
|
345
|
+
issue(`${path}.${field}`, "required", `${path}.${field} is required`, {
|
|
346
|
+
suggestion: `Provide a non-empty value for \`${path}.${field}\`.`,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
issues.push(
|
|
353
|
+
...validateTypedTokenObject(value, path, FONT_FACE_FIELD_VALIDATORS),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return issues;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function validateTheme(
|
|
360
|
+
value: unknown,
|
|
361
|
+
path = "theme",
|
|
362
|
+
): readonly ValidationIssue[] {
|
|
363
|
+
const issues: ValidationIssue[] = [];
|
|
364
|
+
|
|
365
|
+
if (!isRecord(value)) {
|
|
366
|
+
return [typeIssue(path, `${path} must be an object`, "object", value)];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!isNonEmptyString(value.id)) {
|
|
370
|
+
issues.push(
|
|
371
|
+
issue(`${path}.id`, "required", `${path}.id is required`, {
|
|
372
|
+
suggestion: `Provide a value for \`${path}.id\`.`,
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (value.tokens !== undefined) {
|
|
378
|
+
issues.push(...validateThemeTokens(value.tokens, `${path}.tokens`));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (value.css !== undefined && typeof value.css !== "string") {
|
|
382
|
+
issues.push(
|
|
383
|
+
typeIssue(
|
|
384
|
+
`${path}.css`,
|
|
385
|
+
`${path}.css must be a string`,
|
|
386
|
+
"string",
|
|
387
|
+
value.css,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (value.fonts !== undefined) {
|
|
393
|
+
if (!Array.isArray(value.fonts)) {
|
|
394
|
+
issues.push(
|
|
395
|
+
typeIssue(
|
|
396
|
+
`${path}.fonts`,
|
|
397
|
+
`${path}.fonts must be an array`,
|
|
398
|
+
"array",
|
|
399
|
+
value.fonts,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
value.fonts.forEach((face, index) => {
|
|
404
|
+
issues.push(...validateThemeFontFace(face, `${path}.fonts.${index}`));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return issues;
|
|
410
|
+
}
|