@twinfinity/printing 6.0.1-ci.28952-beta → 6.0.2-beta
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 +3 -3
- package/dist/BuildInfo.js +3 -3
- package/dist/BuildInfo.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/analyzeTemplate.js.map +1 -1
- package/dist/layout/clipUtils.js.map +1 -1
- package/dist/layout/colorConstants.js.map +1 -1
- package/dist/layout/elementKinds.js.map +1 -1
- package/dist/layout/exportDrawing.js.map +1 -1
- package/dist/layout/filters.js.map +1 -1
- package/dist/layout/geometryConstants.js.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/itemBuilders.js.map +1 -1
- package/dist/layout/labelsRender.js.map +1 -1
- package/dist/layout/legendConstants.js.map +1 -1
- package/dist/layout/legendRender.js.map +1 -1
- package/dist/layout/legendRows.js.map +1 -1
- package/dist/layout/legendUtils.js.map +1 -1
- package/dist/layout/metadata.js.map +1 -1
- package/dist/layout/model.js.map +1 -1
- package/dist/layout/options.js.map +1 -1
- package/dist/layout/presets.js.map +1 -1
- package/dist/layout/qrRender.js.map +1 -1
- package/dist/layout/renderDrawing.js.map +1 -1
- package/dist/layout/renderUtils.js.map +1 -1
- package/dist/layout/sectionValidation.js.map +1 -1
- package/dist/layout/templateVariables.js.map +1 -1
- package/dist/layout/typeGuards.js.map +1 -1
- package/dist/layout/types.js.map +1 -1
- package/dist/layout/utils.js.map +1 -1
- package/dist/layout/validation.js.map +1 -1
- package/dist/layout/validationHelpers.js.map +1 -1
- package/dist/layout/validationUtils.js.map +1 -1
- package/dist/layout/viewportPrep.js.map +1 -1
- package/dist/layout/viewportRender.js.map +1 -1
- package/dist/layout/viewportUtils.js.map +1 -1
- package/dist/layout/warnings.js.map +1 -1
- package/dist/printToPdf.js.map +1 -1
- package/dist/printToSvg.js.map +1 -1
- package/dist/sections.js.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/BuildInfo.ts +41 -41
- package/src/index.ts +10 -10
- package/src/layout/analyzeTemplate.ts +218 -218
- package/src/layout/clipUtils.ts +193 -193
- package/src/layout/colorConstants.ts +16 -16
- package/src/layout/elementKinds.ts +35 -35
- package/src/layout/exportDrawing.ts +206 -206
- package/src/layout/filters.ts +80 -80
- package/src/layout/geometryConstants.ts +11 -11
- package/src/layout/index.ts +671 -671
- package/src/layout/itemBuilders.ts +159 -159
- package/src/layout/labelsRender.ts +170 -170
- package/src/layout/legendConstants.ts +11 -11
- package/src/layout/legendRender.ts +543 -543
- package/src/layout/legendRows.ts +62 -62
- package/src/layout/legendUtils.ts +144 -144
- package/src/layout/metadata.ts +210 -210
- package/src/layout/model.ts +372 -372
- package/src/layout/options.ts +17 -17
- package/src/layout/presets.ts +51 -51
- package/src/layout/qrRender.ts +126 -126
- package/src/layout/qrcode.d.ts +2 -2
- package/src/layout/renderDrawing.ts +710 -710
- package/src/layout/renderUtils.ts +138 -138
- package/src/layout/sectionValidation.ts +2 -2
- package/src/layout/templateVariables.ts +317 -317
- package/src/layout/typeGuards.ts +9 -9
- package/src/layout/types.ts +835 -835
- package/src/layout/utils.ts +85 -85
- package/src/layout/validation.ts +392 -392
- package/src/layout/validationHelpers.ts +59 -59
- package/src/layout/validationUtils.ts +6 -6
- package/src/layout/viewportPrep.ts +351 -351
- package/src/layout/viewportRender.ts +442 -442
- package/src/layout/viewportUtils.ts +50 -50
- package/src/layout/warnings.ts +3 -3
- package/src/printToPdf.ts +108 -108
- package/src/printToSvg.ts +188 -188
- package/src/sections.ts +1019 -1019
- package/src/types.ts +49 -49
package/src/layout/validation.ts
CHANGED
|
@@ -1,392 +1,392 @@
|
|
|
1
|
-
import { isNonEmptyString, isPositiveNumber, isRemoteUrl, isDataUrl } from './validationUtils';
|
|
2
|
-
import { isRecord, getProp } from './validationHelpers';
|
|
3
|
-
import {
|
|
4
|
-
ELEMENT_KIND_VIEWPORT,
|
|
5
|
-
ELEMENT_KIND_TEXT,
|
|
6
|
-
ELEMENT_KIND_IMAGE,
|
|
7
|
-
ELEMENT_KIND_LINE,
|
|
8
|
-
ELEMENT_KIND_RECT,
|
|
9
|
-
ELEMENT_KIND_SCALE_BAR,
|
|
10
|
-
ELEMENT_KIND_QR,
|
|
11
|
-
ELEMENT_KIND_LABELS,
|
|
12
|
-
ELEMENT_KIND_LEGEND,
|
|
13
|
-
ELEMENT_KIND_GROUP
|
|
14
|
-
} from './elementKinds';
|
|
15
|
-
|
|
16
|
-
import type { ValidationOptions, ValidationIssue, ValidationResult } from './types';
|
|
17
|
-
export type { ValidationOptions, ValidationIssue, ValidationResult };
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Lightweight template validation (structure only) for v2 schema.
|
|
21
|
-
*/
|
|
22
|
-
export function validateLayoutTemplate(template: unknown, options?: ValidationOptions): ValidationResult {
|
|
23
|
-
const allowRemoteAssets = options?.allowRemoteAssets ?? true;
|
|
24
|
-
const allowDataUrls = options?.allowDataUrls ?? true;
|
|
25
|
-
const maxViewports = options?.maxViewports ?? 3;
|
|
26
|
-
const errors: ValidationIssue[] = [];
|
|
27
|
-
|
|
28
|
-
if (!isRecord(template)) {
|
|
29
|
-
return { ok: false, errors: [{ path: '', message: 'Template must be an object.' }] };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const t = template;
|
|
33
|
-
|
|
34
|
-
const page = getProp(t, 'page');
|
|
35
|
-
if (!isRecord(page)) {
|
|
36
|
-
errors.push({ path: 'page', message: 'Page definition is required.' });
|
|
37
|
-
} else {
|
|
38
|
-
if (!isPositiveNumber(getProp(page, 'widthMm')))
|
|
39
|
-
errors.push({ path: 'page.widthMm', message: 'widthMm must be > 0.' });
|
|
40
|
-
if (!isPositiveNumber(getProp(page, 'heightMm')))
|
|
41
|
-
errors.push({ path: 'page.heightMm', message: 'heightMm must be > 0.' });
|
|
42
|
-
const marginMm = getProp(page, 'marginMm');
|
|
43
|
-
if (marginMm !== undefined && typeof marginMm !== 'number') {
|
|
44
|
-
errors.push({ path: 'page.marginMm', message: 'marginMm must be a number when provided.' });
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const assetIds = new Set<string>();
|
|
49
|
-
const assets = getProp(t, 'assets');
|
|
50
|
-
if (Array.isArray(assets)) {
|
|
51
|
-
assets.forEach((asset: unknown, index: number) => {
|
|
52
|
-
if (!isRecord(asset)) {
|
|
53
|
-
errors.push({ path: `assets[${index}]`, message: 'Asset must be an object.' });
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const id = getProp(asset, 'id');
|
|
57
|
-
if (!isNonEmptyString(id)) {
|
|
58
|
-
errors.push({ path: `assets[${index}].id`, message: 'Asset id is required.' });
|
|
59
|
-
} else if (assetIds.has(id as string)) {
|
|
60
|
-
errors.push({ path: `assets[${index}].id`, message: 'Duplicate asset id.' });
|
|
61
|
-
} else {
|
|
62
|
-
assetIds.add(id as string);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const src = getProp(asset, 'src');
|
|
66
|
-
const content = getProp(asset, 'content');
|
|
67
|
-
if (!isNonEmptyString(src) && !isNonEmptyString(content)) {
|
|
68
|
-
errors.push({ path: `assets[${index}]`, message: 'Asset must have either src or content.' });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (isNonEmptyString(src)) {
|
|
72
|
-
if (isRemoteUrl(src as string) && !allowRemoteAssets) {
|
|
73
|
-
errors.push({ path: `assets[${index}].src`, message: 'Remote URLs are not allowed for assets.' });
|
|
74
|
-
} else if (isDataUrl(src as string) && !allowDataUrls) {
|
|
75
|
-
errors.push({ path: `assets[${index}].src`, message: 'Data URLs are not allowed for assets.' });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
} else if (assets !== undefined) {
|
|
80
|
-
errors.push({ path: 'assets', message: 'Assets must be an array when provided.' });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const styleNames = new Set<string>();
|
|
84
|
-
const styles = getProp(t, 'styles');
|
|
85
|
-
if (styles !== undefined) {
|
|
86
|
-
if (!isRecord(styles)) {
|
|
87
|
-
errors.push({ path: 'styles', message: 'Styles must be an object map.' });
|
|
88
|
-
} else {
|
|
89
|
-
Object.entries(styles).forEach(([name, style]) => {
|
|
90
|
-
styleNames.add(name);
|
|
91
|
-
if (!isRecord(style)) {
|
|
92
|
-
errors.push({ path: `styles.${name}`, message: 'Style must be an object.' });
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (!isNonEmptyString(getProp(style, 'fontFamily'))) {
|
|
96
|
-
errors.push({ path: `styles.${name}.fontFamily`, message: 'fontFamily is required.' });
|
|
97
|
-
}
|
|
98
|
-
if (!isPositiveNumber(getProp(style, 'fontSizeMm'))) {
|
|
99
|
-
errors.push({ path: `styles.${name}.fontSizeMm`, message: 'fontSizeMm must be > 0.' });
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
let viewportCount = 0;
|
|
106
|
-
|
|
107
|
-
const validateElement = (el: unknown, path: string): void => {
|
|
108
|
-
if (!isRecord(el)) {
|
|
109
|
-
errors.push({ path, message: 'Element must be an object.' });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
const kind = getProp(el, 'kind');
|
|
113
|
-
if (!kind || typeof kind !== 'string') {
|
|
114
|
-
errors.push({ path: `${path}.kind`, message: 'Element kind is required.' });
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const ensure = (condition: boolean, subPath: string, message: string): void => {
|
|
119
|
-
if (!condition) errors.push({ path: subPath, message });
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
ensure(isNonEmptyString(getProp(el, 'id')), `${path}.id`, 'Element id is required.');
|
|
123
|
-
|
|
124
|
-
if (kind === ELEMENT_KIND_VIEWPORT) {
|
|
125
|
-
viewportCount++;
|
|
126
|
-
if (viewportCount > maxViewports) {
|
|
127
|
-
errors.push({ path, message: 'Too many viewports.' });
|
|
128
|
-
}
|
|
129
|
-
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
130
|
-
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
131
|
-
const xMm = getProp(el, 'xMm');
|
|
132
|
-
const yMm = getProp(el, 'yMm');
|
|
133
|
-
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
134
|
-
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
135
|
-
const border = getProp(el, 'border');
|
|
136
|
-
if (border) {
|
|
137
|
-
// border.stroke and border.strokeWidthMm are optional in v2
|
|
138
|
-
}
|
|
139
|
-
const clip = getProp(el, 'clip');
|
|
140
|
-
if (clip && isRecord(clip)) {
|
|
141
|
-
const where = getProp(clip, 'where');
|
|
142
|
-
if (!isRecord(where)) {
|
|
143
|
-
errors.push({
|
|
144
|
-
path: `${path}.clip.where`,
|
|
145
|
-
message: 'clip.where is required and must be an object.'
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const highlight = getProp(el, 'highlight');
|
|
150
|
-
if (highlight && isRecord(highlight)) {
|
|
151
|
-
const where = getProp(highlight, 'where');
|
|
152
|
-
if (!isRecord(where)) {
|
|
153
|
-
errors.push({
|
|
154
|
-
path: `${path}.highlight.where`,
|
|
155
|
-
message: 'highlight.where is required and must be an object.'
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
} else if (kind === ELEMENT_KIND_TEXT) {
|
|
160
|
-
const xMm = getProp(el, 'xMm');
|
|
161
|
-
const yMm = getProp(el, 'yMm');
|
|
162
|
-
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
163
|
-
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
164
|
-
const textStyle = getProp(el, 'textStyle');
|
|
165
|
-
if (textStyle !== undefined) {
|
|
166
|
-
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
167
|
-
errors.push({
|
|
168
|
-
path: `${path}.textStyle`,
|
|
169
|
-
message: `Style '${textStyle}' is not defined in styles.`
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
ensure(isNonEmptyString(getProp(el, 'text')), `${path}.text`, 'text is required.');
|
|
174
|
-
} else if (kind === ELEMENT_KIND_IMAGE) {
|
|
175
|
-
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
176
|
-
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
177
|
-
ensure(isNonEmptyString(getProp(el, 'assetId')), `${path}.assetId`, 'assetId is required.');
|
|
178
|
-
} else if (kind === ELEMENT_KIND_LINE) {
|
|
179
|
-
// stroke and strokeWidthMm optional in v2
|
|
180
|
-
} else if (kind === ELEMENT_KIND_RECT) {
|
|
181
|
-
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
182
|
-
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
183
|
-
} else if (kind === ELEMENT_KIND_SCALE_BAR) {
|
|
184
|
-
ensure(isPositiveNumber(getProp(el, 'maxLengthMm')), `${path}.maxLengthMm`, 'maxLengthMm must be > 0.');
|
|
185
|
-
const textStyle = getProp(el, 'textStyle');
|
|
186
|
-
if (textStyle !== undefined) {
|
|
187
|
-
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
188
|
-
errors.push({
|
|
189
|
-
path: `${path}.textStyle`,
|
|
190
|
-
message: `Style '${textStyle}' is not defined in styles.`
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} else if (kind === ELEMENT_KIND_QR) {
|
|
195
|
-
ensure(isPositiveNumber(getProp(el, 'sizeMm')), `${path}.sizeMm`, 'sizeMm must be > 0.');
|
|
196
|
-
const xMm = getProp(el, 'xMm');
|
|
197
|
-
const yMm = getProp(el, 'yMm');
|
|
198
|
-
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
199
|
-
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
200
|
-
} else if (kind === ELEMENT_KIND_LABELS) {
|
|
201
|
-
ensure(isNonEmptyString(getProp(el, 'viewport')), `${path}.viewport`, 'viewport is required.');
|
|
202
|
-
ensure(isNonEmptyString(getProp(el, 'text')), `${path}.text`, 'text is required.');
|
|
203
|
-
const textStyle = getProp(el, 'textStyle');
|
|
204
|
-
if (textStyle !== undefined) {
|
|
205
|
-
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
206
|
-
errors.push({
|
|
207
|
-
path: `${path}.textStyle`,
|
|
208
|
-
message: `Style '${textStyle}' is not defined in styles.`
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
} else if (kind === ELEMENT_KIND_LEGEND) {
|
|
213
|
-
ensure(isNonEmptyString(getProp(el, 'viewport')), `${path}.viewport`, 'viewport is required.');
|
|
214
|
-
const columns = getProp(el, 'columns');
|
|
215
|
-
if (!Array.isArray(columns) || !columns.length) {
|
|
216
|
-
errors.push({ path: `${path}.columns`, message: 'columns is required and must be a non-empty array.' });
|
|
217
|
-
} else {
|
|
218
|
-
columns.forEach((col: unknown, idx: number) => {
|
|
219
|
-
if (!isRecord(col)) {
|
|
220
|
-
errors.push({ path: `${path}.columns[${idx}]`, message: 'Column must be an object.' });
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
ensure(
|
|
224
|
-
isNonEmptyString(getProp(col, 'id')),
|
|
225
|
-
`${path}.columns[${idx}].id`,
|
|
226
|
-
'Column id is required.'
|
|
227
|
-
);
|
|
228
|
-
ensure(
|
|
229
|
-
isNonEmptyString(getProp(col, 'label')),
|
|
230
|
-
`${path}.columns[${idx}].label`,
|
|
231
|
-
'Column label is required.'
|
|
232
|
-
);
|
|
233
|
-
ensure(
|
|
234
|
-
isNonEmptyString(getProp(col, 'property')),
|
|
235
|
-
`${path}.columns[${idx}].property`,
|
|
236
|
-
'Column property is required.'
|
|
237
|
-
);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
const textStyle = getProp(el, 'textStyle');
|
|
241
|
-
if (textStyle !== undefined) {
|
|
242
|
-
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
243
|
-
errors.push({
|
|
244
|
-
path: `${path}.textStyle`,
|
|
245
|
-
message: `Style '${textStyle}' is not defined in styles.`
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
const clipToViewport = getProp(el, 'clipToViewport');
|
|
250
|
-
if (clipToViewport !== undefined && typeof clipToViewport !== 'boolean') {
|
|
251
|
-
errors.push({
|
|
252
|
-
path: `${path}.clipToViewport`,
|
|
253
|
-
message: 'clipToViewport must be a boolean when provided.'
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
} else if (kind === ELEMENT_KIND_GROUP) {
|
|
257
|
-
const opacity = getProp(el, 'opacity');
|
|
258
|
-
if (opacity !== undefined) {
|
|
259
|
-
ensure(
|
|
260
|
-
typeof opacity === 'number' && opacity >= 0 && opacity <= 1,
|
|
261
|
-
`${path}.opacity`,
|
|
262
|
-
'opacity must be between 0 and 1.'
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
const transform = getProp(el, 'transform');
|
|
266
|
-
if (transform) {
|
|
267
|
-
const scaleX = getProp(transform, 'scaleX');
|
|
268
|
-
const scaleY = getProp(transform, 'scaleY');
|
|
269
|
-
ensure(
|
|
270
|
-
scaleX === undefined || typeof scaleX === 'number',
|
|
271
|
-
`${path}.transform.scaleX`,
|
|
272
|
-
'scaleX must be a number'
|
|
273
|
-
);
|
|
274
|
-
ensure(
|
|
275
|
-
scaleY === undefined || typeof scaleY === 'number',
|
|
276
|
-
`${path}.transform.scaleY`,
|
|
277
|
-
'scaleY must be a number'
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
const clip = getProp(el, 'clip');
|
|
281
|
-
if (clip) {
|
|
282
|
-
ensure(getProp(clip, 'type') === 'rect', `${path}.clip.type`, "clip.type must be 'rect'.");
|
|
283
|
-
ensure(isPositiveNumber(getProp(clip, 'widthMm')), `${path}.clip.widthMm`, 'clip.widthMm must be > 0.');
|
|
284
|
-
ensure(
|
|
285
|
-
isPositiveNumber(getProp(clip, 'heightMm')),
|
|
286
|
-
`${path}.clip.heightMm`,
|
|
287
|
-
'clip.heightMm must be > 0.'
|
|
288
|
-
);
|
|
289
|
-
const clipXMm = getProp(clip, 'xMm');
|
|
290
|
-
const clipYMm = getProp(clip, 'yMm');
|
|
291
|
-
ensure(isPositiveNumber(clipXMm) || clipXMm === 0, `${path}.clip.xMm`, 'clip.xMm is required.');
|
|
292
|
-
ensure(isPositiveNumber(clipYMm) || clipYMm === 0, `${path}.clip.yMm`, 'clip.yMm is required.');
|
|
293
|
-
}
|
|
294
|
-
const children = getProp(el, 'children');
|
|
295
|
-
if (Array.isArray(children)) {
|
|
296
|
-
children.forEach((child: unknown, childIdx: number) =>
|
|
297
|
-
validateElement(child, `${path}.children[${childIdx}]`)
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
errors.push({ path, message: `Unknown element kind '${kind}'.` });
|
|
302
|
-
}
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
const elements = getProp(t, 'elements');
|
|
306
|
-
if (Array.isArray(elements)) {
|
|
307
|
-
elements.forEach((el: unknown, index: number) => validateElement(el, `elements[${index}]`));
|
|
308
|
-
} else if (elements !== undefined) {
|
|
309
|
-
errors.push({ path: 'elements', message: 'Elements must be an array.' });
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Validate template variables (if present)
|
|
313
|
-
const variables = getProp(t, 'variables');
|
|
314
|
-
if (variables !== undefined) {
|
|
315
|
-
if (!isRecord(variables)) {
|
|
316
|
-
errors.push({ path: 'variables', message: 'Variables must be an object.' });
|
|
317
|
-
} else {
|
|
318
|
-
// Validate each variable definition
|
|
319
|
-
for (const [varName, varDef] of Object.entries(variables)) {
|
|
320
|
-
const varPath = `variables.${varName}`;
|
|
321
|
-
|
|
322
|
-
if (!isRecord(varDef)) {
|
|
323
|
-
errors.push({ path: varPath, message: 'Variable definition must be an object.' });
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Validate type
|
|
328
|
-
const varType = getProp(varDef, 'type');
|
|
329
|
-
if (!['string', 'number', 'boolean', 'array'].includes(varType as string)) {
|
|
330
|
-
errors.push({
|
|
331
|
-
path: `${varPath}.type`,
|
|
332
|
-
message: `Type must be 'string', 'number', 'boolean', or 'array' (got '${varType}').`
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Enforce default value (CRITICAL for fail-safe operation)
|
|
337
|
-
const defaultValue = getProp(varDef, 'default');
|
|
338
|
-
if (defaultValue === undefined || defaultValue === null) {
|
|
339
|
-
errors.push({
|
|
340
|
-
path: `${varPath}.default`,
|
|
341
|
-
message: 'Variable must have a default value (required for fail-safe operation).'
|
|
342
|
-
});
|
|
343
|
-
} else {
|
|
344
|
-
// Validate default value matches declared type
|
|
345
|
-
const actualType = Array.isArray(defaultValue) ? 'array' : typeof defaultValue;
|
|
346
|
-
if (varType === 'number' && actualType !== 'number') {
|
|
347
|
-
errors.push({
|
|
348
|
-
path: `${varPath}.default`,
|
|
349
|
-
message: `Default value must be a number (got ${actualType}).`
|
|
350
|
-
});
|
|
351
|
-
} else if (varType === 'string' && actualType !== 'string') {
|
|
352
|
-
errors.push({
|
|
353
|
-
path: `${varPath}.default`,
|
|
354
|
-
message: `Default value must be a string (got ${actualType}).`
|
|
355
|
-
});
|
|
356
|
-
} else if (varType === 'boolean' && actualType !== 'boolean') {
|
|
357
|
-
errors.push({
|
|
358
|
-
path: `${varPath}.default`,
|
|
359
|
-
message: `Default value must be a boolean (got ${actualType}).`
|
|
360
|
-
});
|
|
361
|
-
} else if (varType === 'array' && actualType !== 'array') {
|
|
362
|
-
errors.push({
|
|
363
|
-
path: `${varPath}.default`,
|
|
364
|
-
message: `Default value must be an array (got ${actualType}).`
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Validate numeric constraints (if present)
|
|
370
|
-
if (varType === 'number') {
|
|
371
|
-
const min = getProp(varDef, 'min');
|
|
372
|
-
const max = getProp(varDef, 'max');
|
|
373
|
-
if (min !== undefined && typeof min !== 'number') {
|
|
374
|
-
errors.push({ path: `${varPath}.min`, message: 'min must be a number.' });
|
|
375
|
-
}
|
|
376
|
-
if (max !== undefined && typeof max !== 'number') {
|
|
377
|
-
errors.push({ path: `${varPath}.max`, message: 'max must be a number.' });
|
|
378
|
-
}
|
|
379
|
-
if (typeof min === 'number' && typeof max === 'number' && min > max) {
|
|
380
|
-
errors.push({ path: `${varPath}`, message: 'min cannot be greater than max.' });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (errors.length) {
|
|
388
|
-
return { ok: false, errors };
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return { ok: true, template };
|
|
392
|
-
}
|
|
1
|
+
import { isNonEmptyString, isPositiveNumber, isRemoteUrl, isDataUrl } from './validationUtils';
|
|
2
|
+
import { isRecord, getProp } from './validationHelpers';
|
|
3
|
+
import {
|
|
4
|
+
ELEMENT_KIND_VIEWPORT,
|
|
5
|
+
ELEMENT_KIND_TEXT,
|
|
6
|
+
ELEMENT_KIND_IMAGE,
|
|
7
|
+
ELEMENT_KIND_LINE,
|
|
8
|
+
ELEMENT_KIND_RECT,
|
|
9
|
+
ELEMENT_KIND_SCALE_BAR,
|
|
10
|
+
ELEMENT_KIND_QR,
|
|
11
|
+
ELEMENT_KIND_LABELS,
|
|
12
|
+
ELEMENT_KIND_LEGEND,
|
|
13
|
+
ELEMENT_KIND_GROUP
|
|
14
|
+
} from './elementKinds';
|
|
15
|
+
|
|
16
|
+
import type { ValidationOptions, ValidationIssue, ValidationResult } from './types';
|
|
17
|
+
export type { ValidationOptions, ValidationIssue, ValidationResult };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Lightweight template validation (structure only) for v2 schema.
|
|
21
|
+
*/
|
|
22
|
+
export function validateLayoutTemplate(template: unknown, options?: ValidationOptions): ValidationResult {
|
|
23
|
+
const allowRemoteAssets = options?.allowRemoteAssets ?? true;
|
|
24
|
+
const allowDataUrls = options?.allowDataUrls ?? true;
|
|
25
|
+
const maxViewports = options?.maxViewports ?? 3;
|
|
26
|
+
const errors: ValidationIssue[] = [];
|
|
27
|
+
|
|
28
|
+
if (!isRecord(template)) {
|
|
29
|
+
return { ok: false, errors: [{ path: '', message: 'Template must be an object.' }] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const t = template;
|
|
33
|
+
|
|
34
|
+
const page = getProp(t, 'page');
|
|
35
|
+
if (!isRecord(page)) {
|
|
36
|
+
errors.push({ path: 'page', message: 'Page definition is required.' });
|
|
37
|
+
} else {
|
|
38
|
+
if (!isPositiveNumber(getProp(page, 'widthMm')))
|
|
39
|
+
errors.push({ path: 'page.widthMm', message: 'widthMm must be > 0.' });
|
|
40
|
+
if (!isPositiveNumber(getProp(page, 'heightMm')))
|
|
41
|
+
errors.push({ path: 'page.heightMm', message: 'heightMm must be > 0.' });
|
|
42
|
+
const marginMm = getProp(page, 'marginMm');
|
|
43
|
+
if (marginMm !== undefined && typeof marginMm !== 'number') {
|
|
44
|
+
errors.push({ path: 'page.marginMm', message: 'marginMm must be a number when provided.' });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const assetIds = new Set<string>();
|
|
49
|
+
const assets = getProp(t, 'assets');
|
|
50
|
+
if (Array.isArray(assets)) {
|
|
51
|
+
assets.forEach((asset: unknown, index: number) => {
|
|
52
|
+
if (!isRecord(asset)) {
|
|
53
|
+
errors.push({ path: `assets[${index}]`, message: 'Asset must be an object.' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const id = getProp(asset, 'id');
|
|
57
|
+
if (!isNonEmptyString(id)) {
|
|
58
|
+
errors.push({ path: `assets[${index}].id`, message: 'Asset id is required.' });
|
|
59
|
+
} else if (assetIds.has(id as string)) {
|
|
60
|
+
errors.push({ path: `assets[${index}].id`, message: 'Duplicate asset id.' });
|
|
61
|
+
} else {
|
|
62
|
+
assetIds.add(id as string);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const src = getProp(asset, 'src');
|
|
66
|
+
const content = getProp(asset, 'content');
|
|
67
|
+
if (!isNonEmptyString(src) && !isNonEmptyString(content)) {
|
|
68
|
+
errors.push({ path: `assets[${index}]`, message: 'Asset must have either src or content.' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isNonEmptyString(src)) {
|
|
72
|
+
if (isRemoteUrl(src as string) && !allowRemoteAssets) {
|
|
73
|
+
errors.push({ path: `assets[${index}].src`, message: 'Remote URLs are not allowed for assets.' });
|
|
74
|
+
} else if (isDataUrl(src as string) && !allowDataUrls) {
|
|
75
|
+
errors.push({ path: `assets[${index}].src`, message: 'Data URLs are not allowed for assets.' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
} else if (assets !== undefined) {
|
|
80
|
+
errors.push({ path: 'assets', message: 'Assets must be an array when provided.' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const styleNames = new Set<string>();
|
|
84
|
+
const styles = getProp(t, 'styles');
|
|
85
|
+
if (styles !== undefined) {
|
|
86
|
+
if (!isRecord(styles)) {
|
|
87
|
+
errors.push({ path: 'styles', message: 'Styles must be an object map.' });
|
|
88
|
+
} else {
|
|
89
|
+
Object.entries(styles).forEach(([name, style]) => {
|
|
90
|
+
styleNames.add(name);
|
|
91
|
+
if (!isRecord(style)) {
|
|
92
|
+
errors.push({ path: `styles.${name}`, message: 'Style must be an object.' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isNonEmptyString(getProp(style, 'fontFamily'))) {
|
|
96
|
+
errors.push({ path: `styles.${name}.fontFamily`, message: 'fontFamily is required.' });
|
|
97
|
+
}
|
|
98
|
+
if (!isPositiveNumber(getProp(style, 'fontSizeMm'))) {
|
|
99
|
+
errors.push({ path: `styles.${name}.fontSizeMm`, message: 'fontSizeMm must be > 0.' });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let viewportCount = 0;
|
|
106
|
+
|
|
107
|
+
const validateElement = (el: unknown, path: string): void => {
|
|
108
|
+
if (!isRecord(el)) {
|
|
109
|
+
errors.push({ path, message: 'Element must be an object.' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const kind = getProp(el, 'kind');
|
|
113
|
+
if (!kind || typeof kind !== 'string') {
|
|
114
|
+
errors.push({ path: `${path}.kind`, message: 'Element kind is required.' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ensure = (condition: boolean, subPath: string, message: string): void => {
|
|
119
|
+
if (!condition) errors.push({ path: subPath, message });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
ensure(isNonEmptyString(getProp(el, 'id')), `${path}.id`, 'Element id is required.');
|
|
123
|
+
|
|
124
|
+
if (kind === ELEMENT_KIND_VIEWPORT) {
|
|
125
|
+
viewportCount++;
|
|
126
|
+
if (viewportCount > maxViewports) {
|
|
127
|
+
errors.push({ path, message: 'Too many viewports.' });
|
|
128
|
+
}
|
|
129
|
+
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
130
|
+
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
131
|
+
const xMm = getProp(el, 'xMm');
|
|
132
|
+
const yMm = getProp(el, 'yMm');
|
|
133
|
+
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
134
|
+
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
135
|
+
const border = getProp(el, 'border');
|
|
136
|
+
if (border) {
|
|
137
|
+
// border.stroke and border.strokeWidthMm are optional in v2
|
|
138
|
+
}
|
|
139
|
+
const clip = getProp(el, 'clip');
|
|
140
|
+
if (clip && isRecord(clip)) {
|
|
141
|
+
const where = getProp(clip, 'where');
|
|
142
|
+
if (!isRecord(where)) {
|
|
143
|
+
errors.push({
|
|
144
|
+
path: `${path}.clip.where`,
|
|
145
|
+
message: 'clip.where is required and must be an object.'
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const highlight = getProp(el, 'highlight');
|
|
150
|
+
if (highlight && isRecord(highlight)) {
|
|
151
|
+
const where = getProp(highlight, 'where');
|
|
152
|
+
if (!isRecord(where)) {
|
|
153
|
+
errors.push({
|
|
154
|
+
path: `${path}.highlight.where`,
|
|
155
|
+
message: 'highlight.where is required and must be an object.'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else if (kind === ELEMENT_KIND_TEXT) {
|
|
160
|
+
const xMm = getProp(el, 'xMm');
|
|
161
|
+
const yMm = getProp(el, 'yMm');
|
|
162
|
+
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
163
|
+
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
164
|
+
const textStyle = getProp(el, 'textStyle');
|
|
165
|
+
if (textStyle !== undefined) {
|
|
166
|
+
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
167
|
+
errors.push({
|
|
168
|
+
path: `${path}.textStyle`,
|
|
169
|
+
message: `Style '${textStyle}' is not defined in styles.`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
ensure(isNonEmptyString(getProp(el, 'text')), `${path}.text`, 'text is required.');
|
|
174
|
+
} else if (kind === ELEMENT_KIND_IMAGE) {
|
|
175
|
+
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
176
|
+
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
177
|
+
ensure(isNonEmptyString(getProp(el, 'assetId')), `${path}.assetId`, 'assetId is required.');
|
|
178
|
+
} else if (kind === ELEMENT_KIND_LINE) {
|
|
179
|
+
// stroke and strokeWidthMm optional in v2
|
|
180
|
+
} else if (kind === ELEMENT_KIND_RECT) {
|
|
181
|
+
ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
|
|
182
|
+
ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
|
|
183
|
+
} else if (kind === ELEMENT_KIND_SCALE_BAR) {
|
|
184
|
+
ensure(isPositiveNumber(getProp(el, 'maxLengthMm')), `${path}.maxLengthMm`, 'maxLengthMm must be > 0.');
|
|
185
|
+
const textStyle = getProp(el, 'textStyle');
|
|
186
|
+
if (textStyle !== undefined) {
|
|
187
|
+
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
path: `${path}.textStyle`,
|
|
190
|
+
message: `Style '${textStyle}' is not defined in styles.`
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else if (kind === ELEMENT_KIND_QR) {
|
|
195
|
+
ensure(isPositiveNumber(getProp(el, 'sizeMm')), `${path}.sizeMm`, 'sizeMm must be > 0.');
|
|
196
|
+
const xMm = getProp(el, 'xMm');
|
|
197
|
+
const yMm = getProp(el, 'yMm');
|
|
198
|
+
ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
|
|
199
|
+
ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
|
|
200
|
+
} else if (kind === ELEMENT_KIND_LABELS) {
|
|
201
|
+
ensure(isNonEmptyString(getProp(el, 'viewport')), `${path}.viewport`, 'viewport is required.');
|
|
202
|
+
ensure(isNonEmptyString(getProp(el, 'text')), `${path}.text`, 'text is required.');
|
|
203
|
+
const textStyle = getProp(el, 'textStyle');
|
|
204
|
+
if (textStyle !== undefined) {
|
|
205
|
+
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
206
|
+
errors.push({
|
|
207
|
+
path: `${path}.textStyle`,
|
|
208
|
+
message: `Style '${textStyle}' is not defined in styles.`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else if (kind === ELEMENT_KIND_LEGEND) {
|
|
213
|
+
ensure(isNonEmptyString(getProp(el, 'viewport')), `${path}.viewport`, 'viewport is required.');
|
|
214
|
+
const columns = getProp(el, 'columns');
|
|
215
|
+
if (!Array.isArray(columns) || !columns.length) {
|
|
216
|
+
errors.push({ path: `${path}.columns`, message: 'columns is required and must be a non-empty array.' });
|
|
217
|
+
} else {
|
|
218
|
+
columns.forEach((col: unknown, idx: number) => {
|
|
219
|
+
if (!isRecord(col)) {
|
|
220
|
+
errors.push({ path: `${path}.columns[${idx}]`, message: 'Column must be an object.' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
ensure(
|
|
224
|
+
isNonEmptyString(getProp(col, 'id')),
|
|
225
|
+
`${path}.columns[${idx}].id`,
|
|
226
|
+
'Column id is required.'
|
|
227
|
+
);
|
|
228
|
+
ensure(
|
|
229
|
+
isNonEmptyString(getProp(col, 'label')),
|
|
230
|
+
`${path}.columns[${idx}].label`,
|
|
231
|
+
'Column label is required.'
|
|
232
|
+
);
|
|
233
|
+
ensure(
|
|
234
|
+
isNonEmptyString(getProp(col, 'property')),
|
|
235
|
+
`${path}.columns[${idx}].property`,
|
|
236
|
+
'Column property is required.'
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const textStyle = getProp(el, 'textStyle');
|
|
241
|
+
if (textStyle !== undefined) {
|
|
242
|
+
if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
|
|
243
|
+
errors.push({
|
|
244
|
+
path: `${path}.textStyle`,
|
|
245
|
+
message: `Style '${textStyle}' is not defined in styles.`
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const clipToViewport = getProp(el, 'clipToViewport');
|
|
250
|
+
if (clipToViewport !== undefined && typeof clipToViewport !== 'boolean') {
|
|
251
|
+
errors.push({
|
|
252
|
+
path: `${path}.clipToViewport`,
|
|
253
|
+
message: 'clipToViewport must be a boolean when provided.'
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} else if (kind === ELEMENT_KIND_GROUP) {
|
|
257
|
+
const opacity = getProp(el, 'opacity');
|
|
258
|
+
if (opacity !== undefined) {
|
|
259
|
+
ensure(
|
|
260
|
+
typeof opacity === 'number' && opacity >= 0 && opacity <= 1,
|
|
261
|
+
`${path}.opacity`,
|
|
262
|
+
'opacity must be between 0 and 1.'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const transform = getProp(el, 'transform');
|
|
266
|
+
if (transform) {
|
|
267
|
+
const scaleX = getProp(transform, 'scaleX');
|
|
268
|
+
const scaleY = getProp(transform, 'scaleY');
|
|
269
|
+
ensure(
|
|
270
|
+
scaleX === undefined || typeof scaleX === 'number',
|
|
271
|
+
`${path}.transform.scaleX`,
|
|
272
|
+
'scaleX must be a number'
|
|
273
|
+
);
|
|
274
|
+
ensure(
|
|
275
|
+
scaleY === undefined || typeof scaleY === 'number',
|
|
276
|
+
`${path}.transform.scaleY`,
|
|
277
|
+
'scaleY must be a number'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const clip = getProp(el, 'clip');
|
|
281
|
+
if (clip) {
|
|
282
|
+
ensure(getProp(clip, 'type') === 'rect', `${path}.clip.type`, "clip.type must be 'rect'.");
|
|
283
|
+
ensure(isPositiveNumber(getProp(clip, 'widthMm')), `${path}.clip.widthMm`, 'clip.widthMm must be > 0.');
|
|
284
|
+
ensure(
|
|
285
|
+
isPositiveNumber(getProp(clip, 'heightMm')),
|
|
286
|
+
`${path}.clip.heightMm`,
|
|
287
|
+
'clip.heightMm must be > 0.'
|
|
288
|
+
);
|
|
289
|
+
const clipXMm = getProp(clip, 'xMm');
|
|
290
|
+
const clipYMm = getProp(clip, 'yMm');
|
|
291
|
+
ensure(isPositiveNumber(clipXMm) || clipXMm === 0, `${path}.clip.xMm`, 'clip.xMm is required.');
|
|
292
|
+
ensure(isPositiveNumber(clipYMm) || clipYMm === 0, `${path}.clip.yMm`, 'clip.yMm is required.');
|
|
293
|
+
}
|
|
294
|
+
const children = getProp(el, 'children');
|
|
295
|
+
if (Array.isArray(children)) {
|
|
296
|
+
children.forEach((child: unknown, childIdx: number) =>
|
|
297
|
+
validateElement(child, `${path}.children[${childIdx}]`)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
errors.push({ path, message: `Unknown element kind '${kind}'.` });
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const elements = getProp(t, 'elements');
|
|
306
|
+
if (Array.isArray(elements)) {
|
|
307
|
+
elements.forEach((el: unknown, index: number) => validateElement(el, `elements[${index}]`));
|
|
308
|
+
} else if (elements !== undefined) {
|
|
309
|
+
errors.push({ path: 'elements', message: 'Elements must be an array.' });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate template variables (if present)
|
|
313
|
+
const variables = getProp(t, 'variables');
|
|
314
|
+
if (variables !== undefined) {
|
|
315
|
+
if (!isRecord(variables)) {
|
|
316
|
+
errors.push({ path: 'variables', message: 'Variables must be an object.' });
|
|
317
|
+
} else {
|
|
318
|
+
// Validate each variable definition
|
|
319
|
+
for (const [varName, varDef] of Object.entries(variables)) {
|
|
320
|
+
const varPath = `variables.${varName}`;
|
|
321
|
+
|
|
322
|
+
if (!isRecord(varDef)) {
|
|
323
|
+
errors.push({ path: varPath, message: 'Variable definition must be an object.' });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Validate type
|
|
328
|
+
const varType = getProp(varDef, 'type');
|
|
329
|
+
if (!['string', 'number', 'boolean', 'array'].includes(varType as string)) {
|
|
330
|
+
errors.push({
|
|
331
|
+
path: `${varPath}.type`,
|
|
332
|
+
message: `Type must be 'string', 'number', 'boolean', or 'array' (got '${varType}').`
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Enforce default value (CRITICAL for fail-safe operation)
|
|
337
|
+
const defaultValue = getProp(varDef, 'default');
|
|
338
|
+
if (defaultValue === undefined || defaultValue === null) {
|
|
339
|
+
errors.push({
|
|
340
|
+
path: `${varPath}.default`,
|
|
341
|
+
message: 'Variable must have a default value (required for fail-safe operation).'
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
// Validate default value matches declared type
|
|
345
|
+
const actualType = Array.isArray(defaultValue) ? 'array' : typeof defaultValue;
|
|
346
|
+
if (varType === 'number' && actualType !== 'number') {
|
|
347
|
+
errors.push({
|
|
348
|
+
path: `${varPath}.default`,
|
|
349
|
+
message: `Default value must be a number (got ${actualType}).`
|
|
350
|
+
});
|
|
351
|
+
} else if (varType === 'string' && actualType !== 'string') {
|
|
352
|
+
errors.push({
|
|
353
|
+
path: `${varPath}.default`,
|
|
354
|
+
message: `Default value must be a string (got ${actualType}).`
|
|
355
|
+
});
|
|
356
|
+
} else if (varType === 'boolean' && actualType !== 'boolean') {
|
|
357
|
+
errors.push({
|
|
358
|
+
path: `${varPath}.default`,
|
|
359
|
+
message: `Default value must be a boolean (got ${actualType}).`
|
|
360
|
+
});
|
|
361
|
+
} else if (varType === 'array' && actualType !== 'array') {
|
|
362
|
+
errors.push({
|
|
363
|
+
path: `${varPath}.default`,
|
|
364
|
+
message: `Default value must be an array (got ${actualType}).`
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Validate numeric constraints (if present)
|
|
370
|
+
if (varType === 'number') {
|
|
371
|
+
const min = getProp(varDef, 'min');
|
|
372
|
+
const max = getProp(varDef, 'max');
|
|
373
|
+
if (min !== undefined && typeof min !== 'number') {
|
|
374
|
+
errors.push({ path: `${varPath}.min`, message: 'min must be a number.' });
|
|
375
|
+
}
|
|
376
|
+
if (max !== undefined && typeof max !== 'number') {
|
|
377
|
+
errors.push({ path: `${varPath}.max`, message: 'max must be a number.' });
|
|
378
|
+
}
|
|
379
|
+
if (typeof min === 'number' && typeof max === 'number' && min > max) {
|
|
380
|
+
errors.push({ path: `${varPath}`, message: 'min cannot be greater than max.' });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (errors.length) {
|
|
388
|
+
return { ok: false, errors };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { ok: true, template };
|
|
392
|
+
}
|