@shapio/schema 0.2.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/LICENSE +201 -0
- package/dist/html.d.ts +26 -0
- package/dist/html.js +424 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1082 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/dist/validate-BQGutaBo.js +381 -0
- package/dist/validate-BQGutaBo.js.map +1 -0
- package/dist/validate-Oyo0GJ9j.d.ts +41 -0
- package/package.json +52 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
//#region src/ids.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stable IDs for models, components and fields: random UUIDs (v4).
|
|
4
|
+
*
|
|
5
|
+
* Why UUID rather than ULID: PostgreSQL stores and indexes `uuid` natively (16 bytes) for the system
|
|
6
|
+
* tables that key on model IDs; `crypto.randomUUID()` exists in Node and every browser, so the admin can
|
|
7
|
+
* create field IDs client-side with no dependency; and nothing in Shapio needs IDs to sort by creation time
|
|
8
|
+
* (revisions carry their own version numbers and timestamps). IDs are compared case-insensitively nowhere:
|
|
9
|
+
* they are always lower-case as generated.
|
|
10
|
+
*/
|
|
11
|
+
const STABLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
12
|
+
const isStableId = (value) => typeof value === "string" && STABLE_ID_PATTERN.test(value);
|
|
13
|
+
const createStableId = () => globalThis.crypto.randomUUID();
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/types/valueFormats.ts
|
|
16
|
+
/**
|
|
17
|
+
* Canonical text formats for values stored as JSON strings (ADR 0001: dates and times are canonical UTC
|
|
18
|
+
* ISO-8601 text so they index and sort as text; decimal and biginteger are strings so no precision is lost).
|
|
19
|
+
* The content validator (package E) and the settings validators here share these patterns.
|
|
20
|
+
*/
|
|
21
|
+
const DATE_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
22
|
+
const DATETIME_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{3})?Z$/;
|
|
23
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{3})?)?$/;
|
|
24
|
+
const DECIMAL_PATTERN = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
|
25
|
+
const BIGINTEGER_PATTERN = /^-?(0|[1-9]\d*)$/;
|
|
26
|
+
/** Lower-case words joined by single hyphens, e.g. `summer-sale-2026`. */
|
|
27
|
+
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
28
|
+
/** URL-safe identifier characters for `uid` fields. */
|
|
29
|
+
const UID_PATTERN = /^[A-Za-z0-9_.~-]+$/;
|
|
30
|
+
/** The rich-text document envelope version this release reads and writes (ADR 0003). */
|
|
31
|
+
const RICHTEXT_FORMAT = "shapio-richtext";
|
|
32
|
+
const RICHTEXT_FORMAT_VERSION = 1;
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/richtext/spec.ts
|
|
35
|
+
const isNullOrString = (max) => (value) => value === null || typeof value === "string" && value.length <= max;
|
|
36
|
+
const isIntegerIn = (min, max) => (value) => typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
37
|
+
/** `href` values a link may carry: web, mail and phone links, and same-document/relative paths. */
|
|
38
|
+
const SAFE_LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i;
|
|
39
|
+
const isSafeHref = (value) => typeof value === "string" && value.length <= 2048 && SAFE_LINK_PATTERN.test(value.trim());
|
|
40
|
+
const CELL_ATTRS = {
|
|
41
|
+
colspan: {
|
|
42
|
+
check: isIntegerIn(1, 1e3),
|
|
43
|
+
default: 1
|
|
44
|
+
},
|
|
45
|
+
rowspan: {
|
|
46
|
+
check: isIntegerIn(1, 1e3),
|
|
47
|
+
default: 1
|
|
48
|
+
},
|
|
49
|
+
colwidth: {
|
|
50
|
+
check: (value) => value === null || Array.isArray(value) && value.every((width) => isIntegerIn(0, 1e5)(width)),
|
|
51
|
+
default: null
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const NODE_RULES = {
|
|
55
|
+
doc: {
|
|
56
|
+
group: "doc",
|
|
57
|
+
content: {
|
|
58
|
+
allow: ["block"],
|
|
59
|
+
min: 0
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
paragraph: {
|
|
63
|
+
group: "block",
|
|
64
|
+
content: {
|
|
65
|
+
allow: ["inline"],
|
|
66
|
+
min: 0
|
|
67
|
+
},
|
|
68
|
+
marks: true
|
|
69
|
+
},
|
|
70
|
+
heading: {
|
|
71
|
+
group: "block",
|
|
72
|
+
content: {
|
|
73
|
+
allow: ["inline"],
|
|
74
|
+
min: 0
|
|
75
|
+
},
|
|
76
|
+
marks: true,
|
|
77
|
+
attrs: { level: {
|
|
78
|
+
check: isIntegerIn(1, 6),
|
|
79
|
+
default: 1
|
|
80
|
+
} }
|
|
81
|
+
},
|
|
82
|
+
blockquote: {
|
|
83
|
+
group: "block",
|
|
84
|
+
content: {
|
|
85
|
+
allow: ["block"],
|
|
86
|
+
min: 1
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
bulletList: {
|
|
90
|
+
group: "block",
|
|
91
|
+
content: {
|
|
92
|
+
allow: ["listItem"],
|
|
93
|
+
min: 1
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
orderedList: {
|
|
97
|
+
group: "block",
|
|
98
|
+
content: {
|
|
99
|
+
allow: ["listItem"],
|
|
100
|
+
min: 1
|
|
101
|
+
},
|
|
102
|
+
attrs: {
|
|
103
|
+
start: {
|
|
104
|
+
check: isIntegerIn(0, 1e6),
|
|
105
|
+
default: 1
|
|
106
|
+
},
|
|
107
|
+
type: {
|
|
108
|
+
check: (value) => value === null || [
|
|
109
|
+
"1",
|
|
110
|
+
"a",
|
|
111
|
+
"A",
|
|
112
|
+
"i",
|
|
113
|
+
"I"
|
|
114
|
+
].includes(value),
|
|
115
|
+
default: null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
listItem: {
|
|
120
|
+
group: "listItem",
|
|
121
|
+
content: {
|
|
122
|
+
allow: ["block"],
|
|
123
|
+
min: 1
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
codeBlock: {
|
|
127
|
+
group: "block",
|
|
128
|
+
content: {
|
|
129
|
+
allow: ["text"],
|
|
130
|
+
min: 0
|
|
131
|
+
},
|
|
132
|
+
marks: false,
|
|
133
|
+
attrs: { language: {
|
|
134
|
+
check: (value) => value === null || typeof value === "string" && /^[A-Za-z0-9_+#.-]{1,32}$/.test(value),
|
|
135
|
+
default: null
|
|
136
|
+
} }
|
|
137
|
+
},
|
|
138
|
+
horizontalRule: { group: "block" },
|
|
139
|
+
image: {
|
|
140
|
+
group: "block",
|
|
141
|
+
attrs: {
|
|
142
|
+
mediaId: {
|
|
143
|
+
check: isStableId,
|
|
144
|
+
default: void 0
|
|
145
|
+
},
|
|
146
|
+
alt: {
|
|
147
|
+
check: isNullOrString(1e3),
|
|
148
|
+
default: null
|
|
149
|
+
},
|
|
150
|
+
title: {
|
|
151
|
+
check: isNullOrString(1e3),
|
|
152
|
+
default: null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
table: {
|
|
157
|
+
group: "block",
|
|
158
|
+
content: {
|
|
159
|
+
allow: ["tableRow"],
|
|
160
|
+
min: 1
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
tableRow: {
|
|
164
|
+
group: "tableRow",
|
|
165
|
+
content: {
|
|
166
|
+
allow: ["tableCell"],
|
|
167
|
+
min: 1
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
tableHeader: {
|
|
171
|
+
group: "tableCell",
|
|
172
|
+
content: {
|
|
173
|
+
allow: ["block"],
|
|
174
|
+
min: 1
|
|
175
|
+
},
|
|
176
|
+
attrs: CELL_ATTRS
|
|
177
|
+
},
|
|
178
|
+
tableCell: {
|
|
179
|
+
group: "tableCell",
|
|
180
|
+
content: {
|
|
181
|
+
allow: ["block"],
|
|
182
|
+
min: 1
|
|
183
|
+
},
|
|
184
|
+
attrs: CELL_ATTRS
|
|
185
|
+
},
|
|
186
|
+
text: { group: "inline" },
|
|
187
|
+
hardBreak: { group: "inline" }
|
|
188
|
+
};
|
|
189
|
+
const MARK_RULES = {
|
|
190
|
+
bold: {},
|
|
191
|
+
italic: {},
|
|
192
|
+
code: {},
|
|
193
|
+
link: { attrs: {
|
|
194
|
+
href: {
|
|
195
|
+
check: isSafeHref,
|
|
196
|
+
default: void 0
|
|
197
|
+
},
|
|
198
|
+
target: {
|
|
199
|
+
check: (value) => value === null || value === "_blank",
|
|
200
|
+
default: null
|
|
201
|
+
},
|
|
202
|
+
rel: {
|
|
203
|
+
check: isNullOrString(200),
|
|
204
|
+
default: null
|
|
205
|
+
},
|
|
206
|
+
class: {
|
|
207
|
+
check: (value) => value === null,
|
|
208
|
+
default: null
|
|
209
|
+
}
|
|
210
|
+
} }
|
|
211
|
+
};
|
|
212
|
+
/** Nesting deeper than this is rejected (keeps validation and rendering bounded). */
|
|
213
|
+
const MAX_RICHTEXT_DEPTH = 40;
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/richtext/validate.ts
|
|
216
|
+
const NODE_KEYS = /* @__PURE__ */ new Set([
|
|
217
|
+
"type",
|
|
218
|
+
"attrs",
|
|
219
|
+
"content",
|
|
220
|
+
"text",
|
|
221
|
+
"marks"
|
|
222
|
+
]);
|
|
223
|
+
const MAX_PROBLEMS = 20;
|
|
224
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
225
|
+
const fail = (walk, path, message) => {
|
|
226
|
+
if (walk.problems.length < MAX_PROBLEMS) walk.problems.push({
|
|
227
|
+
path,
|
|
228
|
+
message
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const checkAttrs = (walk, path, rules, raw) => {
|
|
232
|
+
if (raw !== void 0 && !isRecord(raw)) return fail(walk, `${path}/attrs`, "must be an object");
|
|
233
|
+
const given = raw ?? {};
|
|
234
|
+
const attrs = {};
|
|
235
|
+
for (const key of Object.keys(given)) if (!rules || !Object.hasOwn(rules, key)) fail(walk, `${path}/attrs/${key}`, "is not an allowed attribute");
|
|
236
|
+
for (const [key, rule] of Object.entries(rules ?? {})) {
|
|
237
|
+
const value = Object.hasOwn(given, key) ? given[key] : rule.default;
|
|
238
|
+
if (value === void 0 || !rule.check(value)) {
|
|
239
|
+
fail(walk, `${path}/attrs/${key}`, value === void 0 ? "is required" : "has an invalid value");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
attrs[key] = value;
|
|
243
|
+
}
|
|
244
|
+
return Object.keys(attrs).length > 0 ? attrs : void 0;
|
|
245
|
+
};
|
|
246
|
+
const checkMarks = (walk, path, raw, allowed) => {
|
|
247
|
+
if (raw === void 0) return;
|
|
248
|
+
if (!Array.isArray(raw)) return fail(walk, `${path}/marks`, "must be a list");
|
|
249
|
+
if (!allowed && raw.length > 0) return fail(walk, `${path}/marks`, "marks are not allowed here");
|
|
250
|
+
const seen = /* @__PURE__ */ new Set();
|
|
251
|
+
const marks = [];
|
|
252
|
+
raw.forEach((mark, index) => {
|
|
253
|
+
const at = `${path}/marks/${index}`;
|
|
254
|
+
if (!isRecord(mark) || typeof mark.type !== "string" || !Object.hasOwn(MARK_RULES, mark.type)) {
|
|
255
|
+
fail(walk, at, "is not an allowed mark");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (Object.keys(mark).some((key) => key !== "type" && key !== "attrs")) fail(walk, at, "has unknown properties");
|
|
259
|
+
if (seen.has(mark.type)) fail(walk, at, "is repeated");
|
|
260
|
+
seen.add(mark.type);
|
|
261
|
+
const attrs = checkAttrs(walk, at, MARK_RULES[mark.type]?.attrs, mark.attrs);
|
|
262
|
+
marks.push(attrs ? {
|
|
263
|
+
type: mark.type,
|
|
264
|
+
attrs
|
|
265
|
+
} : { type: mark.type });
|
|
266
|
+
});
|
|
267
|
+
return marks.length > 0 ? marks : void 0;
|
|
268
|
+
};
|
|
269
|
+
const matchesAllow = (allow, type) => {
|
|
270
|
+
const rule = NODE_RULES[type];
|
|
271
|
+
return allow.includes(type) || rule !== void 0 && allow.includes(rule.group);
|
|
272
|
+
};
|
|
273
|
+
const checkNode = (walk, raw, path, depth, context) => {
|
|
274
|
+
if (depth > 40) return fail(walk, path, "is nested too deeply");
|
|
275
|
+
if (!isRecord(raw) || typeof raw.type !== "string") return fail(walk, path, "must be a node with a type");
|
|
276
|
+
const { type } = raw;
|
|
277
|
+
const rule = Object.hasOwn(NODE_RULES, type) ? NODE_RULES[type] : void 0;
|
|
278
|
+
if (!rule) return fail(walk, path, `"${type}" is not an allowed node`);
|
|
279
|
+
if (!matchesAllow(context.allow, type)) return fail(walk, path, `"${type}" is not allowed here`);
|
|
280
|
+
for (const key of Object.keys(raw)) if (!NODE_KEYS.has(key)) fail(walk, `${path}/${key}`, "is not an allowed property");
|
|
281
|
+
if (type === "text") {
|
|
282
|
+
if (typeof raw.text !== "string" || raw.text.length === 0) return fail(walk, `${path}/text`, "must be a non-empty string");
|
|
283
|
+
walk.textLength += raw.text.length;
|
|
284
|
+
const marks = checkMarks(walk, path, raw.marks, context.marks);
|
|
285
|
+
return marks ? {
|
|
286
|
+
type,
|
|
287
|
+
text: raw.text,
|
|
288
|
+
marks
|
|
289
|
+
} : {
|
|
290
|
+
type,
|
|
291
|
+
text: raw.text
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (raw.text !== void 0) fail(walk, `${path}/text`, "only text nodes carry text");
|
|
295
|
+
const node = { type };
|
|
296
|
+
const attrs = checkAttrs(walk, path, rule.attrs, raw.attrs);
|
|
297
|
+
if (attrs) node.attrs = attrs;
|
|
298
|
+
const marks = checkMarks(walk, path, raw.marks, context.marks && rule.group === "inline");
|
|
299
|
+
if (marks) node.marks = marks;
|
|
300
|
+
if (!rule.content) {
|
|
301
|
+
if (raw.content !== void 0 && !(Array.isArray(raw.content) && raw.content.length === 0)) fail(walk, `${path}/content`, `"${type}" has no content`);
|
|
302
|
+
return node;
|
|
303
|
+
}
|
|
304
|
+
if (raw.content !== void 0 && !Array.isArray(raw.content)) return fail(walk, `${path}/content`, "must be a list");
|
|
305
|
+
const children = raw.content ?? [];
|
|
306
|
+
if (children.length < rule.content.min) fail(walk, `${path}/content`, `"${type}" needs at least ${rule.content.min} child node(s)`);
|
|
307
|
+
const childContext = {
|
|
308
|
+
allow: rule.content.allow,
|
|
309
|
+
marks: rule.marks ?? true
|
|
310
|
+
};
|
|
311
|
+
const content = children.flatMap((child, index) => {
|
|
312
|
+
const checked = checkNode(walk, child, `${path}/content/${index}`, depth + 1, childContext);
|
|
313
|
+
return checked ? [checked] : [];
|
|
314
|
+
});
|
|
315
|
+
if (content.length > 0) node.content = content;
|
|
316
|
+
if (type === "codeBlock" || type === "paragraph" || type === "heading") walk.textLength += 1;
|
|
317
|
+
return node;
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Validates a stored rich-text value: the versioned envelope `{ format, version, doc }` and the document
|
|
321
|
+
* against the baseline schema. Returns a normalized copy (known keys only, attribute defaults filled).
|
|
322
|
+
*/
|
|
323
|
+
const validateRichText = (value) => {
|
|
324
|
+
const walk = {
|
|
325
|
+
problems: [],
|
|
326
|
+
textLength: 0
|
|
327
|
+
};
|
|
328
|
+
if (!isRecord(value)) return {
|
|
329
|
+
ok: false,
|
|
330
|
+
problems: [{
|
|
331
|
+
path: "",
|
|
332
|
+
message: "must be a rich-text document object"
|
|
333
|
+
}]
|
|
334
|
+
};
|
|
335
|
+
if (value.format !== "shapio-richtext") fail(walk, "/format", `must be "${RICHTEXT_FORMAT}"`);
|
|
336
|
+
if (value.version !== 1) fail(walk, "/version", `must be 1`);
|
|
337
|
+
for (const key of Object.keys(value)) if (![
|
|
338
|
+
"format",
|
|
339
|
+
"version",
|
|
340
|
+
"doc"
|
|
341
|
+
].includes(key)) fail(walk, `/${key}`, "is not an allowed property");
|
|
342
|
+
const doc = checkNode(walk, value.doc, "/doc", 0, {
|
|
343
|
+
allow: ["doc"],
|
|
344
|
+
marks: false
|
|
345
|
+
});
|
|
346
|
+
if (walk.problems.length > 0 || !doc) return {
|
|
347
|
+
ok: false,
|
|
348
|
+
problems: walk.problems.length > 0 ? walk.problems : [{
|
|
349
|
+
path: "/doc",
|
|
350
|
+
message: "is invalid"
|
|
351
|
+
}]
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
document: {
|
|
356
|
+
format: RICHTEXT_FORMAT,
|
|
357
|
+
version: 1,
|
|
358
|
+
doc
|
|
359
|
+
},
|
|
360
|
+
textLength: walk.textLength
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
/** True when a document holds no text and no media (an empty editor). */
|
|
364
|
+
const isEmptyRichText = (document) => {
|
|
365
|
+
const hasSubstance = (node) => node.type === "text" || node.type === "image" || node.type === "horizontalRule" || (node.content ?? []).some(hasSubstance);
|
|
366
|
+
return !hasSubstance(document.doc);
|
|
367
|
+
};
|
|
368
|
+
/** Media assets a document's images reference, in document order, without repeats. */
|
|
369
|
+
const richTextMediaIds = (document) => {
|
|
370
|
+
const ids = /* @__PURE__ */ new Set();
|
|
371
|
+
const walk = (node) => {
|
|
372
|
+
if (node.type === "image" && typeof node.attrs?.mediaId === "string") ids.add(node.attrs.mediaId);
|
|
373
|
+
(node.content ?? []).forEach(walk);
|
|
374
|
+
};
|
|
375
|
+
walk(document.doc);
|
|
376
|
+
return [...ids];
|
|
377
|
+
};
|
|
378
|
+
//#endregion
|
|
379
|
+
export { STABLE_ID_PATTERN as _, MAX_RICHTEXT_DEPTH as a, BIGINTEGER_PATTERN as c, DECIMAL_PATTERN as d, RICHTEXT_FORMAT as f, UID_PATTERN as g, TIME_PATTERN as h, MARK_RULES as i, DATETIME_PATTERN as l, SLUG_PATTERN as m, richTextMediaIds as n, NODE_RULES as o, RICHTEXT_FORMAT_VERSION as p, validateRichText as r, SAFE_LINK_PATTERN as s, isEmptyRichText as t, DATE_PATTERN as u, createStableId as v, isStableId as y };
|
|
380
|
+
|
|
381
|
+
//# sourceMappingURL=validate-BQGutaBo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-BQGutaBo.js","names":[],"sources":["../src/ids.ts","../src/types/valueFormats.ts","../src/richtext/spec.ts","../src/richtext/validate.ts"],"sourcesContent":["/**\n * Stable IDs for models, components and fields: random UUIDs (v4).\n *\n * Why UUID rather than ULID: PostgreSQL stores and indexes `uuid` natively (16 bytes) for the system\n * tables that key on model IDs; `crypto.randomUUID()` exists in Node and every browser, so the admin can\n * create field IDs client-side with no dependency; and nothing in Shapio needs IDs to sort by creation time\n * (revisions carry their own version numbers and timestamps). IDs are compared case-insensitively nowhere:\n * they are always lower-case as generated.\n */\nexport const STABLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n\nexport const isStableId = (value: unknown): value is string =>\n typeof value === 'string' && STABLE_ID_PATTERN.test(value);\n\nexport const createStableId = (): string => globalThis.crypto.randomUUID();\n","/**\n * Canonical text formats for values stored as JSON strings (ADR 0001: dates and times are canonical UTC\n * ISO-8601 text so they index and sort as text; decimal and biginteger are strings so no precision is lost).\n * The content validator (package E) and the settings validators here share these patterns.\n */\nexport const DATE_PATTERN = /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/;\nexport const DATETIME_PATTERN =\n /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d{3})?Z$/;\nexport const TIME_PATTERN = /^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{3})?)?$/;\nexport const DECIMAL_PATTERN = /^-?(0|[1-9]\\d*)(\\.\\d+)?$/;\nexport const BIGINTEGER_PATTERN = /^-?(0|[1-9]\\d*)$/;\n/** Lower-case words joined by single hyphens, e.g. `summer-sale-2026`. */\nexport const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n/** URL-safe identifier characters for `uid` fields. */\nexport const UID_PATTERN = /^[A-Za-z0-9_.~-]+$/;\n\n/** The rich-text document envelope version this release reads and writes (ADR 0003). */\nexport const RICHTEXT_FORMAT = 'shapio-richtext';\nexport const RICHTEXT_FORMAT_VERSION = 1;\n","import { isStableId } from '../ids.js';\n\n/**\n * The rich-text document schema (ADR 0003, build plan §3.9): the baseline Tiptap/ProseMirror node and mark\n * set, as data. The server validates stored documents against it and renders HTML from it; the admin's\n * Tiptap editor (package F) must produce exactly these nodes, marks and attributes. Anything else is\n * rejected, never stripped silently.\n *\n * Why a hand-written structural check instead of `@tiptap/core` headless: the baseline is small and fixed,\n * the check needs no DOM shim, no extra runtime dependency on the server and no schema instantiation per\n * request, and it reports precise paths. Package F keeps the editor in step with this module (it is the\n * contract), and the shared JSON fixtures in this module's tests are what both sides agree on.\n */\n\nexport type AttrRule = {\n /** Returns true when the value is acceptable. `null` is checked like any other value. */\n check: (value: unknown) => boolean;\n /** Used when the attribute is absent. */\n default: unknown;\n};\n\nexport type NodeRule = {\n /** `block` nodes sit in block containers; `inline` nodes in textblocks. */\n group: 'block' | 'inline' | 'listItem' | 'tableRow' | 'tableCell' | 'doc';\n /** Allowed child groups or node types, and the minimum number of children. */\n content?: { allow: readonly string[]; min: number };\n attrs?: Readonly<Record<string, AttrRule>>;\n /** Inline children may carry marks (text nodes in a textblock); false for code blocks. */\n marks?: boolean;\n};\n\nconst isNullOrString = (max: number) => (value: unknown) =>\n value === null || (typeof value === 'string' && value.length <= max);\nconst isIntegerIn = (min: number, max: number) => (value: unknown) =>\n typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;\n\n/** `href` values a link may carry: web, mail and phone links, and same-document/relative paths. */\nexport const SAFE_LINK_PATTERN = /^(https?:\\/\\/|mailto:|tel:|\\/(?!\\/)|#)/i;\nconst isSafeHref = (value: unknown) =>\n typeof value === 'string' && value.length <= 2048 && SAFE_LINK_PATTERN.test(value.trim());\n\nconst CELL_ATTRS: Readonly<Record<string, AttrRule>> = {\n colspan: { check: isIntegerIn(1, 1000), default: 1 },\n rowspan: { check: isIntegerIn(1, 1000), default: 1 },\n colwidth: {\n check: (value) =>\n value === null || (Array.isArray(value) && value.every((width) => isIntegerIn(0, 100_000)(width))),\n default: null,\n },\n};\n\nexport const NODE_RULES: Readonly<Record<string, NodeRule>> = {\n doc: { group: 'doc', content: { allow: ['block'], min: 0 } },\n paragraph: { group: 'block', content: { allow: ['inline'], min: 0 }, marks: true },\n heading: {\n group: 'block',\n content: { allow: ['inline'], min: 0 },\n marks: true,\n attrs: { level: { check: isIntegerIn(1, 6), default: 1 } },\n },\n blockquote: { group: 'block', content: { allow: ['block'], min: 1 } },\n bulletList: { group: 'block', content: { allow: ['listItem'], min: 1 } },\n orderedList: {\n group: 'block',\n content: { allow: ['listItem'], min: 1 },\n attrs: {\n start: { check: isIntegerIn(0, 1_000_000), default: 1 },\n type: {\n check: (value) => value === null || ['1', 'a', 'A', 'i', 'I'].includes(value as string),\n default: null,\n },\n },\n },\n listItem: { group: 'listItem', content: { allow: ['block'], min: 1 } },\n codeBlock: {\n group: 'block',\n content: { allow: ['text'], min: 0 },\n marks: false,\n attrs: {\n language: {\n check: (value) =>\n value === null || (typeof value === 'string' && /^[A-Za-z0-9_+#.-]{1,32}$/.test(value)),\n default: null,\n },\n },\n },\n horizontalRule: { group: 'block' },\n image: {\n group: 'block',\n attrs: {\n // A media library reference, never a URL: delivery resolves it so private media stays private.\n mediaId: { check: isStableId, default: undefined },\n alt: { check: isNullOrString(1000), default: null },\n title: { check: isNullOrString(1000), default: null },\n },\n },\n table: { group: 'block', content: { allow: ['tableRow'], min: 1 } },\n tableRow: { group: 'tableRow', content: { allow: ['tableCell'], min: 1 } },\n tableHeader: { group: 'tableCell', content: { allow: ['block'], min: 1 }, attrs: CELL_ATTRS },\n tableCell: { group: 'tableCell', content: { allow: ['block'], min: 1 }, attrs: CELL_ATTRS },\n text: { group: 'inline' },\n hardBreak: { group: 'inline' },\n};\n\nexport type MarkRule = { attrs?: Readonly<Record<string, AttrRule>> };\n\nexport const MARK_RULES: Readonly<Record<string, MarkRule>> = {\n bold: {},\n italic: {},\n code: {},\n link: {\n attrs: {\n href: { check: isSafeHref, default: undefined },\n target: { check: (value) => value === null || value === '_blank', default: null },\n rel: { check: isNullOrString(200), default: null },\n class: { check: (value) => value === null, default: null },\n },\n },\n};\n\n/** Nesting deeper than this is rejected (keeps validation and rendering bounded). */\nexport const MAX_RICHTEXT_DEPTH = 40;\n","import { RICHTEXT_FORMAT, RICHTEXT_FORMAT_VERSION } from '../types/valueFormats.js';\nimport { MARK_RULES, MAX_RICHTEXT_DEPTH, NODE_RULES, type AttrRule } from './spec.js';\n\n/** A ProseMirror JSON node in Shapio's normalized form (only known keys, attributes defaulted). */\nexport type RichTextNode = {\n type: string;\n attrs?: Record<string, unknown>;\n content?: RichTextNode[];\n text?: string;\n marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;\n};\n\nexport type RichTextDocument = { format: string; version: number; doc: RichTextNode };\n\nexport type RichTextProblem = { path: string; message: string };\n\nexport type RichTextResult =\n { ok: true; document: RichTextDocument; textLength: number } | { ok: false; problems: RichTextProblem[] };\n\nconst NODE_KEYS = new Set(['type', 'attrs', 'content', 'text', 'marks']);\nconst MAX_PROBLEMS = 20;\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\ntype Walk = { problems: RichTextProblem[]; textLength: number };\n\nconst fail = (walk: Walk, path: string, message: string): undefined => {\n if (walk.problems.length < MAX_PROBLEMS) {\n walk.problems.push({ path, message });\n }\n return undefined;\n};\n\nconst checkAttrs = (\n walk: Walk,\n path: string,\n rules: Readonly<Record<string, AttrRule>> | undefined,\n raw: unknown,\n): Record<string, unknown> | undefined => {\n if (raw !== undefined && !isRecord(raw)) {\n return fail(walk, `${path}/attrs`, 'must be an object');\n }\n const given = raw ?? {};\n const attrs: Record<string, unknown> = {};\n for (const key of Object.keys(given)) {\n if (!rules || !Object.hasOwn(rules, key)) {\n fail(walk, `${path}/attrs/${key}`, 'is not an allowed attribute');\n }\n }\n for (const [key, rule] of Object.entries(rules ?? {})) {\n const value = Object.hasOwn(given, key) ? given[key] : rule.default;\n if (value === undefined || !rule.check(value)) {\n fail(walk, `${path}/attrs/${key}`, value === undefined ? 'is required' : 'has an invalid value');\n continue;\n }\n attrs[key] = value;\n }\n return Object.keys(attrs).length > 0 ? attrs : undefined;\n};\n\nconst checkMarks = (walk: Walk, path: string, raw: unknown, allowed: boolean) => {\n if (raw === undefined) {\n return undefined;\n }\n if (!Array.isArray(raw)) {\n return fail(walk, `${path}/marks`, 'must be a list');\n }\n if (!allowed && raw.length > 0) {\n return fail(walk, `${path}/marks`, 'marks are not allowed here');\n }\n const seen = new Set<string>();\n const marks: NonNullable<RichTextNode['marks']> = [];\n raw.forEach((mark: unknown, index) => {\n const at = `${path}/marks/${index}`;\n if (!isRecord(mark) || typeof mark.type !== 'string' || !Object.hasOwn(MARK_RULES, mark.type)) {\n fail(walk, at, 'is not an allowed mark');\n return;\n }\n if (Object.keys(mark).some((key) => key !== 'type' && key !== 'attrs')) {\n fail(walk, at, 'has unknown properties');\n }\n if (seen.has(mark.type)) {\n fail(walk, at, 'is repeated');\n }\n seen.add(mark.type);\n const attrs = checkAttrs(walk, at, MARK_RULES[mark.type]?.attrs, mark.attrs);\n marks.push(attrs ? { type: mark.type, attrs } : { type: mark.type });\n });\n return marks.length > 0 ? marks : undefined;\n};\n\nconst matchesAllow = (allow: readonly string[], type: string): boolean => {\n const rule = NODE_RULES[type];\n return allow.includes(type) || (rule !== undefined && allow.includes(rule.group));\n};\n\nconst checkNode = (\n walk: Walk,\n raw: unknown,\n path: string,\n depth: number,\n context: { allow: readonly string[]; marks: boolean },\n): RichTextNode | undefined => {\n if (depth > MAX_RICHTEXT_DEPTH) {\n return fail(walk, path, 'is nested too deeply');\n }\n if (!isRecord(raw) || typeof raw.type !== 'string') {\n return fail(walk, path, 'must be a node with a type');\n }\n const { type } = raw;\n const rule = Object.hasOwn(NODE_RULES, type) ? NODE_RULES[type] : undefined;\n if (!rule) {\n return fail(walk, path, `\"${type}\" is not an allowed node`);\n }\n if (!matchesAllow(context.allow, type)) {\n return fail(walk, path, `\"${type}\" is not allowed here`);\n }\n for (const key of Object.keys(raw)) {\n if (!NODE_KEYS.has(key)) {\n fail(walk, `${path}/${key}`, 'is not an allowed property');\n }\n }\n if (type === 'text') {\n if (typeof raw.text !== 'string' || raw.text.length === 0) {\n return fail(walk, `${path}/text`, 'must be a non-empty string');\n }\n walk.textLength += raw.text.length;\n const marks = checkMarks(walk, path, raw.marks, context.marks);\n return marks ? { type, text: raw.text, marks } : { type, text: raw.text };\n }\n if (raw.text !== undefined) {\n fail(walk, `${path}/text`, 'only text nodes carry text');\n }\n const node: RichTextNode = { type };\n const attrs = checkAttrs(walk, path, rule.attrs, raw.attrs);\n if (attrs) {\n node.attrs = attrs;\n }\n const marks = checkMarks(walk, path, raw.marks, context.marks && rule.group === 'inline');\n if (marks) {\n node.marks = marks;\n }\n if (!rule.content) {\n if (raw.content !== undefined && !(Array.isArray(raw.content) && raw.content.length === 0)) {\n fail(walk, `${path}/content`, `\"${type}\" has no content`);\n }\n return node;\n }\n if (raw.content !== undefined && !Array.isArray(raw.content)) {\n return fail(walk, `${path}/content`, 'must be a list');\n }\n const children = (raw.content ?? []) as unknown[];\n if (children.length < rule.content.min) {\n fail(walk, `${path}/content`, `\"${type}\" needs at least ${rule.content.min} child node(s)`);\n }\n const childContext = { allow: rule.content.allow, marks: rule.marks ?? true };\n const content = children.flatMap((child, index) => {\n const checked = checkNode(walk, child, `${path}/content/${index}`, depth + 1, childContext);\n return checked ? [checked] : [];\n });\n if (content.length > 0) {\n node.content = content;\n }\n if (type === 'codeBlock' || type === 'paragraph' || type === 'heading') {\n walk.textLength += 1;\n }\n return node;\n};\n\n/**\n * Validates a stored rich-text value: the versioned envelope `{ format, version, doc }` and the document\n * against the baseline schema. Returns a normalized copy (known keys only, attribute defaults filled).\n */\nexport const validateRichText = (value: unknown): RichTextResult => {\n const walk: Walk = { problems: [], textLength: 0 };\n if (!isRecord(value)) {\n return { ok: false, problems: [{ path: '', message: 'must be a rich-text document object' }] };\n }\n if (value.format !== RICHTEXT_FORMAT) {\n fail(walk, '/format', `must be \"${RICHTEXT_FORMAT}\"`);\n }\n if (value.version !== RICHTEXT_FORMAT_VERSION) {\n fail(walk, '/version', `must be ${RICHTEXT_FORMAT_VERSION}`);\n }\n for (const key of Object.keys(value)) {\n if (!['format', 'version', 'doc'].includes(key)) {\n fail(walk, `/${key}`, 'is not an allowed property');\n }\n }\n const doc = checkNode(walk, value.doc, '/doc', 0, { allow: ['doc'], marks: false });\n if (walk.problems.length > 0 || !doc) {\n return {\n ok: false,\n problems: walk.problems.length > 0 ? walk.problems : [{ path: '/doc', message: 'is invalid' }],\n };\n }\n return {\n ok: true,\n document: { format: RICHTEXT_FORMAT, version: RICHTEXT_FORMAT_VERSION, doc },\n textLength: walk.textLength,\n };\n};\n\n/** True when a document holds no text and no media (an empty editor). */\nexport const isEmptyRichText = (document: RichTextDocument): boolean => {\n const hasSubstance = (node: RichTextNode): boolean =>\n node.type === 'text' ||\n node.type === 'image' ||\n node.type === 'horizontalRule' ||\n (node.content ?? []).some(hasSubstance);\n return !hasSubstance(document.doc);\n};\n\n/** Media assets a document's images reference, in document order, without repeats. */\nexport const richTextMediaIds = (document: RichTextDocument): string[] => {\n const ids = new Set<string>();\n const walk = (node: RichTextNode) => {\n if (node.type === 'image' && typeof node.attrs?.mediaId === 'string') {\n ids.add(node.attrs.mediaId);\n }\n (node.content ?? []).forEach(walk);\n };\n walk(document.doc);\n return [...ids];\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/richtext/validate.d.ts
|
|
2
|
+
/** A ProseMirror JSON node in Shapio's normalized form (only known keys, attributes defaulted). */
|
|
3
|
+
type RichTextNode = {
|
|
4
|
+
type: string;
|
|
5
|
+
attrs?: Record<string, unknown>;
|
|
6
|
+
content?: RichTextNode[];
|
|
7
|
+
text?: string;
|
|
8
|
+
marks?: Array<{
|
|
9
|
+
type: string;
|
|
10
|
+
attrs?: Record<string, unknown>;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
type RichTextDocument = {
|
|
14
|
+
format: string;
|
|
15
|
+
version: number;
|
|
16
|
+
doc: RichTextNode;
|
|
17
|
+
};
|
|
18
|
+
type RichTextProblem = {
|
|
19
|
+
path: string;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
type RichTextResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
document: RichTextDocument;
|
|
25
|
+
textLength: number;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
problems: RichTextProblem[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Validates a stored rich-text value: the versioned envelope `{ format, version, doc }` and the document
|
|
32
|
+
* against the baseline schema. Returns a normalized copy (known keys only, attribute defaults filled).
|
|
33
|
+
*/
|
|
34
|
+
declare const validateRichText: (value: unknown) => RichTextResult;
|
|
35
|
+
/** True when a document holds no text and no media (an empty editor). */
|
|
36
|
+
declare const isEmptyRichText: (document: RichTextDocument) => boolean;
|
|
37
|
+
/** Media assets a document's images reference, in document order, without repeats. */
|
|
38
|
+
declare const richTextMediaIds: (document: RichTextDocument) => string[];
|
|
39
|
+
//#endregion
|
|
40
|
+
export { isEmptyRichText as a, RichTextResult as i, RichTextNode as n, richTextMediaIds as o, RichTextProblem as r, validateRichText as s, RichTextDocument as t };
|
|
41
|
+
//# sourceMappingURL=validate-Oyo0GJ9j.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shapio/schema",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shapio model definition types, validators, diff and schema file format.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://shapio.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mybrokengnome/shapio.git",
|
|
10
|
+
"directory": "packages/schema"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/mybrokengnome/shapio/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"@shapio/source": "./src/index.ts",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./html": {
|
|
26
|
+
"@shapio/source": "./src/html.ts",
|
|
27
|
+
"types": "./dist/html.d.ts",
|
|
28
|
+
"default": "./dist/html.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"parse5": "8.0.1",
|
|
36
|
+
"pluralize": "8.0.0",
|
|
37
|
+
"typebox": "1.3.34"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/pluralize": "0.0.33",
|
|
41
|
+
"tsdown": "0.23.0",
|
|
42
|
+
"typescript": "6.0.3",
|
|
43
|
+
"vitest": "5.0.3"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsdown",
|
|
50
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
51
|
+
}
|
|
52
|
+
}
|