@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,687 @@
|
|
|
1
|
+
import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
2
|
+
import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
|
|
3
|
+
|
|
4
|
+
export const FOOTER_LIMITS = Object.freeze({
|
|
5
|
+
linkColumns: 6,
|
|
6
|
+
linkGroups: 12,
|
|
7
|
+
groupsPerColumn: 6,
|
|
8
|
+
linksPerGroup: 12,
|
|
9
|
+
bottomLinks: 8,
|
|
10
|
+
richTextParagraphs: 12,
|
|
11
|
+
richTextRuns: 200,
|
|
12
|
+
groupHeadingLength: 80,
|
|
13
|
+
asideHeadingLength: 120,
|
|
14
|
+
asideBodyLength: 1_000,
|
|
15
|
+
linkLabelLength: 120,
|
|
16
|
+
bottomTextLength: 500,
|
|
17
|
+
featureImageAltLength: 300,
|
|
18
|
+
externalUrlLength: 2_048,
|
|
19
|
+
backgroundRepeatHeightMinimum: 64,
|
|
20
|
+
backgroundRepeatHeightMaximum: 512,
|
|
21
|
+
backgroundRepeatHeightStep: 16,
|
|
22
|
+
backgroundRepeatHeightDefault: 160,
|
|
23
|
+
additionalTopPaddingRemMaximum: 20,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const FOOTER_BACKGROUND_PRESENTATIONS = Object.freeze([
|
|
27
|
+
"FOOTER_BACKGROUND_PRESENTATION_TILE",
|
|
28
|
+
"FOOTER_BACKGROUND_PRESENTATION_REPEAT_X",
|
|
29
|
+
"FOOTER_BACKGROUND_PRESENTATION_COVER",
|
|
30
|
+
]);
|
|
31
|
+
export const FOOTER_FADE_MODES = Object.freeze([
|
|
32
|
+
"FOOTER_FADE_MODE_NONE",
|
|
33
|
+
"FOOTER_FADE_MODE_BOTTOM",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// One ordered inventory drives the local closed-object checks, the request
|
|
37
|
+
// mapper (validation returns this exact shape), and reference help. The three
|
|
38
|
+
// response-only delivery fields remain in the inventory as read-only so a
|
|
39
|
+
// future proto edit cannot quietly become authorable.
|
|
40
|
+
export const FOOTER_FIELD_INVENTORY = Object.freeze({
|
|
41
|
+
FooterSettings: Object.freeze([
|
|
42
|
+
"enabled",
|
|
43
|
+
"showBrand",
|
|
44
|
+
"asideCta",
|
|
45
|
+
"bottomLinks",
|
|
46
|
+
"light",
|
|
47
|
+
"dark",
|
|
48
|
+
"linkColumns",
|
|
49
|
+
"asideHeadingContent",
|
|
50
|
+
"asideBodyContent",
|
|
51
|
+
"bottomContent",
|
|
52
|
+
"featureImage",
|
|
53
|
+
"showBrandText",
|
|
54
|
+
]),
|
|
55
|
+
FooterLinkColumn: Object.freeze(["id", "groups"]),
|
|
56
|
+
FooterLinkGroup: Object.freeze(["id", "heading", "links"]),
|
|
57
|
+
FooterLink: Object.freeze(["id", "label", "pageResourceId", "externalUrl"]),
|
|
58
|
+
FooterRichText: Object.freeze(["paragraphs"]),
|
|
59
|
+
FooterRichTextParagraph: Object.freeze(["runs"]),
|
|
60
|
+
FooterRichTextRun: Object.freeze(["text", "bold", "italic", "underline", "link"]),
|
|
61
|
+
FooterInlineLink: Object.freeze(["pageResourceId", "externalUrl"]),
|
|
62
|
+
FooterSchemeSettings: Object.freeze([
|
|
63
|
+
"backgroundColor",
|
|
64
|
+
"textColor",
|
|
65
|
+
"headingColor",
|
|
66
|
+
"linkColor",
|
|
67
|
+
"linkHoverColor",
|
|
68
|
+
"backgroundImageId",
|
|
69
|
+
"backgroundImageUrl",
|
|
70
|
+
"backgroundImageOpacity",
|
|
71
|
+
"backgroundPresentation",
|
|
72
|
+
"backgroundFade",
|
|
73
|
+
"backgroundRepeatHeightPx",
|
|
74
|
+
"additionalTopPaddingRem",
|
|
75
|
+
]),
|
|
76
|
+
FooterFeatureImageSettings: Object.freeze(["imageId", "imageUrl", "alt", "responsiveUrls"]),
|
|
77
|
+
FooterFeatureResponsiveImageUrl: Object.freeze(["minWidth", "url"]),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const FOOTER_READ_ONLY_FIELDS = Object.freeze([
|
|
81
|
+
"light.backgroundImageUrl",
|
|
82
|
+
"dark.backgroundImageUrl",
|
|
83
|
+
"featureImage.imageUrl",
|
|
84
|
+
"featureImage.responsiveUrls",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
function isPlainObject(value) {
|
|
88
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fail(code, message, field) {
|
|
92
|
+
throw new SiteAuthoringError(code, message, { field });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function object(value, path, { nullable = false } = {}) {
|
|
96
|
+
if (nullable && (value === null || value === undefined)) return null;
|
|
97
|
+
if (!isPlainObject(value)) fail("footer.document_invalid", `${path} must be a JSON object.`, path);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function closed(value, type, path) {
|
|
102
|
+
const allowed = FOOTER_FIELD_INVENTORY[type];
|
|
103
|
+
for (const key of Object.keys(value)) {
|
|
104
|
+
if (!allowed.includes(key)) {
|
|
105
|
+
fail("footer.field_unknown", `${path}.${key} is not part of the closed footer contract.`, `${path}.${key}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function array(value, path, maximum) {
|
|
111
|
+
if (value === undefined) return [];
|
|
112
|
+
if (!Array.isArray(value)) fail("footer.field_invalid", `${path} must be an array.`, path);
|
|
113
|
+
if (value.length > maximum) {
|
|
114
|
+
fail("footer.collection_too_large", `${path} may contain at most ${maximum} items.`, path);
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function boolean(value, defaultValue, path) {
|
|
120
|
+
if (value === undefined) return defaultValue;
|
|
121
|
+
if (typeof value !== "boolean") fail("footer.field_invalid", `${path} must be true or false.`, path);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function number(value, defaultValue, path, minimum, maximum) {
|
|
126
|
+
if (value === undefined) return defaultValue;
|
|
127
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
|
128
|
+
fail("footer.field_invalid", `${path} must be a finite number from ${minimum} through ${maximum}.`, path);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function choice(value, defaultValue, choices, path) {
|
|
134
|
+
if (value === undefined) return defaultValue;
|
|
135
|
+
if (typeof value !== "string" || !choices.includes(value)) {
|
|
136
|
+
fail("footer.field_invalid", `${path} must be one of ${choices.join(", ")}.`, path);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isDotNetWhiteSpace(character) {
|
|
142
|
+
const codePoint = character.codePointAt(0);
|
|
143
|
+
return (codePoint >= 0x0009 && codePoint <= 0x000d)
|
|
144
|
+
|| codePoint === 0x0020
|
|
145
|
+
|| codePoint === 0x0085
|
|
146
|
+
|| codePoint === 0x00a0
|
|
147
|
+
|| codePoint === 0x1680
|
|
148
|
+
|| (codePoint >= 0x2000 && codePoint <= 0x200a)
|
|
149
|
+
|| codePoint === 0x2028
|
|
150
|
+
|| codePoint === 0x2029
|
|
151
|
+
|| codePoint === 0x202f
|
|
152
|
+
|| codePoint === 0x205f
|
|
153
|
+
|| codePoint === 0x3000;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeSingleLineLikeServer(value) {
|
|
157
|
+
let result = "";
|
|
158
|
+
let pendingSpace = false;
|
|
159
|
+
for (const character of value) {
|
|
160
|
+
if (isDotNetWhiteSpace(character)) {
|
|
161
|
+
pendingSpace = result.length > 0;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (pendingSpace) result += " ";
|
|
165
|
+
pendingSpace = false;
|
|
166
|
+
result += character;
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function trimLikeServer(value) {
|
|
172
|
+
let start = 0;
|
|
173
|
+
let end = value.length;
|
|
174
|
+
while (start < end && isDotNetWhiteSpace(value[start])) start += 1;
|
|
175
|
+
while (end > start && isDotNetWhiteSpace(value[end - 1])) end -= 1;
|
|
176
|
+
return value.slice(start, end);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizedSingleLine(value, maximum, path, { required = false, tolerateStoredValues = false } = {}) {
|
|
180
|
+
if (value === undefined) value = "";
|
|
181
|
+
if (typeof value !== "string" || value.length > maximum) {
|
|
182
|
+
fail("footer.text_invalid", `${path} must be a bounded plain-text string of at most ${maximum} characters.`, path);
|
|
183
|
+
}
|
|
184
|
+
const normalized = normalizeSingleLineLikeServer(value);
|
|
185
|
+
if (!tolerateStoredValues && hasControlCharacter(normalized)) {
|
|
186
|
+
fail("footer.text_invalid", `${path} must be a bounded plain-text string of at most ${maximum} characters.`, path);
|
|
187
|
+
}
|
|
188
|
+
if (required && normalized.length === 0) fail("footer.text_required", `${path} is required.`, path);
|
|
189
|
+
return normalized;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function stableId(value, path, seen, label) {
|
|
193
|
+
if (!isCanonicalUuid(value)) {
|
|
194
|
+
fail("footer.id_invalid", `${path} must be a canonical lowercase ${label} UUID.`, path);
|
|
195
|
+
}
|
|
196
|
+
if (seen.has(value)) fail("footer.id_duplicate", `${path} duplicates another ${label} UUID.`, path);
|
|
197
|
+
seen.add(value);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function safeExternalUrl(value, path, tolerateStoredValues) {
|
|
202
|
+
if (typeof value !== "string") {
|
|
203
|
+
fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
|
|
204
|
+
}
|
|
205
|
+
const normalized = trimLikeServer(value);
|
|
206
|
+
if (tolerateStoredValues) return normalized;
|
|
207
|
+
if (
|
|
208
|
+
normalized.length === 0
|
|
209
|
+
|| normalized.length > FOOTER_LIMITS.externalUrlLength
|
|
210
|
+
|| hasControlCharacter(normalized)
|
|
211
|
+
|| normalized.includes("\\")
|
|
212
|
+
) {
|
|
213
|
+
fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = new URL(normalized);
|
|
218
|
+
} catch {
|
|
219
|
+
fail("footer.url_invalid", `${path} must be an absolute http or https URL.`, path);
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
223
|
+
|| parsed.username !== ""
|
|
224
|
+
|| parsed.password !== ""
|
|
225
|
+
|| parsed.hostname === ""
|
|
226
|
+
) {
|
|
227
|
+
fail("footer.url_invalid", `${path} must use http or https without embedded credentials.`, path);
|
|
228
|
+
}
|
|
229
|
+
return normalized;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function target(value, path, knownPageResourceIds, tolerateStoredValues) {
|
|
233
|
+
const hasPage = Object.hasOwn(value, "pageResourceId");
|
|
234
|
+
const hasExternal = Object.hasOwn(value, "externalUrl");
|
|
235
|
+
if (hasPage === hasExternal) {
|
|
236
|
+
fail(
|
|
237
|
+
"footer.target_invalid",
|
|
238
|
+
`${path} must contain exactly one of pageResourceId or externalUrl.`,
|
|
239
|
+
path,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (hasPage) {
|
|
243
|
+
if (!isCanonicalUuid(value.pageResourceId)) {
|
|
244
|
+
fail("footer.id_invalid", `${path}.pageResourceId must be a canonical lowercase UUID.`, `${path}.pageResourceId`);
|
|
245
|
+
}
|
|
246
|
+
if (knownPageResourceIds && !knownPageResourceIds.has(value.pageResourceId)) {
|
|
247
|
+
fail(
|
|
248
|
+
"footer.page_reference_unknown",
|
|
249
|
+
`${path}.pageResourceId must identify a page tracked by the pull manifest.`,
|
|
250
|
+
`${path}.pageResourceId`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return { pageResourceId: value.pageResourceId };
|
|
254
|
+
}
|
|
255
|
+
return { externalUrl: safeExternalUrl(value.externalUrl, `${path}.externalUrl`, tolerateStoredValues) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function inlineLink(value, path, knownPageResourceIds, tolerateStoredValues) {
|
|
259
|
+
if (value === undefined || value === null) return undefined;
|
|
260
|
+
value = object(value, path);
|
|
261
|
+
closed(value, "FooterInlineLink", path);
|
|
262
|
+
return target(value, path, knownPageResourceIds, tolerateStoredValues);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function richText(value, path, maximumLength, maximumParagraphs, knownPageResourceIds, tolerateStoredValues) {
|
|
266
|
+
if (value === undefined || value === null) return { paragraphs: [] };
|
|
267
|
+
value = object(value, path);
|
|
268
|
+
closed(value, "FooterRichText", path);
|
|
269
|
+
const paragraphs = array(value.paragraphs, `${path}.paragraphs`, maximumParagraphs);
|
|
270
|
+
let totalLength = 0;
|
|
271
|
+
let totalRuns = 0;
|
|
272
|
+
const normalizedParagraphs = paragraphs.map((paragraph, paragraphIndex) => {
|
|
273
|
+
const paragraphPath = `${path}.paragraphs[${paragraphIndex}]`;
|
|
274
|
+
paragraph = object(paragraph, paragraphPath);
|
|
275
|
+
closed(paragraph, "FooterRichTextParagraph", paragraphPath);
|
|
276
|
+
const runs = array(paragraph.runs, `${paragraphPath}.runs`, FOOTER_LIMITS.richTextRuns);
|
|
277
|
+
const normalizedRuns = [];
|
|
278
|
+
for (let runIndex = 0; runIndex < runs.length; runIndex += 1) {
|
|
279
|
+
const runPath = `${paragraphPath}.runs[${runIndex}]`;
|
|
280
|
+
const run = object(runs[runIndex], runPath);
|
|
281
|
+
closed(run, "FooterRichTextRun", runPath);
|
|
282
|
+
if (
|
|
283
|
+
typeof run.text !== "string"
|
|
284
|
+
|| (!tolerateStoredValues && hasControlCharacter(run.text.replace(/[\n\r\t]/gu, "")))
|
|
285
|
+
) {
|
|
286
|
+
fail("footer.text_invalid", `${runPath}.text must be plain text.`, `${runPath}.text`);
|
|
287
|
+
}
|
|
288
|
+
const text = run.text.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
|
|
289
|
+
if (text.length === 0) continue;
|
|
290
|
+
totalLength += text.length;
|
|
291
|
+
totalRuns += 1;
|
|
292
|
+
normalizedRuns.push({
|
|
293
|
+
text,
|
|
294
|
+
bold: boolean(run.bold, false, `${runPath}.bold`),
|
|
295
|
+
italic: boolean(run.italic, false, `${runPath}.italic`),
|
|
296
|
+
underline: boolean(run.underline, false, `${runPath}.underline`),
|
|
297
|
+
...(run.link === undefined || run.link === null
|
|
298
|
+
? {}
|
|
299
|
+
: { link: inlineLink(run.link, `${runPath}.link`, knownPageResourceIds, tolerateStoredValues) }),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { runs: normalizedRuns };
|
|
303
|
+
});
|
|
304
|
+
if (totalLength > maximumLength) {
|
|
305
|
+
fail("footer.text_too_long", `${path} may contain at most ${maximumLength} characters.`, path);
|
|
306
|
+
}
|
|
307
|
+
if (totalRuns > FOOTER_LIMITS.richTextRuns) {
|
|
308
|
+
fail("footer.collection_too_large", `${path} may contain at most ${FOOTER_LIMITS.richTextRuns} styled runs.`, path);
|
|
309
|
+
}
|
|
310
|
+
return { paragraphs: normalizedParagraphs };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function footerLink(value, path, seenLinkIds, knownPageResourceIds, tolerateStoredValues) {
|
|
314
|
+
value = object(value, path);
|
|
315
|
+
closed(value, "FooterLink", path);
|
|
316
|
+
return {
|
|
317
|
+
id: stableId(value.id, `${path}.id`, seenLinkIds, "link"),
|
|
318
|
+
label: normalizedSingleLine(value.label, FOOTER_LIMITS.linkLabelLength, `${path}.label`, {
|
|
319
|
+
required: true,
|
|
320
|
+
tolerateStoredValues,
|
|
321
|
+
}),
|
|
322
|
+
...target(value, path, knownPageResourceIds, tolerateStoredValues),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function footerLinks(values, path, maximum, seenLinkIds, knownPageResourceIds, tolerateStoredValues) {
|
|
327
|
+
return array(values, path, maximum).map((value, index) =>
|
|
328
|
+
footerLink(value, `${path}[${index}]`, seenLinkIds, knownPageResourceIds, tolerateStoredValues)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function imageId(value, path, knownImageIds) {
|
|
333
|
+
if (value === undefined || value === "") return "";
|
|
334
|
+
if (!isCanonicalUuid(value)) {
|
|
335
|
+
fail("footer.id_invalid", `${path} must be empty or a canonical lowercase site-owned image UUID.`, path);
|
|
336
|
+
}
|
|
337
|
+
if (knownImageIds && !knownImageIds.has(value)) {
|
|
338
|
+
fail(
|
|
339
|
+
"footer.image_reference_unknown",
|
|
340
|
+
`${path} must identify media recorded by pull or 'taproot-site media upload'.`,
|
|
341
|
+
path,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function rejectReadOnly(value, field, path) {
|
|
348
|
+
if (Object.hasOwn(value, field)) {
|
|
349
|
+
fail(
|
|
350
|
+
"footer.field_read_only",
|
|
351
|
+
`${path}.${field} is a server-resolved read-only projection; remove it and author the stable image id instead.`,
|
|
352
|
+
`${path}.${field}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function scheme(value, path, knownImageIds) {
|
|
358
|
+
value = value === undefined || value === null ? {} : object(value, path);
|
|
359
|
+
closed(value, "FooterSchemeSettings", path);
|
|
360
|
+
rejectReadOnly(value, "backgroundImageUrl", path);
|
|
361
|
+
const repeatHeight = value.backgroundRepeatHeightPx ?? FOOTER_LIMITS.backgroundRepeatHeightDefault;
|
|
362
|
+
if (
|
|
363
|
+
!Number.isInteger(repeatHeight)
|
|
364
|
+
|| repeatHeight < FOOTER_LIMITS.backgroundRepeatHeightMinimum
|
|
365
|
+
|| repeatHeight > FOOTER_LIMITS.backgroundRepeatHeightMaximum
|
|
366
|
+
|| (repeatHeight - FOOTER_LIMITS.backgroundRepeatHeightMinimum) % FOOTER_LIMITS.backgroundRepeatHeightStep !== 0
|
|
367
|
+
) {
|
|
368
|
+
fail(
|
|
369
|
+
"footer.field_invalid",
|
|
370
|
+
`${path}.backgroundRepeatHeightPx must be an integer from ${FOOTER_LIMITS.backgroundRepeatHeightMinimum} through ${FOOTER_LIMITS.backgroundRepeatHeightMaximum} in ${FOOTER_LIMITS.backgroundRepeatHeightStep}-pixel steps.`,
|
|
371
|
+
`${path}.backgroundRepeatHeightPx`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
backgroundColor: requireAppearanceColor(
|
|
376
|
+
value.backgroundColor ?? "",
|
|
377
|
+
FOOTER_COLOR_TOKENS.backgroundColor,
|
|
378
|
+
`${path}.backgroundColor`,
|
|
379
|
+
),
|
|
380
|
+
textColor: requireAppearanceColor(value.textColor ?? "", FOOTER_COLOR_TOKENS.textColor, `${path}.textColor`),
|
|
381
|
+
headingColor: requireAppearanceColor(
|
|
382
|
+
value.headingColor ?? "",
|
|
383
|
+
FOOTER_COLOR_TOKENS.headingColor,
|
|
384
|
+
`${path}.headingColor`,
|
|
385
|
+
),
|
|
386
|
+
linkColor: requireAppearanceColor(value.linkColor ?? "", FOOTER_COLOR_TOKENS.linkColor, `${path}.linkColor`),
|
|
387
|
+
linkHoverColor: requireAppearanceColor(
|
|
388
|
+
value.linkHoverColor ?? "",
|
|
389
|
+
FOOTER_COLOR_TOKENS.linkHoverColor,
|
|
390
|
+
`${path}.linkHoverColor`,
|
|
391
|
+
),
|
|
392
|
+
backgroundImageId: imageId(value.backgroundImageId, `${path}.backgroundImageId`, knownImageIds),
|
|
393
|
+
backgroundImageOpacity: number(value.backgroundImageOpacity, 1, `${path}.backgroundImageOpacity`, 0, 1),
|
|
394
|
+
backgroundPresentation: choice(
|
|
395
|
+
value.backgroundPresentation,
|
|
396
|
+
FOOTER_BACKGROUND_PRESENTATIONS[0],
|
|
397
|
+
FOOTER_BACKGROUND_PRESENTATIONS,
|
|
398
|
+
`${path}.backgroundPresentation`,
|
|
399
|
+
),
|
|
400
|
+
backgroundFade: choice(
|
|
401
|
+
value.backgroundFade,
|
|
402
|
+
FOOTER_FADE_MODES[0],
|
|
403
|
+
FOOTER_FADE_MODES,
|
|
404
|
+
`${path}.backgroundFade`,
|
|
405
|
+
),
|
|
406
|
+
backgroundRepeatHeightPx: repeatHeight,
|
|
407
|
+
additionalTopPaddingRem: number(
|
|
408
|
+
value.additionalTopPaddingRem,
|
|
409
|
+
0,
|
|
410
|
+
`${path}.additionalTopPaddingRem`,
|
|
411
|
+
0,
|
|
412
|
+
FOOTER_LIMITS.additionalTopPaddingRemMaximum,
|
|
413
|
+
),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function featureImage(value, knownImageIds, tolerateStoredValues) {
|
|
418
|
+
if (value === undefined || value === null) return undefined;
|
|
419
|
+
value = object(value, "footerSettings.featureImage");
|
|
420
|
+
closed(value, "FooterFeatureImageSettings", "footerSettings.featureImage");
|
|
421
|
+
rejectReadOnly(value, "imageUrl", "footerSettings.featureImage");
|
|
422
|
+
rejectReadOnly(value, "responsiveUrls", "footerSettings.featureImage");
|
|
423
|
+
const id = imageId(value.imageId, "footerSettings.featureImage.imageId", knownImageIds);
|
|
424
|
+
const alt = normalizedSingleLine(
|
|
425
|
+
value.alt,
|
|
426
|
+
FOOTER_LIMITS.featureImageAltLength,
|
|
427
|
+
"footerSettings.featureImage.alt",
|
|
428
|
+
{ tolerateStoredValues },
|
|
429
|
+
);
|
|
430
|
+
if (id === "" && alt === "") return undefined;
|
|
431
|
+
if (id === "") {
|
|
432
|
+
fail(
|
|
433
|
+
"footer.id_invalid",
|
|
434
|
+
"footerSettings.featureImage.imageId is required.",
|
|
435
|
+
"footerSettings.featureImage.imageId",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (alt === "") {
|
|
439
|
+
fail(
|
|
440
|
+
"footer.alt_required",
|
|
441
|
+
"footerSettings.featureImage.alt is required for an accessible feature image.",
|
|
442
|
+
"footerSettings.featureImage.alt",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return { imageId: id, alt };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function footerDefaults() {
|
|
449
|
+
const defaults = () => ({
|
|
450
|
+
backgroundColor: "",
|
|
451
|
+
textColor: "",
|
|
452
|
+
headingColor: "",
|
|
453
|
+
linkColor: "",
|
|
454
|
+
linkHoverColor: "",
|
|
455
|
+
backgroundImageId: "",
|
|
456
|
+
backgroundImageOpacity: 1,
|
|
457
|
+
backgroundPresentation: FOOTER_BACKGROUND_PRESENTATIONS[0],
|
|
458
|
+
backgroundFade: FOOTER_FADE_MODES[0],
|
|
459
|
+
backgroundRepeatHeightPx: FOOTER_LIMITS.backgroundRepeatHeightDefault,
|
|
460
|
+
additionalTopPaddingRem: 0,
|
|
461
|
+
});
|
|
462
|
+
return {
|
|
463
|
+
enabled: false,
|
|
464
|
+
showBrand: true,
|
|
465
|
+
asideCta: undefined,
|
|
466
|
+
bottomLinks: [],
|
|
467
|
+
light: defaults(),
|
|
468
|
+
dark: defaults(),
|
|
469
|
+
linkColumns: [],
|
|
470
|
+
asideHeadingContent: { paragraphs: [] },
|
|
471
|
+
asideBodyContent: { paragraphs: [] },
|
|
472
|
+
bottomContent: { paragraphs: [] },
|
|
473
|
+
featureImage: undefined,
|
|
474
|
+
showBrandText: true,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function normalizeFooterDocument(
|
|
479
|
+
value,
|
|
480
|
+
{ knownPageResourceIds, knownImageIds, tolerateStoredValues = false } = {},
|
|
481
|
+
) {
|
|
482
|
+
value = object(value ?? {}, "footerSettings");
|
|
483
|
+
closed(value, "FooterSettings", "footerSettings");
|
|
484
|
+
const seenColumnIds = new Set();
|
|
485
|
+
const seenGroupIds = new Set();
|
|
486
|
+
const seenLinkIds = new Set();
|
|
487
|
+
let groupCount = 0;
|
|
488
|
+
const linkColumns = array(value.linkColumns, "footerSettings.linkColumns", FOOTER_LIMITS.linkColumns)
|
|
489
|
+
.map((column, columnIndex) => {
|
|
490
|
+
const columnPath = `footerSettings.linkColumns[${columnIndex}]`;
|
|
491
|
+
column = object(column, columnPath);
|
|
492
|
+
closed(column, "FooterLinkColumn", columnPath);
|
|
493
|
+
const groups = array(column.groups, `${columnPath}.groups`, FOOTER_LIMITS.groupsPerColumn)
|
|
494
|
+
.map((group, groupIndex) => {
|
|
495
|
+
groupCount += 1;
|
|
496
|
+
const groupPath = `${columnPath}.groups[${groupIndex}]`;
|
|
497
|
+
group = object(group, groupPath);
|
|
498
|
+
closed(group, "FooterLinkGroup", groupPath);
|
|
499
|
+
const links = footerLinks(
|
|
500
|
+
group.links,
|
|
501
|
+
`${groupPath}.links`,
|
|
502
|
+
FOOTER_LIMITS.linksPerGroup,
|
|
503
|
+
seenLinkIds,
|
|
504
|
+
knownPageResourceIds,
|
|
505
|
+
tolerateStoredValues,
|
|
506
|
+
);
|
|
507
|
+
return {
|
|
508
|
+
id: stableId(group.id, `${groupPath}.id`, seenGroupIds, "group"),
|
|
509
|
+
heading: normalizedSingleLine(
|
|
510
|
+
group.heading,
|
|
511
|
+
FOOTER_LIMITS.groupHeadingLength,
|
|
512
|
+
`${groupPath}.heading`,
|
|
513
|
+
{ required: links.length > 0, tolerateStoredValues },
|
|
514
|
+
),
|
|
515
|
+
links,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
return {
|
|
519
|
+
id: stableId(column.id, `${columnPath}.id`, seenColumnIds, "column"),
|
|
520
|
+
groups,
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
if (groupCount > FOOTER_LIMITS.linkGroups) {
|
|
524
|
+
fail(
|
|
525
|
+
"footer.collection_too_large",
|
|
526
|
+
`footerSettings.linkColumns may contain at most ${FOOTER_LIMITS.linkGroups} groups in total.`,
|
|
527
|
+
"footerSettings.linkColumns",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const normalizedFeatureImage = featureImage(value.featureImage, knownImageIds, tolerateStoredValues);
|
|
532
|
+
return {
|
|
533
|
+
enabled: boolean(value.enabled, false, "footerSettings.enabled"),
|
|
534
|
+
showBrand: boolean(value.showBrand, true, "footerSettings.showBrand"),
|
|
535
|
+
...(value.asideCta === undefined || value.asideCta === null
|
|
536
|
+
? {}
|
|
537
|
+
: {
|
|
538
|
+
asideCta: footerLink(
|
|
539
|
+
value.asideCta,
|
|
540
|
+
"footerSettings.asideCta",
|
|
541
|
+
seenLinkIds,
|
|
542
|
+
knownPageResourceIds,
|
|
543
|
+
tolerateStoredValues,
|
|
544
|
+
),
|
|
545
|
+
}),
|
|
546
|
+
bottomLinks: footerLinks(
|
|
547
|
+
value.bottomLinks,
|
|
548
|
+
"footerSettings.bottomLinks",
|
|
549
|
+
FOOTER_LIMITS.bottomLinks,
|
|
550
|
+
seenLinkIds,
|
|
551
|
+
knownPageResourceIds,
|
|
552
|
+
tolerateStoredValues,
|
|
553
|
+
),
|
|
554
|
+
light: scheme(value.light, "footerSettings.light", knownImageIds),
|
|
555
|
+
dark: scheme(value.dark, "footerSettings.dark", knownImageIds),
|
|
556
|
+
linkColumns,
|
|
557
|
+
asideHeadingContent: richText(
|
|
558
|
+
value.asideHeadingContent,
|
|
559
|
+
"footerSettings.asideHeadingContent",
|
|
560
|
+
FOOTER_LIMITS.asideHeadingLength,
|
|
561
|
+
1,
|
|
562
|
+
knownPageResourceIds,
|
|
563
|
+
tolerateStoredValues,
|
|
564
|
+
),
|
|
565
|
+
asideBodyContent: richText(
|
|
566
|
+
value.asideBodyContent,
|
|
567
|
+
"footerSettings.asideBodyContent",
|
|
568
|
+
FOOTER_LIMITS.asideBodyLength,
|
|
569
|
+
FOOTER_LIMITS.richTextParagraphs,
|
|
570
|
+
knownPageResourceIds,
|
|
571
|
+
tolerateStoredValues,
|
|
572
|
+
),
|
|
573
|
+
bottomContent: richText(
|
|
574
|
+
value.bottomContent,
|
|
575
|
+
"footerSettings.bottomContent",
|
|
576
|
+
FOOTER_LIMITS.bottomTextLength,
|
|
577
|
+
FOOTER_LIMITS.richTextParagraphs,
|
|
578
|
+
knownPageResourceIds,
|
|
579
|
+
tolerateStoredValues,
|
|
580
|
+
),
|
|
581
|
+
...(normalizedFeatureImage === undefined ? {} : { featureImage: normalizedFeatureImage }),
|
|
582
|
+
showBrandText: boolean(value.showBrandText, true, "footerSettings.showBrandText"),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Validates and normalizes the strict authorable workspace/request shape. */
|
|
587
|
+
export function validateFooterDocument(value, options = {}) {
|
|
588
|
+
return normalizeFooterDocument(value, options);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function withoutServerProjections(value) {
|
|
592
|
+
const projected = structuredClone(isPlainObject(value) ? value : {});
|
|
593
|
+
for (const schemeName of ["light", "dark"]) {
|
|
594
|
+
if (isPlainObject(projected[schemeName])) delete projected[schemeName].backgroundImageUrl;
|
|
595
|
+
}
|
|
596
|
+
if (isPlainObject(projected.featureImage)) {
|
|
597
|
+
delete projected.featureImage.imageUrl;
|
|
598
|
+
delete projected.featureImage.responsiveUrls;
|
|
599
|
+
}
|
|
600
|
+
return projected;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** The editable workspace/request shape, with response-only delivery URLs gone. */
|
|
604
|
+
export function projectFooterSettingsForWorkspace(value) {
|
|
605
|
+
return normalizeFooterDocument(withoutServerProjections(value), { tolerateStoredValues: true });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function footerImageIds(value) {
|
|
609
|
+
const ids = new Set();
|
|
610
|
+
for (
|
|
611
|
+
const candidate of [value?.light?.backgroundImageId, value?.dark?.backgroundImageId, value?.featureImage?.imageId]
|
|
612
|
+
) {
|
|
613
|
+
if (isCanonicalUuid(candidate)) ids.add(candidate);
|
|
614
|
+
}
|
|
615
|
+
return [...ids].sort();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export const FOOTER_EXAMPLE = Object.freeze({
|
|
619
|
+
enabled: true,
|
|
620
|
+
showBrand: true,
|
|
621
|
+
asideCta: Object.freeze({
|
|
622
|
+
id: "103e726d-3152-49c8-bf14-c947b1bd8a14",
|
|
623
|
+
label: "Talk with us",
|
|
624
|
+
externalUrl: "https://example.com/contact",
|
|
625
|
+
}),
|
|
626
|
+
bottomLinks: Object.freeze([Object.freeze({
|
|
627
|
+
id: "28363e08-b341-42fc-bc83-8dd73fa87904",
|
|
628
|
+
label: "Privacy",
|
|
629
|
+
externalUrl: "https://example.com/privacy",
|
|
630
|
+
})]),
|
|
631
|
+
light: Object.freeze({
|
|
632
|
+
backgroundColor: "--esp-color-layer-1",
|
|
633
|
+
textColor: "--esp-color-text",
|
|
634
|
+
headingColor: "--esp-color-headings",
|
|
635
|
+
linkColor: "--esp-color-link",
|
|
636
|
+
linkHoverColor: "--esp-color-link-hover",
|
|
637
|
+
backgroundImageId: "d461035a-74b6-4528-a4f5-e5a96fb50037",
|
|
638
|
+
backgroundImageOpacity: 0.18,
|
|
639
|
+
backgroundPresentation: "FOOTER_BACKGROUND_PRESENTATION_COVER",
|
|
640
|
+
backgroundFade: "FOOTER_FADE_MODE_BOTTOM",
|
|
641
|
+
backgroundRepeatHeightPx: 160,
|
|
642
|
+
additionalTopPaddingRem: 0,
|
|
643
|
+
}),
|
|
644
|
+
dark: Object.freeze({
|
|
645
|
+
backgroundColor: "--esp-color-background",
|
|
646
|
+
textColor: "--esp-color-text",
|
|
647
|
+
headingColor: "--esp-color-headings",
|
|
648
|
+
linkColor: "--esp-color-link",
|
|
649
|
+
linkHoverColor: "--esp-color-link-hover",
|
|
650
|
+
backgroundImageId: "",
|
|
651
|
+
backgroundImageOpacity: 1,
|
|
652
|
+
backgroundPresentation: "FOOTER_BACKGROUND_PRESENTATION_TILE",
|
|
653
|
+
backgroundFade: "FOOTER_FADE_MODE_NONE",
|
|
654
|
+
backgroundRepeatHeightPx: 160,
|
|
655
|
+
additionalTopPaddingRem: 0,
|
|
656
|
+
}),
|
|
657
|
+
linkColumns: Object.freeze([Object.freeze({
|
|
658
|
+
id: "84bf7531-dfc8-453e-a78d-a5fa3348d56b",
|
|
659
|
+
groups: Object.freeze([Object.freeze({
|
|
660
|
+
id: "64789fae-8862-43ab-85ca-f3b41819bddb",
|
|
661
|
+
heading: "Explore",
|
|
662
|
+
links: Object.freeze([Object.freeze({
|
|
663
|
+
id: "b84094e6-ebd3-42ce-8c32-80166d73391a",
|
|
664
|
+
label: "About",
|
|
665
|
+
pageResourceId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
666
|
+
})]),
|
|
667
|
+
})]),
|
|
668
|
+
})]),
|
|
669
|
+
asideHeadingContent: Object.freeze({
|
|
670
|
+
paragraphs: Object.freeze([Object.freeze({
|
|
671
|
+
runs: Object.freeze([Object.freeze({ text: "Build a site worth returning to.", bold: true })]),
|
|
672
|
+
})]),
|
|
673
|
+
}),
|
|
674
|
+
asideBodyContent: Object.freeze({
|
|
675
|
+
paragraphs: Object.freeze([Object.freeze({
|
|
676
|
+
runs: Object.freeze([Object.freeze({ text: "Own the relationship with your readers." })]),
|
|
677
|
+
})]),
|
|
678
|
+
}),
|
|
679
|
+
bottomContent: Object.freeze({
|
|
680
|
+
paragraphs: Object.freeze([Object.freeze({ runs: Object.freeze([Object.freeze({ text: "© Example Studio" })]) })]),
|
|
681
|
+
}),
|
|
682
|
+
featureImage: Object.freeze({
|
|
683
|
+
imageId: "df51103e-6983-477d-8fc7-d59640e284df",
|
|
684
|
+
alt: "A sunlit creative studio with books, artwork, and a writing desk",
|
|
685
|
+
}),
|
|
686
|
+
showBrandText: true,
|
|
687
|
+
});
|