@shop-on/theme-contract 0.4.0 → 0.6.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/ai-files.d.ts +18 -0
- package/dist/ai-files.js +47 -0
- package/dist/block-allowlist.d.ts +27 -0
- package/dist/block-allowlist.js +21 -0
- package/dist/closest-scope.d.ts +51 -0
- package/dist/closest-scope.js +66 -0
- package/dist/document.d.ts +13 -0
- package/dist/document.js +19 -5
- package/dist/editor-tools/apply.d.ts +85 -0
- package/dist/editor-tools/apply.js +544 -0
- package/dist/editor-tools/args.d.ts +153 -0
- package/dist/editor-tools/args.js +145 -0
- package/dist/editor-tools/coerce.d.ts +43 -0
- package/dist/editor-tools/coerce.js +200 -0
- package/dist/editor-tools/digest.d.ts +75 -0
- package/dist/editor-tools/digest.js +99 -0
- package/dist/editor-tools/index.d.ts +21 -0
- package/dist/editor-tools/index.js +24 -0
- package/dist/editor-tools/recipes.d.ts +122 -0
- package/dist/editor-tools/recipes.js +192 -0
- package/dist/fonts.d.ts +9 -0
- package/dist/fonts.js +11 -0
- package/dist/google-fonts.generated.d.ts +7 -0
- package/dist/google-fonts.generated.js +753 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.js +15 -6
- package/dist/link-url.d.ts +9 -0
- package/dist/link-url.js +33 -0
- package/dist/metafield-binding.d.ts +89 -9
- package/dist/metafield-binding.js +254 -43
- package/dist/ops.d.ts +85 -0
- package/dist/ops.js +442 -0
- package/dist/richtext.d.ts +23 -0
- package/dist/richtext.js +115 -1
- package/dist/schemas.d.ts +329 -0
- package/dist/schemas.js +62 -8
- package/dist/section-scope.d.ts +17 -0
- package/dist/section-scope.js +22 -0
- package/dist/store-path.d.ts +9 -0
- package/dist/store-path.js +13 -1
- package/dist/template-limits.d.ts +34 -0
- package/dist/template-limits.js +71 -0
- package/dist/video-url.d.ts +10 -0
- package/dist/video-url.js +36 -0
- package/package.json +8 -1
package/dist/ops.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { STORE_SLUG_RE, invalidTemplateId } from "./store-path.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/ops.ts
|
|
4
|
+
const MAX_PATH_DEPTH = 24;
|
|
5
|
+
const MAX_SEGMENT_LENGTH = 64;
|
|
6
|
+
const MAX_BATCH_OPS = 200;
|
|
7
|
+
const MAX_BATCH_BYTES = 65536;
|
|
8
|
+
const ROOT_FIELDS = {
|
|
9
|
+
settings: "settings",
|
|
10
|
+
template: "templates",
|
|
11
|
+
group: "groups"
|
|
12
|
+
};
|
|
13
|
+
const FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
|
|
14
|
+
"__proto__",
|
|
15
|
+
"constructor",
|
|
16
|
+
"prototype"
|
|
17
|
+
]);
|
|
18
|
+
/** Template keys the store carries beside the document; the server never holds them. */
|
|
19
|
+
const TEMPLATE_NON_DOCUMENT_KEYS = /* @__PURE__ */ new Set(["section_groups"]);
|
|
20
|
+
/** A group's document is exactly these; `name`, `type`, `defaults` are not. */
|
|
21
|
+
const GROUP_DOCUMENT_KEYS = /* @__PURE__ */ new Set(["sections", "order"]);
|
|
22
|
+
function isPlainObject(x) {
|
|
23
|
+
if (x === null || typeof x !== "object" || Array.isArray(x)) return false;
|
|
24
|
+
const proto = Object.getPrototypeOf(x);
|
|
25
|
+
return proto === Object.prototype || proto === null;
|
|
26
|
+
}
|
|
27
|
+
function deepEqual(a, b) {
|
|
28
|
+
if (a === b) return true;
|
|
29
|
+
if (Array.isArray(a)) {
|
|
30
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
31
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
32
|
+
}
|
|
33
|
+
if (isPlainObject(a)) {
|
|
34
|
+
if (!isPlainObject(b)) return false;
|
|
35
|
+
const ka = Object.keys(a);
|
|
36
|
+
if (ka.length !== Object.keys(b).length) return false;
|
|
37
|
+
return ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/** Where an op lands relative to the order-bearing containers it passes. */
|
|
42
|
+
function shapeOf(p) {
|
|
43
|
+
if (p[0] === "settings") return { kind: "settings" };
|
|
44
|
+
if (p.length === 2) return { kind: "entry" };
|
|
45
|
+
let container = {
|
|
46
|
+
path: p.slice(0, 2),
|
|
47
|
+
childKey: "sections",
|
|
48
|
+
orderKey: "order"
|
|
49
|
+
};
|
|
50
|
+
let i = 2;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const seg = p[i];
|
|
53
|
+
const last = i === p.length - 1;
|
|
54
|
+
if (seg === container.orderKey && last) return {
|
|
55
|
+
kind: "order",
|
|
56
|
+
container
|
|
57
|
+
};
|
|
58
|
+
if (seg === container.childKey) {
|
|
59
|
+
if (last) return {
|
|
60
|
+
kind: "children",
|
|
61
|
+
container
|
|
62
|
+
};
|
|
63
|
+
const childId = p[i + 1];
|
|
64
|
+
if (i + 2 === p.length) return {
|
|
65
|
+
kind: "child",
|
|
66
|
+
container,
|
|
67
|
+
childId
|
|
68
|
+
};
|
|
69
|
+
container = {
|
|
70
|
+
path: p.slice(0, i + 2),
|
|
71
|
+
childKey: "blocks",
|
|
72
|
+
orderKey: "block_order"
|
|
73
|
+
};
|
|
74
|
+
i += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
kind: "field",
|
|
79
|
+
container
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function toDocPath(p) {
|
|
84
|
+
const field = ROOT_FIELDS[p[0]];
|
|
85
|
+
if (!field || p.length < 2) return null;
|
|
86
|
+
return [field, ...p.slice(1)];
|
|
87
|
+
}
|
|
88
|
+
function getAt(root, docPath) {
|
|
89
|
+
let node = root;
|
|
90
|
+
for (const seg of docPath) {
|
|
91
|
+
if (!isPlainObject(node) || !Object.hasOwn(node, seg)) return { found: false };
|
|
92
|
+
node = node[seg];
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
found: true,
|
|
96
|
+
value: node
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const UNCHANGED = Symbol("unchanged");
|
|
100
|
+
/** `createMap`: a child set may create its missing parent `sections` / `blocks` map. */
|
|
101
|
+
function writeAt(node, docPath, i, op, createMap) {
|
|
102
|
+
if (!isPlainObject(node)) return UNCHANGED;
|
|
103
|
+
const key = docPath[i];
|
|
104
|
+
if (i === docPath.length - 1) {
|
|
105
|
+
if (Object.hasOwn(op, "v")) {
|
|
106
|
+
if (Object.hasOwn(node, key) && node[key] === op.v) return UNCHANGED;
|
|
107
|
+
return {
|
|
108
|
+
...node,
|
|
109
|
+
[key]: op.v
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (!Object.hasOwn(node, key)) return UNCHANGED;
|
|
113
|
+
const { [key]: _removed, ...rest } = node;
|
|
114
|
+
return rest;
|
|
115
|
+
}
|
|
116
|
+
const missing = !Object.hasOwn(node, key);
|
|
117
|
+
if (missing && !(createMap && i === docPath.length - 2)) return UNCHANGED;
|
|
118
|
+
const child = writeAt(missing ? {} : node[key], docPath, i + 1, op, createMap);
|
|
119
|
+
if (child === UNCHANGED) return UNCHANGED;
|
|
120
|
+
return {
|
|
121
|
+
...node,
|
|
122
|
+
[key]: child
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function applyOne(doc, op) {
|
|
126
|
+
if (op.p.some((seg) => FORBIDDEN_KEYS.has(seg))) return doc;
|
|
127
|
+
const docPath = toDocPath(op.p);
|
|
128
|
+
if (!docPath) return doc;
|
|
129
|
+
const out = writeAt(doc, docPath, 0, op, Object.hasOwn(op, "v") && shapeOf(op.p).kind === "child");
|
|
130
|
+
return out === UNCHANGED ? doc : out;
|
|
131
|
+
}
|
|
132
|
+
const pathKey = (p) => p.join("\0");
|
|
133
|
+
function isPrefix(prefix, p) {
|
|
134
|
+
return prefix.length <= p.length && prefix.every((seg, i) => seg === p[i]);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Applies a batch copy-on-write along each op's path, then normalises every
|
|
138
|
+
* container the batch touched: drops order ids with no node, strips static
|
|
139
|
+
* block ids, and re-appends ids that were in that order when the batch began,
|
|
140
|
+
* still have a node and were not deleted by the batch. Ids never in the prior
|
|
141
|
+
* order stay out: presets hide content by omitting it from `order`.
|
|
142
|
+
*/
|
|
143
|
+
function applyOps(doc, ops) {
|
|
144
|
+
if (ops.length === 0) return doc;
|
|
145
|
+
const touched = /* @__PURE__ */ new Map();
|
|
146
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
147
|
+
const wholeSets = [];
|
|
148
|
+
const touch = (container) => {
|
|
149
|
+
const key = pathKey(container.path);
|
|
150
|
+
if (!touched.has(key)) touched.set(key, {
|
|
151
|
+
container,
|
|
152
|
+
prior: null,
|
|
153
|
+
replaced: false
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
for (const op of ops) {
|
|
157
|
+
const isSet = Object.hasOwn(op, "v");
|
|
158
|
+
const shape = shapeOf(op.p);
|
|
159
|
+
if (shape.kind === "settings" || shape.kind === "field") continue;
|
|
160
|
+
if (shape.kind === "entry") {
|
|
161
|
+
if (isSet) {
|
|
162
|
+
wholeSets.push(op.p);
|
|
163
|
+
touch({
|
|
164
|
+
path: op.p,
|
|
165
|
+
childKey: "sections",
|
|
166
|
+
orderKey: "order"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
touch(shape.container);
|
|
172
|
+
if (shape.kind === "child") {
|
|
173
|
+
const childPath = [
|
|
174
|
+
...shape.container.path,
|
|
175
|
+
shape.container.childKey,
|
|
176
|
+
shape.childId
|
|
177
|
+
];
|
|
178
|
+
if (isSet) {
|
|
179
|
+
wholeSets.push(childPath);
|
|
180
|
+
touch({
|
|
181
|
+
path: childPath,
|
|
182
|
+
childKey: "blocks",
|
|
183
|
+
orderKey: "block_order"
|
|
184
|
+
});
|
|
185
|
+
} else deleted.add(pathKey(childPath));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const t of touched.values()) {
|
|
189
|
+
t.replaced = wholeSets.some((set) => isPrefix(set, t.container.path));
|
|
190
|
+
if (t.replaced) continue;
|
|
191
|
+
const docPath = toDocPath(t.container.path);
|
|
192
|
+
const node = docPath ? getAt(doc, docPath) : { found: false };
|
|
193
|
+
if (node.found && isPlainObject(node.value) && Array.isArray(node.value[t.container.orderKey])) t.prior = node.value[t.container.orderKey];
|
|
194
|
+
}
|
|
195
|
+
let out = doc;
|
|
196
|
+
for (const op of ops) out = applyOne(out, op);
|
|
197
|
+
for (const t of touched.values()) out = normalise(out, t, deleted);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
function normalise(doc, t, deleted) {
|
|
201
|
+
const { path, childKey, orderKey } = t.container;
|
|
202
|
+
const docPath = toDocPath(path);
|
|
203
|
+
if (!docPath) return doc;
|
|
204
|
+
const node = getAt(doc, docPath);
|
|
205
|
+
if (!node.found || !isPlainObject(node.value)) return doc;
|
|
206
|
+
const order = node.value[orderKey];
|
|
207
|
+
if (!Array.isArray(order)) return doc;
|
|
208
|
+
const children = isPlainObject(node.value[childKey]) ? node.value[childKey] : {};
|
|
209
|
+
const result = [];
|
|
210
|
+
const seen = /* @__PURE__ */ new Set();
|
|
211
|
+
const keep = (id) => typeof id === "string" && !seen.has(id) && Object.hasOwn(children, id) && isPlainObject(children[id]) && children[id].static !== true;
|
|
212
|
+
for (const id of order) if (keep(id)) {
|
|
213
|
+
seen.add(id);
|
|
214
|
+
result.push(id);
|
|
215
|
+
}
|
|
216
|
+
if (t.prior && !t.replaced) {
|
|
217
|
+
for (const id of t.prior) if (keep(id) && !deleted.has(pathKey([
|
|
218
|
+
...path,
|
|
219
|
+
childKey,
|
|
220
|
+
id
|
|
221
|
+
]))) {
|
|
222
|
+
seen.add(id);
|
|
223
|
+
result.push(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (result.length === order.length && result.every((id, i) => id === order[i])) return doc;
|
|
227
|
+
return applyOne(doc, {
|
|
228
|
+
p: [...path, orderKey],
|
|
229
|
+
v: result
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function unionKeys(a, b) {
|
|
233
|
+
const keys = Object.keys(b);
|
|
234
|
+
for (const k of Object.keys(a)) if (!Object.hasOwn(b, k)) keys.push(k);
|
|
235
|
+
return keys;
|
|
236
|
+
}
|
|
237
|
+
function diffLeafMap(path, a, b, ops) {
|
|
238
|
+
if (a === b) return;
|
|
239
|
+
for (const k of unionKeys(a, b)) if (!Object.hasOwn(b, k)) ops.push({ p: [...path, k] });
|
|
240
|
+
else if (!Object.hasOwn(a, k) || !deepEqual(a[k], b[k])) ops.push({
|
|
241
|
+
p: [...path, k],
|
|
242
|
+
v: b[k]
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function diffNode(path, a, b, childKey, orderKey, ops, isDocumentKey = () => true) {
|
|
246
|
+
if (a === b) return;
|
|
247
|
+
let orderOp = null;
|
|
248
|
+
for (const key of unionKeys(a, b)) {
|
|
249
|
+
if (!isDocumentKey(key)) continue;
|
|
250
|
+
const av = a[key];
|
|
251
|
+
const bv = b[key];
|
|
252
|
+
if (av === bv) continue;
|
|
253
|
+
const p = [...path, key];
|
|
254
|
+
let op = null;
|
|
255
|
+
if (!Object.hasOwn(b, key)) op = { p };
|
|
256
|
+
else if (key === childKey && isPlainObject(av) && isPlainObject(bv)) diffChildren(p, av, bv, ops);
|
|
257
|
+
else if (key === childKey && !Object.hasOwn(a, key) && isPlainObject(bv) && Object.keys(bv).length > 0) diffChildren(p, {}, bv, ops);
|
|
258
|
+
else if (key === "settings" && isPlainObject(av) && isPlainObject(bv)) diffLeafMap(p, av, bv, ops);
|
|
259
|
+
else if (!Object.hasOwn(a, key) || !deepEqual(av, bv)) op = {
|
|
260
|
+
p,
|
|
261
|
+
v: bv
|
|
262
|
+
};
|
|
263
|
+
if (!op) continue;
|
|
264
|
+
if (key === orderKey) orderOp = op;
|
|
265
|
+
else ops.push(op);
|
|
266
|
+
}
|
|
267
|
+
if (orderOp) ops.push(orderOp);
|
|
268
|
+
}
|
|
269
|
+
function diffChildren(path, a, b, ops) {
|
|
270
|
+
for (const id of unionKeys(a, b)) if (!Object.hasOwn(b, id)) ops.push({ p: [...path, id] });
|
|
271
|
+
else if (!Object.hasOwn(a, id)) ops.push({
|
|
272
|
+
p: [...path, id],
|
|
273
|
+
v: b[id]
|
|
274
|
+
});
|
|
275
|
+
else if (isPlainObject(a[id]) && isPlainObject(b[id])) diffNode([...path, id], a[id], b[id], "blocks", "block_order", ops);
|
|
276
|
+
else if (!deepEqual(a[id], b[id])) ops.push({
|
|
277
|
+
p: [...path, id],
|
|
278
|
+
v: b[id]
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function documentOf(root, entry) {
|
|
282
|
+
const out = {};
|
|
283
|
+
for (const [k, v] of Object.entries(entry)) if (isDocumentKey(root, k)) out[k] = v;
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
function isDocumentKey(root, key) {
|
|
287
|
+
return root === "group" ? GROUP_DOCUMENT_KEYS.has(key) : !TEMPLATE_NON_DOCUMENT_KEYS.has(key);
|
|
288
|
+
}
|
|
289
|
+
function diffEntries(root, a, b, ops) {
|
|
290
|
+
if (a === b) return;
|
|
291
|
+
for (const id of unionKeys(a, b)) if (!Object.hasOwn(b, id)) ops.push({ p: [root, id] });
|
|
292
|
+
else if (!Object.hasOwn(a, id)) ops.push({
|
|
293
|
+
p: [root, id],
|
|
294
|
+
v: documentOf(root, b[id])
|
|
295
|
+
});
|
|
296
|
+
else diffNode([root, id], a[id], b[id], "sections", "order", ops, (k) => isDocumentKey(root, k));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The ops that turn `prev` into `next`. Setting values, `order` and
|
|
300
|
+
* `block_order` are leaves (set whole); an added node is set whole, a removed
|
|
301
|
+
* one is one delete, a changed one is recursed. Short-circuits on `===`.
|
|
302
|
+
*/
|
|
303
|
+
function diffDocument(prev, next) {
|
|
304
|
+
if (prev === next) return [];
|
|
305
|
+
const ops = [];
|
|
306
|
+
diffLeafMap(["settings"], prev.settings, next.settings, ops);
|
|
307
|
+
diffEntries("template", prev.templates, next.templates, ops);
|
|
308
|
+
diffEntries("group", prev.groups, next.groups, ops);
|
|
309
|
+
return ops;
|
|
310
|
+
}
|
|
311
|
+
/** The ops that restore `prev` at exactly the paths `ops` touched. */
|
|
312
|
+
function invertOps(prev, ops) {
|
|
313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
314
|
+
const paths = [];
|
|
315
|
+
for (const op of ops) {
|
|
316
|
+
const key = pathKey(op.p);
|
|
317
|
+
if (seen.has(key)) continue;
|
|
318
|
+
seen.add(key);
|
|
319
|
+
paths.push(op.p);
|
|
320
|
+
}
|
|
321
|
+
paths.sort((a, b) => a.length - b.length);
|
|
322
|
+
return paths.map((p) => {
|
|
323
|
+
const docPath = toDocPath(p);
|
|
324
|
+
const at = docPath ? getAt(prev, docPath) : { found: false };
|
|
325
|
+
return at.found ? {
|
|
326
|
+
p,
|
|
327
|
+
v: at.value
|
|
328
|
+
} : { p };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const encoder = new TextEncoder();
|
|
332
|
+
/** The entries `ops` write, added to `into` when given (a log read batch by
|
|
333
|
+
* batch). */
|
|
334
|
+
function touchedEntries(ops, into = {
|
|
335
|
+
settings: false,
|
|
336
|
+
templates: /* @__PURE__ */ new Set(),
|
|
337
|
+
groups: /* @__PURE__ */ new Set()
|
|
338
|
+
}) {
|
|
339
|
+
for (const { p } of ops) if (p[0] === "settings") into.settings = true;
|
|
340
|
+
else if (p[0] === "template") into.templates.add(p[1]);
|
|
341
|
+
else into.groups.add(p[1]);
|
|
342
|
+
return into;
|
|
343
|
+
}
|
|
344
|
+
const BODY_ENVELOPE_BYTES = 512;
|
|
345
|
+
/**
|
|
346
|
+
* Splits ops into batches within the op and byte caps, keeping their order:
|
|
347
|
+
* `diffDocument` emits a container's new nodes before its order, so an order
|
|
348
|
+
* op never lands in an earlier batch than its nodes. An op larger than the cap
|
|
349
|
+
* travels alone (and the server refuses it).
|
|
350
|
+
*/
|
|
351
|
+
function chunkOps(ops, maxBytes = MAX_BATCH_BYTES, maxOps = 200) {
|
|
352
|
+
const budget = maxBytes - BODY_ENVELOPE_BYTES;
|
|
353
|
+
const chunks = [];
|
|
354
|
+
let current = [];
|
|
355
|
+
let bytes = 2;
|
|
356
|
+
for (const op of ops) {
|
|
357
|
+
const size = encoder.encode(JSON.stringify(op)).length + 1;
|
|
358
|
+
if (current.length > 0 && (current.length >= maxOps || bytes + size > budget)) {
|
|
359
|
+
chunks.push(current);
|
|
360
|
+
current = [];
|
|
361
|
+
bytes = 2;
|
|
362
|
+
}
|
|
363
|
+
current.push(op);
|
|
364
|
+
bytes += size;
|
|
365
|
+
}
|
|
366
|
+
if (current.length > 0) chunks.push(current);
|
|
367
|
+
return chunks;
|
|
368
|
+
}
|
|
369
|
+
const Segment = z.string().min(1).max(64).refine((s) => s.trim() === s, "segment must not be padded").refine((s) => !FORBIDDEN_KEYS.has(s), "forbidden segment");
|
|
370
|
+
function hasForbiddenKey(value) {
|
|
371
|
+
const stack = [value];
|
|
372
|
+
while (stack.length > 0) {
|
|
373
|
+
const node = stack.pop();
|
|
374
|
+
if (Array.isArray(node)) stack.push(...node);
|
|
375
|
+
else if (node !== null && typeof node === "object") for (const [k, v] of Object.entries(node)) {
|
|
376
|
+
if (FORBIDDEN_KEYS.has(k)) return true;
|
|
377
|
+
stack.push(v);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
const isIdList = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
383
|
+
/** A section or block, or a whole entry: an object whose children (if any)
|
|
384
|
+
* are nodes and whose order (if any) is a list of ids, all the way down. The
|
|
385
|
+
* theme renders what the draft holds, so one malformed node must never land:
|
|
386
|
+
* it would break every page of the install for every peer. */
|
|
387
|
+
function isContainer(v, childKey, orderKey) {
|
|
388
|
+
if (!isPlainObject(v)) return false;
|
|
389
|
+
if (Object.hasOwn(v, orderKey) && !isIdList(v[orderKey])) return false;
|
|
390
|
+
if (!Object.hasOwn(v, childKey)) return true;
|
|
391
|
+
const children = v[childKey];
|
|
392
|
+
return isPlainObject(children) && Object.values(children).every((c) => isContainer(c, "blocks", "block_order"));
|
|
393
|
+
}
|
|
394
|
+
function validValue(p, v) {
|
|
395
|
+
switch (shapeOf(p).kind) {
|
|
396
|
+
case "settings":
|
|
397
|
+
case "field": return null;
|
|
398
|
+
case "order": return isIdList(v) ? null : "order must be a list of ids";
|
|
399
|
+
case "entry": return isContainer(v, "sections", "order") ? null : "an entry must be a template or group";
|
|
400
|
+
case "child": return isContainer(v, "blocks", "block_order") ? null : "a node must be an object";
|
|
401
|
+
case "children": return isPlainObject(v) && Object.values(v).every((c) => isContainer(c, "blocks", "block_order")) ? null : "children must be an object of nodes";
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const OpSchema = z.object({
|
|
405
|
+
p: z.array(Segment).min(2).max(24),
|
|
406
|
+
v: z.unknown().optional()
|
|
407
|
+
}).strict().superRefine((op, ctx) => {
|
|
408
|
+
const [root, id] = op.p;
|
|
409
|
+
const fail = (message) => ctx.addIssue({
|
|
410
|
+
code: "custom",
|
|
411
|
+
message,
|
|
412
|
+
path: ["p"]
|
|
413
|
+
});
|
|
414
|
+
if (root === "settings") {
|
|
415
|
+
if (op.p.length !== 2) fail("a settings path is one segment deep");
|
|
416
|
+
} else if (root === "template") {
|
|
417
|
+
if (invalidTemplateId(id) || id === "__seeded") fail("invalid template id");
|
|
418
|
+
} else if (root === "group") {
|
|
419
|
+
if (!STORE_SLUG_RE.test(id)) fail("invalid group type");
|
|
420
|
+
} else fail("unknown root");
|
|
421
|
+
if (!Object.hasOwn(op, "v")) return;
|
|
422
|
+
if (hasForbiddenKey(op.v)) ctx.addIssue({
|
|
423
|
+
code: "custom",
|
|
424
|
+
message: "forbidden key in value",
|
|
425
|
+
path: ["v"]
|
|
426
|
+
});
|
|
427
|
+
const invalid = validValue(op.p, op.v);
|
|
428
|
+
if (invalid) ctx.addIssue({
|
|
429
|
+
code: "custom",
|
|
430
|
+
message: invalid,
|
|
431
|
+
path: ["v"]
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
/** The body of `POST /draft/ops`. The byte cap is the route's body limit. */
|
|
435
|
+
const OpBatchBodySchema = z.object({
|
|
436
|
+
clientId: z.string().min(1).max(64),
|
|
437
|
+
batchNo: z.number().int().positive(),
|
|
438
|
+
batchId: z.string().min(1).max(64),
|
|
439
|
+
ops: z.array(OpSchema).min(1).max(200)
|
|
440
|
+
}).strict();
|
|
441
|
+
//#endregion
|
|
442
|
+
export { MAX_BATCH_BYTES, MAX_BATCH_OPS, MAX_PATH_DEPTH, MAX_SEGMENT_LENGTH, OpBatchBodySchema, OpSchema, applyOps, chunkOps, diffDocument, invertOps, touchedEntries };
|
package/dist/richtext.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
//#region src/richtext.d.ts
|
|
2
2
|
export declare const RICHTEXT_ALLOWED_TAGS: readonly ["p", "h1", "h2", "h3", "h4", "h5", "h6", "strong", "em", "u", "a", "span", "mark", "ul", "ol", "li", "br", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td"];
|
|
3
3
|
export type RichtextTag = (typeof RICHTEXT_ALLOWED_TAGS)[number];
|
|
4
|
+
export declare const RICHTEXT_SANITIZE_CONFIG: {
|
|
5
|
+
ALLOWED_TAGS: ("a" | "br" | "em" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "img" | "li" | "mark" | "ol" | "p" | "span" | "strong" | "table" | "tbody" | "td" | "tfoot" | "th" | "thead" | "tr" | "u" | "ul")[];
|
|
6
|
+
ALLOWED_ATTR: string[];
|
|
7
|
+
ALLOWED_URI_REGEXP: RegExp;
|
|
8
|
+
ADD_URI_SAFE_ATTR: string[];
|
|
9
|
+
FORBID_ATTR: string[];
|
|
10
|
+
};
|
|
11
|
+
/** The slice of a DOMPurify instance the hooks need, so this package carries
|
|
12
|
+
* no DOM dependency. */
|
|
13
|
+
export interface RichTextPurifier {
|
|
14
|
+
addHook(entryPoint: string, hook: (...args: unknown[]) => void): void;
|
|
15
|
+
}
|
|
16
|
+
/** Registers the two hooks `RICHTEXT_SANITIZE_CONFIG` relies on. Call once per
|
|
17
|
+
* DOMPurify instance, before its first `sanitize`. */
|
|
18
|
+
export declare function registerRichTextHooks(purify: RichTextPurifier): void;
|
|
19
|
+
export declare const RICHTEXT_FIELD_TAGS: {
|
|
20
|
+
readonly richtext: readonly ["p", "h1", "h2", "h3", "h4", "h5", "h6", "strong", "em", "u", "a", "ul", "ol", "li", "br"];
|
|
21
|
+
readonly inline_richtext: readonly ["strong", "em", "a"];
|
|
22
|
+
};
|
|
23
|
+
/** Strip inline-token sentinel delimiters from a string. */
|
|
24
|
+
export declare function stripInlineTokenSentinels(value: string): string;
|
|
25
|
+
/** True when a rich-text value would put something on screen. */
|
|
26
|
+
export declare function hasRichText(html: string | null | undefined): html is string;
|
|
4
27
|
//#endregion
|
package/dist/richtext.js
CHANGED
|
@@ -26,5 +26,119 @@ const RICHTEXT_ALLOWED_TAGS = [
|
|
|
26
26
|
"th",
|
|
27
27
|
"td"
|
|
28
28
|
];
|
|
29
|
+
const RICHTEXT_SANITIZE_CONFIG = {
|
|
30
|
+
ALLOWED_TAGS: [...RICHTEXT_ALLOWED_TAGS],
|
|
31
|
+
ALLOWED_ATTR: [
|
|
32
|
+
"href",
|
|
33
|
+
"rel",
|
|
34
|
+
"target",
|
|
35
|
+
"data-color",
|
|
36
|
+
"data-bg-color",
|
|
37
|
+
"data-page-ref",
|
|
38
|
+
"data-dyn",
|
|
39
|
+
"data-label",
|
|
40
|
+
"data-mention",
|
|
41
|
+
"src",
|
|
42
|
+
"alt",
|
|
43
|
+
"title",
|
|
44
|
+
"width",
|
|
45
|
+
"height",
|
|
46
|
+
"colspan",
|
|
47
|
+
"rowspan"
|
|
48
|
+
],
|
|
49
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|\/(?!\/)|#)/i,
|
|
50
|
+
ADD_URI_SAFE_ATTR: [
|
|
51
|
+
"width",
|
|
52
|
+
"height",
|
|
53
|
+
"colspan",
|
|
54
|
+
"rowspan",
|
|
55
|
+
"data-dyn",
|
|
56
|
+
"data-label",
|
|
57
|
+
"data-mention"
|
|
58
|
+
],
|
|
59
|
+
FORBID_ATTR: [
|
|
60
|
+
"class",
|
|
61
|
+
"style",
|
|
62
|
+
"id"
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
const COLOR_STYLE_RE = /^(color|background-color)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;?\s*$/;
|
|
66
|
+
const STYLE_TO_DATA_ATTR = {
|
|
67
|
+
color: "data-color",
|
|
68
|
+
"background-color": "data-bg-color"
|
|
69
|
+
};
|
|
70
|
+
const EXTERNAL_HTTP_RE = /^https?:/i;
|
|
71
|
+
const REWRITABLE_TAGS = /* @__PURE__ */ new Set(["SPAN", "MARK"]);
|
|
72
|
+
const ELEMENT_NODE = 1;
|
|
73
|
+
/** Registers the two hooks `RICHTEXT_SANITIZE_CONFIG` relies on. Call once per
|
|
74
|
+
* DOMPurify instance, before its first `sanitize`. */
|
|
75
|
+
function registerRichTextHooks(purify) {
|
|
76
|
+
purify.addHook("uponSanitizeElement", (raw) => {
|
|
77
|
+
const node = raw;
|
|
78
|
+
if (node.nodeType !== ELEMENT_NODE) return;
|
|
79
|
+
if (!REWRITABLE_TAGS.has(node.tagName)) return;
|
|
80
|
+
const style = node.getAttribute("style");
|
|
81
|
+
if (!style) return;
|
|
82
|
+
const match = style.match(COLOR_STYLE_RE);
|
|
83
|
+
if (!match) return;
|
|
84
|
+
node.removeAttribute("style");
|
|
85
|
+
node.setAttribute(STYLE_TO_DATA_ATTR[match[1]], match[2]);
|
|
86
|
+
});
|
|
87
|
+
purify.addHook("uponSanitizeAttribute", (rawNode, rawData) => {
|
|
88
|
+
const node = rawNode;
|
|
89
|
+
const data = rawData;
|
|
90
|
+
if (data.attrName === "data-page-ref" && node.tagName !== "A") {
|
|
91
|
+
data.keepAttr = false;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if ((data.attrName === "data-dyn" || data.attrName === "data-label" || data.attrName === "data-mention") && node.tagName !== "SPAN") {
|
|
95
|
+
data.keepAttr = false;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (node.tagName !== "A") return;
|
|
99
|
+
if (data.attrName !== "href") return;
|
|
100
|
+
if (!EXTERNAL_HTTP_RE.test(data.attrValue)) return;
|
|
101
|
+
node.setAttribute("rel", "noopener noreferrer nofollow");
|
|
102
|
+
node.setAttribute("target", "_blank");
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const RICHTEXT_FIELD_TAGS = {
|
|
106
|
+
richtext: [
|
|
107
|
+
"p",
|
|
108
|
+
"h1",
|
|
109
|
+
"h2",
|
|
110
|
+
"h3",
|
|
111
|
+
"h4",
|
|
112
|
+
"h5",
|
|
113
|
+
"h6",
|
|
114
|
+
"strong",
|
|
115
|
+
"em",
|
|
116
|
+
"u",
|
|
117
|
+
"a",
|
|
118
|
+
"ul",
|
|
119
|
+
"ol",
|
|
120
|
+
"li",
|
|
121
|
+
"br"
|
|
122
|
+
],
|
|
123
|
+
inline_richtext: [
|
|
124
|
+
"strong",
|
|
125
|
+
"em",
|
|
126
|
+
"a"
|
|
127
|
+
]
|
|
128
|
+
};
|
|
129
|
+
const INLINE_TOKEN_DELIMITERS = /[]/g;
|
|
130
|
+
/** Strip inline-token sentinel delimiters from a string. */
|
|
131
|
+
function stripInlineTokenSentinels(value) {
|
|
132
|
+
return value.replace(INLINE_TOKEN_DELIMITERS, "");
|
|
133
|
+
}
|
|
134
|
+
const EMBEDDED_RE = /<(img|iframe|video|hr)\b/i;
|
|
135
|
+
const TAG_RE = /<[^>]*>/g;
|
|
136
|
+
const NBSP_RE = / | | /gi;
|
|
137
|
+
/** True when a rich-text value would put something on screen. */
|
|
138
|
+
function hasRichText(html) {
|
|
139
|
+
if (!html) return false;
|
|
140
|
+
if (EMBEDDED_RE.test(html)) return true;
|
|
141
|
+
return html.replace(TAG_RE, " ").replace(NBSP_RE, " ").trim() !== "";
|
|
142
|
+
}
|
|
29
143
|
//#endregion
|
|
30
|
-
export { RICHTEXT_ALLOWED_TAGS };
|
|
144
|
+
export { RICHTEXT_ALLOWED_TAGS, RICHTEXT_FIELD_TAGS, RICHTEXT_SANITIZE_CONFIG, hasRichText, registerRichTextHooks, stripInlineTokenSentinels };
|