@taprootio/site-authoring 0.1.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 +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import registry from "./free-form-section-registry.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_ERROR_CODES as CODES,
|
|
5
|
+
CONTENT_LIMITS,
|
|
6
|
+
contentError,
|
|
7
|
+
identifier,
|
|
8
|
+
isPlainObject,
|
|
9
|
+
isUuid,
|
|
10
|
+
pointerSegment,
|
|
11
|
+
} from "./vocabulary.js";
|
|
12
|
+
|
|
13
|
+
function deepFreeze(value) {
|
|
14
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
15
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
16
|
+
return Object.freeze(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const FREE_FORM_SECTION_REGISTRY = deepFreeze(registry);
|
|
20
|
+
|
|
21
|
+
function decorationDeliveryUrlIsSafe(value) {
|
|
22
|
+
if (typeof value !== "string" || value === "" || /[\u0000-\u0020\u007f]/u.test(value) || value.includes("\\")) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (value.startsWith("/")) return !value.startsWith("//");
|
|
26
|
+
if (!/^https:\/\//iu.test(value)) return false;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = new URL(value);
|
|
29
|
+
return parsed.protocol === "https:"
|
|
30
|
+
&& parsed.hostname !== ""
|
|
31
|
+
&& parsed.username === ""
|
|
32
|
+
&& parsed.password === "";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeProcessedSectionImage(value, definition, path, label, report) {
|
|
39
|
+
let valid = true;
|
|
40
|
+
const reject = (errorPath, code, message) => {
|
|
41
|
+
valid = false;
|
|
42
|
+
report(errorPath, code, message);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (!isPlainObject(value)) {
|
|
46
|
+
reject(path, CODES.attrInvalid, `${label} must be an object.`);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const imageKeys = [...definition.requiredKeys, ...definition.optionalKeys];
|
|
51
|
+
for (const key of Object.keys(value)) {
|
|
52
|
+
if (!imageKeys.includes(key)) {
|
|
53
|
+
reject(
|
|
54
|
+
`${path}/${pointerSegment(key)}`,
|
|
55
|
+
CODES.attrUnknown,
|
|
56
|
+
`${label} field '${identifier(key)}' is not part of the processed-image shape.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const key of definition.requiredKeys) {
|
|
61
|
+
if (!Object.hasOwn(value, key)) {
|
|
62
|
+
reject(
|
|
63
|
+
`${path}/${key}`,
|
|
64
|
+
CODES.imageKeys,
|
|
65
|
+
`${label} must include '${key}' so ownership and delivery rewriting can resolve it.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (Object.hasOwn(value, "imageId") && !isUuid(value.imageId)) {
|
|
71
|
+
reject(`${path}/imageId`, CODES.attrInvalid, `${label} imageId must be a canonical lowercase UUID.`);
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(value, "src")) {
|
|
74
|
+
if (typeof value.src !== "string") {
|
|
75
|
+
reject(`${path}/src`, CODES.attrInvalid, `${label} src must be a string.`);
|
|
76
|
+
} else if (!decorationDeliveryUrlIsSafe(value.src)) {
|
|
77
|
+
reject(
|
|
78
|
+
`${path}/src`,
|
|
79
|
+
value.src === "" ? CODES.imageKeys : CODES.attrInvalid,
|
|
80
|
+
value.src === ""
|
|
81
|
+
? `${label} src has no usable delivery URL. Use the complete processed-image object returned by media upload.`
|
|
82
|
+
: `${label} src must be an HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (Object.hasOwn(value, "urls")) {
|
|
88
|
+
if (!Array.isArray(value.urls)) {
|
|
89
|
+
reject(`${path}/urls`, CODES.attrInvalid, `${label} urls must be an array.`);
|
|
90
|
+
} else {
|
|
91
|
+
if (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems) {
|
|
92
|
+
reject(
|
|
93
|
+
`${path}/urls`,
|
|
94
|
+
CODES.attrInvalid,
|
|
95
|
+
`${label} urls must contain ${definition.urls.minItems} through ${definition.urls.maxItems} responsive candidates.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (let index = 0; index < value.urls.length; index += 1) {
|
|
99
|
+
const option = value.urls[index];
|
|
100
|
+
const optionPath = `${path}/urls/${index}`;
|
|
101
|
+
if (!isPlainObject(option)) {
|
|
102
|
+
reject(optionPath, CODES.attrInvalid, `A responsive ${label.toLowerCase()} URL must be an object.`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const key of Object.keys(option)) {
|
|
106
|
+
if (!Object.hasOwn(definition.urls.fields, key)) {
|
|
107
|
+
reject(
|
|
108
|
+
`${optionPath}/${pointerSegment(key)}`,
|
|
109
|
+
CODES.attrUnknown,
|
|
110
|
+
`Responsive ${label.toLowerCase()} field '${identifier(key)}' is not supported.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
typeof option.minWidth !== "number"
|
|
116
|
+
|| !Number.isInteger(option.minWidth)
|
|
117
|
+
|| option.minWidth < definition.urls.fields.minWidth.minimum
|
|
118
|
+
) {
|
|
119
|
+
reject(`${optionPath}/minWidth`, CODES.attrInvalid, `Responsive ${label.toLowerCase()} minWidth must be a positive integer.`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof option.url !== "string" || !decorationDeliveryUrlIsSafe(option.url)) {
|
|
122
|
+
reject(
|
|
123
|
+
`${optionPath}/url`,
|
|
124
|
+
CODES.attrInvalid,
|
|
125
|
+
`A responsive ${label.toLowerCase()} URL must be HTTPS or a non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
option.type !== undefined
|
|
130
|
+
&& (typeof option.type !== "string" || !definition.urls.fields.type.values.includes(option.type))
|
|
131
|
+
) {
|
|
132
|
+
reject(
|
|
133
|
+
`${optionPath}/type`,
|
|
134
|
+
CODES.attrInvalid,
|
|
135
|
+
`Responsive ${label.toLowerCase()} type must be ${definition.urls.fields.type.values.map((entry) => JSON.stringify(entry)).join(" or ")} when present.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
definition.srcMustMatchUrls
|
|
141
|
+
&& typeof value.src === "string"
|
|
142
|
+
&& !value.urls.some((option) => isPlainObject(option) && option.url === value.src)
|
|
143
|
+
) {
|
|
144
|
+
reject(`${path}/src`, CODES.attrInvalid, `${label} src must equal one of its responsive urls[].url candidates.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const name of ["width", "height"]) {
|
|
150
|
+
if (
|
|
151
|
+
Object.hasOwn(value, name)
|
|
152
|
+
&& (typeof value[name] !== "number" || !Number.isInteger(value[name]) || value[name] <= 0)
|
|
153
|
+
) {
|
|
154
|
+
reject(`${path}/${name}`, CODES.attrInvalid, `${label} ${name} must be a positive integer.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (Object.hasOwn(value, "alt") && typeof value.alt !== "string") {
|
|
158
|
+
reject(`${path}/alt`, CODES.attrInvalid, `${label} alt must be a string.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!valid) return undefined;
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
imageId: value.imageId,
|
|
164
|
+
src: value.src,
|
|
165
|
+
urls: Object.freeze(value.urls.map((option) => Object.freeze({
|
|
166
|
+
minWidth: option.minWidth,
|
|
167
|
+
url: option.url,
|
|
168
|
+
...(option.type === undefined ? {} : { type: option.type }),
|
|
169
|
+
}))),
|
|
170
|
+
...(Object.hasOwn(value, "width") ? { width: value.width } : {}),
|
|
171
|
+
...(Object.hasOwn(value, "height") ? { height: value.height } : {}),
|
|
172
|
+
...(Object.hasOwn(value, "alt") ? { alt: value.alt } : {}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeSectionFocus(value, definition, path, label, report) {
|
|
177
|
+
if (value === undefined) value = definition.default;
|
|
178
|
+
if (value === null && definition.nullable === true) return null;
|
|
179
|
+
if (!isPlainObject(value)) {
|
|
180
|
+
report(path, CODES.attrInvalid, `${label} must be a closed { x, y } point.`);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let valid = true;
|
|
185
|
+
for (const key of Object.keys(value)) {
|
|
186
|
+
if (!Object.hasOwn(definition.fields, key)) {
|
|
187
|
+
valid = false;
|
|
188
|
+
report(`${path}/${pointerSegment(key)}`, CODES.attrUnknown, `${label} field '${identifier(key)}' is not supported.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const normalized = {};
|
|
192
|
+
for (const [name, coordinate] of Object.entries(definition.fields)) {
|
|
193
|
+
const entry = Object.hasOwn(value, name) ? value[name] : coordinate.default;
|
|
194
|
+
if (
|
|
195
|
+
typeof entry !== "number"
|
|
196
|
+
|| !Number.isFinite(entry)
|
|
197
|
+
|| entry < coordinate.minimum
|
|
198
|
+
|| entry > coordinate.maximum
|
|
199
|
+
) {
|
|
200
|
+
valid = false;
|
|
201
|
+
report(
|
|
202
|
+
`${path}/${name}`,
|
|
203
|
+
CODES.attrInvalid,
|
|
204
|
+
`${label} ${name} must be a finite number from ${coordinate.minimum} through ${coordinate.maximum}.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
normalized[name] = entry;
|
|
208
|
+
}
|
|
209
|
+
return valid ? Object.freeze(normalized) : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Validate and canonicalize the registry-owned section photo substrate. */
|
|
213
|
+
export function normalizeFreeFormSectionBackground(
|
|
214
|
+
value,
|
|
215
|
+
path = "/attrs/background",
|
|
216
|
+
source = FREE_FORM_SECTION_REGISTRY,
|
|
217
|
+
) {
|
|
218
|
+
const errors = [];
|
|
219
|
+
const report = (errorPath, code, message) => {
|
|
220
|
+
if (errors.length < CONTENT_LIMITS.documentErrors) errors.push(contentError(errorPath, code, message));
|
|
221
|
+
};
|
|
222
|
+
const finish = (background) => Object.freeze({ background, errors: Object.freeze(errors) });
|
|
223
|
+
|
|
224
|
+
if (value === undefined || value === null) return finish(undefined);
|
|
225
|
+
if (!isPlainObject(value)) {
|
|
226
|
+
report(path, CODES.attrInvalid, "Section background must be an object when present.");
|
|
227
|
+
return finish(undefined);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fields = source.section.attrs.background.fields;
|
|
231
|
+
for (const key of Object.keys(value)) {
|
|
232
|
+
if (!Object.hasOwn(fields, key)) {
|
|
233
|
+
report(`${path}/${pointerSegment(key)}`, CODES.attrUnknown, `Section background field '${identifier(key)}' is not supported.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!Object.hasOwn(value, "image")) {
|
|
238
|
+
report(`${path}/image`, CODES.imageKeys, "Section background must include a site-owned processed image.");
|
|
239
|
+
}
|
|
240
|
+
const image = Object.hasOwn(value, "image")
|
|
241
|
+
? normalizeProcessedSectionImage(value.image, fields.image, `${path}/image`, "Section background image", report)
|
|
242
|
+
: undefined;
|
|
243
|
+
const portraitImage = value.portraitImage === undefined || value.portraitImage === null
|
|
244
|
+
? null
|
|
245
|
+
: normalizeProcessedSectionImage(
|
|
246
|
+
value.portraitImage,
|
|
247
|
+
fields.portraitImage,
|
|
248
|
+
`${path}/portraitImage`,
|
|
249
|
+
"Section background portrait image",
|
|
250
|
+
report,
|
|
251
|
+
);
|
|
252
|
+
const focus = normalizeSectionFocus(value.focus, fields.focus, `${path}/focus`, "Section background focus", report);
|
|
253
|
+
const portraitFocus = normalizeSectionFocus(
|
|
254
|
+
value.portraitFocus,
|
|
255
|
+
fields.portraitFocus,
|
|
256
|
+
`${path}/portraitFocus`,
|
|
257
|
+
"Section background portrait focus",
|
|
258
|
+
report,
|
|
259
|
+
);
|
|
260
|
+
const scrimStrength = Object.hasOwn(value, "scrimStrength")
|
|
261
|
+
? value.scrimStrength
|
|
262
|
+
: fields.scrimStrength.default;
|
|
263
|
+
if (typeof scrimStrength !== "string" || !fields.scrimStrength.values.includes(scrimStrength)) {
|
|
264
|
+
report(
|
|
265
|
+
`${path}/scrimStrength`,
|
|
266
|
+
CODES.attrInvalid,
|
|
267
|
+
`Section background scrimStrength must be one of ${fields.scrimStrength.values.map((item) => JSON.stringify(item)).join(", ")}.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (errors.length > 0 || image === undefined || focus === undefined || portraitFocus === undefined) {
|
|
272
|
+
return finish(undefined);
|
|
273
|
+
}
|
|
274
|
+
return finish(Object.freeze({
|
|
275
|
+
image,
|
|
276
|
+
portraitImage,
|
|
277
|
+
focus,
|
|
278
|
+
portraitFocus,
|
|
279
|
+
scrimStrength,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validate and canonicalize one section decoration from the registry.
|
|
285
|
+
*
|
|
286
|
+
* The returned image keeps the standard `imageId`/`src`/`urls` delivery keys:
|
|
287
|
+
* the API's ownership rewriter only replaces keys already present in the
|
|
288
|
+
* document. URLs are still restricted here because pulled `.pm.json` files and
|
|
289
|
+
* Markdown headers are untrusted authoring input, even though an owned image is
|
|
290
|
+
* re-enriched before the generator sees it.
|
|
291
|
+
*/
|
|
292
|
+
export function normalizeFreeFormSectionDecoration(
|
|
293
|
+
value,
|
|
294
|
+
path = "/attrs/decoration",
|
|
295
|
+
source = FREE_FORM_SECTION_REGISTRY,
|
|
296
|
+
) {
|
|
297
|
+
const errors = [];
|
|
298
|
+
const report = (errorPath, code, message) => {
|
|
299
|
+
if (errors.length < CONTENT_LIMITS.documentErrors) errors.push(contentError(errorPath, code, message));
|
|
300
|
+
};
|
|
301
|
+
const finish = (decoration) => Object.freeze({
|
|
302
|
+
decoration,
|
|
303
|
+
errors: Object.freeze(errors),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (value === undefined || value === null) return finish(undefined);
|
|
307
|
+
if (!isPlainObject(value)) {
|
|
308
|
+
report(path, CODES.attrInvalid, "Section decoration must be an object when present.");
|
|
309
|
+
return finish(undefined);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const fields = source.section.attrs.decoration.fields;
|
|
313
|
+
for (const key of Object.keys(value)) {
|
|
314
|
+
if (errors.length >= CONTENT_LIMITS.documentErrors) break;
|
|
315
|
+
if (!Object.hasOwn(fields, key)) {
|
|
316
|
+
report(
|
|
317
|
+
`${path}/${pointerSegment(key)}`,
|
|
318
|
+
CODES.attrUnknown,
|
|
319
|
+
`Section decoration field '${identifier(key)}' is not supported.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const imagePath = `${path}/image`;
|
|
325
|
+
const imageDefinition = fields.image;
|
|
326
|
+
const image = value.image;
|
|
327
|
+
if (!Object.hasOwn(value, "image")) {
|
|
328
|
+
report(imagePath, CODES.imageKeys, "Section decoration must include a site-owned processed image.");
|
|
329
|
+
} else if (!isPlainObject(image)) {
|
|
330
|
+
report(imagePath, CODES.attrInvalid, "Section decoration image must be an object.");
|
|
331
|
+
} else {
|
|
332
|
+
const imageKeys = [...imageDefinition.requiredKeys, ...imageDefinition.optionalKeys];
|
|
333
|
+
for (const key of Object.keys(image)) {
|
|
334
|
+
if (errors.length >= CONTENT_LIMITS.documentErrors) break;
|
|
335
|
+
if (!imageKeys.includes(key)) {
|
|
336
|
+
report(
|
|
337
|
+
`${imagePath}/${pointerSegment(key)}`,
|
|
338
|
+
CODES.attrUnknown,
|
|
339
|
+
`Section decoration image field '${identifier(key)}' is not part of the processed-image shape.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const key of imageDefinition.requiredKeys) {
|
|
344
|
+
if (!Object.hasOwn(image, key)) {
|
|
345
|
+
report(
|
|
346
|
+
`${imagePath}/${key}`,
|
|
347
|
+
CODES.imageKeys,
|
|
348
|
+
`Section decoration image must include '${key}' so ownership and delivery rewriting can resolve it.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (Object.hasOwn(image, "imageId") && !isUuid(image.imageId)) {
|
|
354
|
+
report(`${imagePath}/imageId`, CODES.attrInvalid, "Section decoration imageId must be a canonical lowercase UUID.");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (Object.hasOwn(image, "src")) {
|
|
358
|
+
if (typeof image.src !== "string") {
|
|
359
|
+
report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must be a string.");
|
|
360
|
+
} else if (!decorationDeliveryUrlIsSafe(image.src)) {
|
|
361
|
+
report(
|
|
362
|
+
`${imagePath}/src`,
|
|
363
|
+
image.src === "" ? CODES.imageKeys : CODES.attrInvalid,
|
|
364
|
+
image.src === ""
|
|
365
|
+
? "Section decoration image src has no usable delivery URL. Use the complete processed-image object returned by media upload."
|
|
366
|
+
: "Section decoration image src must be an HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (Object.hasOwn(image, "urls")) {
|
|
372
|
+
if (!Array.isArray(image.urls)) {
|
|
373
|
+
report(`${imagePath}/urls`, CODES.attrInvalid, "Section decoration image urls must be an array.");
|
|
374
|
+
} else {
|
|
375
|
+
if (image.urls.length < imageDefinition.urls.minItems || image.urls.length > imageDefinition.urls.maxItems) {
|
|
376
|
+
report(
|
|
377
|
+
`${imagePath}/urls`,
|
|
378
|
+
CODES.attrInvalid,
|
|
379
|
+
`Section decoration image urls must contain ${imageDefinition.urls.minItems} through ${imageDefinition.urls.maxItems} responsive candidates.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
for (let index = 0; index < image.urls.length && errors.length < CONTENT_LIMITS.documentErrors; index += 1) {
|
|
383
|
+
const option = image.urls[index];
|
|
384
|
+
const optionPath = `${imagePath}/urls/${index}`;
|
|
385
|
+
if (!isPlainObject(option)) {
|
|
386
|
+
report(optionPath, CODES.attrInvalid, "A responsive decoration image URL must be an object.");
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (const key of Object.keys(option)) {
|
|
390
|
+
if (!Object.hasOwn(imageDefinition.urls.fields, key)) {
|
|
391
|
+
report(
|
|
392
|
+
`${optionPath}/${pointerSegment(key)}`,
|
|
393
|
+
CODES.attrUnknown,
|
|
394
|
+
`Responsive decoration image field '${identifier(key)}' is not supported.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (
|
|
399
|
+
typeof option.minWidth !== "number"
|
|
400
|
+
|| !Number.isInteger(option.minWidth)
|
|
401
|
+
|| option.minWidth < imageDefinition.urls.fields.minWidth.minimum
|
|
402
|
+
) {
|
|
403
|
+
report(`${optionPath}/minWidth`, CODES.attrInvalid, "Responsive decoration minWidth must be a positive integer.");
|
|
404
|
+
}
|
|
405
|
+
if (typeof option.url !== "string" || !decorationDeliveryUrlIsSafe(option.url)) {
|
|
406
|
+
report(
|
|
407
|
+
`${optionPath}/url`,
|
|
408
|
+
CODES.attrInvalid,
|
|
409
|
+
"A responsive decoration URL must be HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
option.type !== undefined
|
|
414
|
+
&& (
|
|
415
|
+
typeof option.type !== "string"
|
|
416
|
+
|| !imageDefinition.urls.fields.type.values.includes(option.type)
|
|
417
|
+
)
|
|
418
|
+
) {
|
|
419
|
+
report(`${optionPath}/type`, CODES.attrInvalid, "Responsive decoration image type must be 'image/webp'.");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
imageDefinition.srcMustMatchUrls
|
|
424
|
+
&& typeof image.src === "string"
|
|
425
|
+
&& !image.urls.some((option) => isPlainObject(option) && option.url === image.src)
|
|
426
|
+
) {
|
|
427
|
+
report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must equal one of its responsive urls[].url candidates.");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const name of ["width", "height"]) {
|
|
433
|
+
if (
|
|
434
|
+
Object.hasOwn(image, name)
|
|
435
|
+
&& (typeof image[name] !== "number" || !Number.isInteger(image[name]) || image[name] <= 0)
|
|
436
|
+
) {
|
|
437
|
+
report(`${imagePath}/${name}`, CODES.attrInvalid, `Section decoration image ${name} must be a positive integer.`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (Object.hasOwn(image, "alt") && typeof image.alt !== "string") {
|
|
441
|
+
report(`${imagePath}/alt`, CODES.attrInvalid, "Section decoration image alt must be a string.");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const [name, definition] of Object.entries(fields)) {
|
|
446
|
+
if (name === "image" || !Object.hasOwn(value, name)) continue;
|
|
447
|
+
const fieldPath = `${path}/${name}`;
|
|
448
|
+
const entry = value[name];
|
|
449
|
+
if (definition.type === "enum") {
|
|
450
|
+
if (typeof entry !== "string" || !definition.values.includes(entry)) {
|
|
451
|
+
report(
|
|
452
|
+
fieldPath,
|
|
453
|
+
CODES.attrInvalid,
|
|
454
|
+
`Section decoration ${name} must be one of ${definition.values.map((item) => JSON.stringify(item)).join(", ")}.`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
typeof entry !== "number"
|
|
461
|
+
|| !Number.isFinite(entry)
|
|
462
|
+
|| (definition.type === "integer" && !Number.isInteger(entry))
|
|
463
|
+
|| entry < definition.minimum
|
|
464
|
+
|| entry > definition.maximum
|
|
465
|
+
) {
|
|
466
|
+
report(
|
|
467
|
+
fieldPath,
|
|
468
|
+
CODES.attrInvalid,
|
|
469
|
+
`Section decoration ${name} must be ${definition.type === "integer" ? "an integer" : "a finite number"} from ${definition.minimum} through ${definition.maximum}.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (errors.length > 0 || !isPlainObject(image)) return finish(undefined);
|
|
475
|
+
const normalizedImage = {
|
|
476
|
+
imageId: image.imageId,
|
|
477
|
+
src: image.src,
|
|
478
|
+
urls: Object.freeze(image.urls.map((option) => Object.freeze({
|
|
479
|
+
minWidth: option.minWidth,
|
|
480
|
+
url: option.url,
|
|
481
|
+
...(option.type === undefined ? {} : { type: option.type }),
|
|
482
|
+
}))),
|
|
483
|
+
...(Object.hasOwn(image, "width") ? { width: image.width } : {}),
|
|
484
|
+
...(Object.hasOwn(image, "height") ? { height: image.height } : {}),
|
|
485
|
+
...(Object.hasOwn(image, "alt") ? { alt: image.alt } : {}),
|
|
486
|
+
};
|
|
487
|
+
const normalized = { image: Object.freeze(normalizedImage) };
|
|
488
|
+
for (const [name, definition] of Object.entries(fields)) {
|
|
489
|
+
if (name !== "image") normalized[name] = value[name] ?? definition.default;
|
|
490
|
+
}
|
|
491
|
+
return finish(Object.freeze(normalized));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The exact, sorted context names present in both staged site themes. */
|
|
495
|
+
export function sharedThemeContextNames(lightTheme, darkTheme) {
|
|
496
|
+
const light = isPlainObject(lightTheme?.contexts) ? lightTheme.contexts : {};
|
|
497
|
+
const dark = isPlainObject(darkTheme?.contexts) ? darkTheme.contexts : {};
|
|
498
|
+
return Object.freeze(Object.keys(light).filter((name) => Object.hasOwn(dark, name)).sort());
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Resolve one root document node through the declarative placement registry.
|
|
503
|
+
* Components carry their own placement row; ordinary nodes use `rootNodes`.
|
|
504
|
+
*/
|
|
505
|
+
export function freeFormRootPresentation(node, source = FREE_FORM_SECTION_REGISTRY) {
|
|
506
|
+
if (!isPlainObject(node) || typeof node.type !== "string") return undefined;
|
|
507
|
+
if (node.type === "componentBlock") {
|
|
508
|
+
const componentType = isPlainObject(node.attrs) ? node.attrs.componentType : undefined;
|
|
509
|
+
return typeof componentType === "string" && Object.hasOwn(source.components, componentType)
|
|
510
|
+
? source.components[componentType]
|
|
511
|
+
: undefined;
|
|
512
|
+
}
|
|
513
|
+
return Object.hasOwn(source.rootNodes, node.type) ? source.rootNodes[node.type] : undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Whether a structurally validated document needs staged theme settings. */
|
|
517
|
+
export function hasNamedFreeFormSectionContext(document, source = FREE_FORM_SECTION_REGISTRY) {
|
|
518
|
+
let found = false;
|
|
519
|
+
const sectionType = source.section.nodeType;
|
|
520
|
+
|
|
521
|
+
function visit(node) {
|
|
522
|
+
if (found || !isPlainObject(node)) return;
|
|
523
|
+
if (
|
|
524
|
+
node.type === sectionType
|
|
525
|
+
&& isPlainObject(node.attrs)
|
|
526
|
+
&& typeof node.attrs.context === "string"
|
|
527
|
+
&& node.attrs.context !== ""
|
|
528
|
+
) {
|
|
529
|
+
found = true;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (Array.isArray(node.content)) node.content.forEach(visit);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
visit(document);
|
|
536
|
+
return found;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Validate explicit section contexts without I/O or mutation. The same helper
|
|
541
|
+
* can guard editor documents and generator inputs once those runtimes consume
|
|
542
|
+
* the registry. Document-shape validation remains `validateDocument`'s job.
|
|
543
|
+
*/
|
|
544
|
+
export function validateFreeFormSectionContexts(
|
|
545
|
+
document,
|
|
546
|
+
allowedContexts,
|
|
547
|
+
source = FREE_FORM_SECTION_REGISTRY,
|
|
548
|
+
) {
|
|
549
|
+
const allowed = new Set(
|
|
550
|
+
Array.isArray(allowedContexts) ? allowedContexts.filter((name) => typeof name === "string") : [],
|
|
551
|
+
);
|
|
552
|
+
const alternatives = [...allowed].sort();
|
|
553
|
+
const suffix = alternatives.length > 0
|
|
554
|
+
? `Declared alternatives: ${alternatives.join(", ")}.`
|
|
555
|
+
: "The staged light and dark themes declare no shared context alternatives.";
|
|
556
|
+
const errors = [];
|
|
557
|
+
const sectionType = source.section.nodeType;
|
|
558
|
+
|
|
559
|
+
function visit(node, path) {
|
|
560
|
+
if (errors.length >= CONTENT_LIMITS.documentErrors) return false;
|
|
561
|
+
if (!isPlainObject(node)) return true;
|
|
562
|
+
if (node.type === sectionType && isPlainObject(node.attrs) && typeof node.attrs.context === "string") {
|
|
563
|
+
const context = node.attrs.context;
|
|
564
|
+
if (!allowed.has(context)) {
|
|
565
|
+
errors.push(contentError(
|
|
566
|
+
`${path}/attrs/context`,
|
|
567
|
+
CODES.sectionContextUnknown,
|
|
568
|
+
`Section context '${context}' is not declared by both staged themes. ${suffix}`,
|
|
569
|
+
));
|
|
570
|
+
if (errors.length >= CONTENT_LIMITS.documentErrors) return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!Array.isArray(node.content)) return true;
|
|
574
|
+
for (let index = 0; index < node.content.length; index += 1) {
|
|
575
|
+
if (!visit(node.content[index], `${path}/content/${index}`)) return false;
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
visit(document, "");
|
|
581
|
+
return Object.freeze({ errors: Object.freeze(errors) });
|
|
582
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content validation and Markdown conversion — the part of this CLI that is
|
|
3
|
+
* not a wrapper over the API.
|
|
4
|
+
*
|
|
5
|
+
* TR00604's defining constraint: the server checks a page body for
|
|
6
|
+
* `RichTextBody.IsPresent` and nothing else. Everything past that gate is
|
|
7
|
+
* accepted, stored, and silently mangled at render time. So the verbs in
|
|
8
|
+
* `../verbs/` push nothing they have not run through `validateDocument` first,
|
|
9
|
+
* and agents author in Markdown through `markdownToProseMirror` rather than
|
|
10
|
+
* hand-assembling document JSON.
|
|
11
|
+
*
|
|
12
|
+
* The module is pure: no network, no filesystem, no clock, no dependencies.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { validateDocument } from "./validate-document.js";
|
|
16
|
+
export { markdownToProseMirror } from "./markdown.js";
|
|
17
|
+
export {
|
|
18
|
+
FREE_FORM_SECTION_REGISTRY,
|
|
19
|
+
freeFormRootPresentation,
|
|
20
|
+
hasNamedFreeFormSectionContext,
|
|
21
|
+
normalizeFreeFormSectionBackground,
|
|
22
|
+
normalizeFreeFormSectionDecoration,
|
|
23
|
+
sharedThemeContextNames,
|
|
24
|
+
validateFreeFormSectionContexts,
|
|
25
|
+
} from "./free-form-sections.js";
|
|
26
|
+
export { normalizeInlineFactsItems } from "./inline-facts.js";
|
|
27
|
+
// The stable codes both of the above report through, so a caller branches on
|
|
28
|
+
// an identity instead of matching message text.
|
|
29
|
+
export { CONTENT_ERROR_CODES } from "./vocabulary.js";
|