@taprootio/site-authoring 0.27.1 → 0.29.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/README.md +11 -12
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +8 -0
- package/package.json +1 -1
- package/src/changelog.json +37 -0
- package/src/cli.js +3 -3
- package/src/content/components.js +386 -20
- package/src/content/free-form-section-registry.json +37 -1
- package/src/footer-contract.js +59 -0
- package/src/footer-draft-hash.js +14 -1
- package/src/presentation-reference.js +23 -1
- package/src/reference-help.js +13 -6
- package/src/site-checks.js +25 -2
- package/src/social-link-url.js +160 -0
- package/src/social-platforms.json +16 -0
- package/src/typed-page-reference.js +7 -14
- package/src/typed-pages.js +11 -29
- package/src/verbs/pages-push.js +34 -11
- package/src/verbs/places.js +2 -2
- package/src/verbs/pull.js +1 -1
package/src/footer-contract.js
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
isContactOrWebUrl,
|
|
5
5
|
} from "./contact-url.js";
|
|
6
6
|
import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
7
|
+
import { isSocialLinkUrl, SOCIAL_LINK_URL_MAX_LENGTH, trimSocialLinkInput } from "./social-link-url.js";
|
|
8
|
+
import socialPlatforms from "./social-platforms.json" with { type: "json" };
|
|
7
9
|
import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
|
|
8
10
|
|
|
9
11
|
export const FOOTER_LIMITS = Object.freeze({
|
|
@@ -12,6 +14,7 @@ export const FOOTER_LIMITS = Object.freeze({
|
|
|
12
14
|
groupsPerColumn: 6,
|
|
13
15
|
linksPerGroup: 12,
|
|
14
16
|
bottomLinks: 8,
|
|
17
|
+
socialLinks: 12,
|
|
15
18
|
richTextParagraphs: 12,
|
|
16
19
|
richTextRuns: 200,
|
|
17
20
|
groupHeadingLength: 80,
|
|
@@ -56,10 +59,12 @@ export const FOOTER_FIELD_INVENTORY = Object.freeze({
|
|
|
56
59
|
"bottomContent",
|
|
57
60
|
"featureImage",
|
|
58
61
|
"showBrandText",
|
|
62
|
+
"socialLinks",
|
|
59
63
|
]),
|
|
60
64
|
FooterLinkColumn: Object.freeze(["id", "groups"]),
|
|
61
65
|
FooterLinkGroup: Object.freeze(["id", "heading", "links"]),
|
|
62
66
|
FooterLink: Object.freeze(["id", "label", "pageResourceId", "externalUrl"]),
|
|
67
|
+
FooterSocialLink: Object.freeze(["id", "platform", "url", "label"]),
|
|
63
68
|
FooterRichText: Object.freeze(["paragraphs"]),
|
|
64
69
|
FooterRichTextParagraph: Object.freeze(["runs"]),
|
|
65
70
|
FooterRichTextRun: Object.freeze(["text", "bold", "italic", "underline", "link"]),
|
|
@@ -82,6 +87,13 @@ export const FOOTER_FIELD_INVENTORY = Object.freeze({
|
|
|
82
87
|
FooterFeatureResponsiveImageUrl: Object.freeze(["minWidth", "url"]),
|
|
83
88
|
});
|
|
84
89
|
|
|
90
|
+
/** The platform keys a footer social link may name, in the vocabulary's order; `other` is the Website link. */
|
|
91
|
+
export const SOCIAL_PLATFORM_KEYS = Object.freeze(socialPlatforms.platforms.map((platform) => platform.key));
|
|
92
|
+
export const OTHER_SOCIAL_PLATFORM = "other";
|
|
93
|
+
export const SOCIAL_LINK_URL_REQUIREMENT =
|
|
94
|
+
`must be an absolute https URL without credentials, at most ${SOCIAL_LINK_URL_MAX_LENGTH} characters, `
|
|
95
|
+
+ "to a domain name of unaccented letters, digits and hyphens, like https://www.instagram.com/yourname";
|
|
96
|
+
|
|
85
97
|
export const FOOTER_READ_ONLY_FIELDS = Object.freeze([
|
|
86
98
|
"light.backgroundImageUrl",
|
|
87
99
|
"dark.backgroundImageUrl",
|
|
@@ -314,6 +326,40 @@ function footerLink(value, path, seenLinkIds, knownPageResourceIds, tolerateStor
|
|
|
314
326
|
};
|
|
315
327
|
}
|
|
316
328
|
|
|
329
|
+
// Mirrors the server's social-link rules (TR01324) in the same order: the id
|
|
330
|
+
// shares the footer's seen set, the platform is a vocabulary key, the address
|
|
331
|
+
// is judged after the shared trim, and a label is optional text that is
|
|
332
|
+
// refused when it is only whitespace and required for a Website link.
|
|
333
|
+
function socialLink(value, path, seenLinkIds, tolerateStoredValues) {
|
|
334
|
+
value = object(value, path);
|
|
335
|
+
closed(value, "FooterSocialLink", path);
|
|
336
|
+
const id = stableId(value.id, `${path}.id`, seenLinkIds, "link");
|
|
337
|
+
// A stored platform that later leaves the vocabulary must still pull, as a
|
|
338
|
+
// stored bottom-link address does; only an authored document is refused.
|
|
339
|
+
if (typeof value.platform !== "string" || (!tolerateStoredValues && !SOCIAL_PLATFORM_KEYS.includes(value.platform))) {
|
|
340
|
+
fail("footer.field_invalid", `${path}.platform must be one of ${SOCIAL_PLATFORM_KEYS.join(", ")}.`, `${path}.platform`);
|
|
341
|
+
}
|
|
342
|
+
if (typeof value.url !== "string") {
|
|
343
|
+
fail("footer.url_invalid", `${path}.url ${SOCIAL_LINK_URL_REQUIREMENT}.`, `${path}.url`);
|
|
344
|
+
}
|
|
345
|
+
const url = trimSocialLinkInput(value.url);
|
|
346
|
+
if (!tolerateStoredValues && !isSocialLinkUrl(url)) {
|
|
347
|
+
fail("footer.url_invalid", `${path}.url ${SOCIAL_LINK_URL_REQUIREMENT}.`, `${path}.url`);
|
|
348
|
+
}
|
|
349
|
+
const rawLabel = value.label === undefined ? "" : value.label;
|
|
350
|
+
const label = normalizedSingleLine(rawLabel, FOOTER_LIMITS.linkLabelLength, `${path}.label`, { tolerateStoredValues });
|
|
351
|
+
if (!tolerateStoredValues) {
|
|
352
|
+
// Blank is judged with the shared whitespace set, as the page component and the handler judge it.
|
|
353
|
+
if (rawLabel.length > 0 && trimSocialLinkInput(label) === "") {
|
|
354
|
+
fail("footer.text_required", `${path}.label: A label must have text, or leave it out.`, `${path}.label`);
|
|
355
|
+
}
|
|
356
|
+
if (trimSocialLinkInput(label) === "" && value.platform === OTHER_SOCIAL_PLATFORM) {
|
|
357
|
+
fail("footer.text_required", `${path}.label: A Website link needs a label.`, `${path}.label`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { id, platform: value.platform, url, label };
|
|
361
|
+
}
|
|
362
|
+
|
|
317
363
|
function footerLinks(values, path, maximum, seenLinkIds, knownPageResourceIds, tolerateStoredValues) {
|
|
318
364
|
return array(values, path, maximum).map((value, index) =>
|
|
319
365
|
footerLink(value, `${path}[${index}]`, seenLinkIds, knownPageResourceIds, tolerateStoredValues)
|
|
@@ -463,6 +509,7 @@ export function footerDefaults() {
|
|
|
463
509
|
bottomContent: { paragraphs: [] },
|
|
464
510
|
featureImage: undefined,
|
|
465
511
|
showBrandText: true,
|
|
512
|
+
socialLinks: [],
|
|
466
513
|
};
|
|
467
514
|
}
|
|
468
515
|
|
|
@@ -571,6 +618,8 @@ function normalizeFooterDocument(
|
|
|
571
618
|
),
|
|
572
619
|
...(normalizedFeatureImage === undefined ? {} : { featureImage: normalizedFeatureImage }),
|
|
573
620
|
showBrandText: boolean(value.showBrandText, true, "footerSettings.showBrandText"),
|
|
621
|
+
socialLinks: array(value.socialLinks, "footerSettings.socialLinks", FOOTER_LIMITS.socialLinks)
|
|
622
|
+
.map((link, index) => socialLink(link, `footerSettings.socialLinks[${index}]`, seenLinkIds, tolerateStoredValues)),
|
|
574
623
|
};
|
|
575
624
|
}
|
|
576
625
|
|
|
@@ -684,4 +733,14 @@ export const FOOTER_EXAMPLE = Object.freeze({
|
|
|
684
733
|
alt: "A sunlit creative studio with books, artwork, and a writing desk",
|
|
685
734
|
}),
|
|
686
735
|
showBrandText: true,
|
|
736
|
+
// The icon row between the bottom content and the bottom links. A label is
|
|
737
|
+
// the link's text; without one the platform's name is read out.
|
|
738
|
+
socialLinks: Object.freeze([
|
|
739
|
+
Object.freeze({
|
|
740
|
+
id: "7c1f3a52-9d4e-4b8a-8f1e-2a6c9d0e4b11",
|
|
741
|
+
platform: "instagram",
|
|
742
|
+
url: "https://www.instagram.com/examplestudio",
|
|
743
|
+
label: "Example Studio on Instagram",
|
|
744
|
+
}),
|
|
745
|
+
]),
|
|
687
746
|
});
|
package/src/footer-draft-hash.js
CHANGED
|
@@ -88,7 +88,7 @@ export function stableJson(value) {
|
|
|
88
88
|
// the canonical form.
|
|
89
89
|
export function computeFooterDraftHash(settings = {}) {
|
|
90
90
|
const canonical = [];
|
|
91
|
-
append(canonical, "footer-draft-
|
|
91
|
+
append(canonical, "footer-draft-v4");
|
|
92
92
|
append(canonical, settings.enabled === true);
|
|
93
93
|
append(canonical, settings.showBrand ?? true);
|
|
94
94
|
append(canonical, settings.showBrandText ?? true);
|
|
@@ -113,6 +113,7 @@ export function computeFooterDraftHash(settings = {}) {
|
|
|
113
113
|
if (hasAsideCta) appendLink(canonical, settings.asideCta);
|
|
114
114
|
appendRichText(canonical, settings.bottomContent);
|
|
115
115
|
appendLinks(canonical, settings.bottomLinks);
|
|
116
|
+
appendSocialLinks(canonical, settings.socialLinks);
|
|
116
117
|
appendScheme(canonical, settings.light);
|
|
117
118
|
appendScheme(canonical, settings.dark);
|
|
118
119
|
|
|
@@ -132,6 +133,18 @@ function appendLinks(canonical, rawLinks) {
|
|
|
132
133
|
for (const link of links) appendLink(canonical, link);
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
function appendSocialLinks(canonical, rawLinks) {
|
|
137
|
+
const links = Array.isArray(rawLinks) ? rawLinks : [];
|
|
138
|
+
append(canonical, links.length);
|
|
139
|
+
for (const rawLink of links) {
|
|
140
|
+
const link = rawLink ?? {};
|
|
141
|
+
append(canonical, normalizeId(link.id));
|
|
142
|
+
append(canonical, link.platform ?? "");
|
|
143
|
+
append(canonical, link.url ?? "");
|
|
144
|
+
append(canonical, link.label ?? "");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
// Coalesce rather than default so the canonical form stays total even for a
|
|
136
149
|
// null entry: a parameter default covers only undefined.
|
|
137
150
|
function appendLink(canonical, rawLink) {
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
FOOTER_FIELD_INVENTORY,
|
|
36
36
|
FOOTER_LIMITS,
|
|
37
37
|
FOOTER_READ_ONLY_FIELDS,
|
|
38
|
+
SOCIAL_LINK_URL_REQUIREMENT,
|
|
39
|
+
SOCIAL_PLATFORM_KEYS,
|
|
38
40
|
validateFooterDocument,
|
|
39
41
|
} from "./footer-contract.js";
|
|
40
42
|
import {
|
|
@@ -589,6 +591,11 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
|
|
|
589
591
|
maximumParagraphs: FOOTER_LIMITS.richTextParagraphs,
|
|
590
592
|
},
|
|
591
593
|
"FooterSettings.bottomLinks": { type: "FooterLink[]", default: [], maximumItems: FOOTER_LIMITS.bottomLinks },
|
|
594
|
+
"FooterSettings.socialLinks": {
|
|
595
|
+
type: "FooterSocialLink[]",
|
|
596
|
+
default: [],
|
|
597
|
+
maximumItems: FOOTER_LIMITS.socialLinks,
|
|
598
|
+
},
|
|
592
599
|
"FooterSettings.light": { type: "FooterSchemeSettings", default: "scheme defaults" },
|
|
593
600
|
"FooterSettings.dark": { type: "FooterSchemeSettings", default: "scheme defaults" },
|
|
594
601
|
"FooterSettings.featureImage": { type: "FooterFeatureImageSettings|null", default: null },
|
|
@@ -609,6 +616,14 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
|
|
|
609
616
|
exclusiveWith: "pageResourceId",
|
|
610
617
|
maximumLength: FOOTER_LIMITS.externalUrlLength,
|
|
611
618
|
},
|
|
619
|
+
"FooterSocialLink.id": { type: "canonical-lowercase-uuid", required: true },
|
|
620
|
+
"FooterSocialLink.platform": { type: "social-platform-key", required: true, values: SOCIAL_PLATFORM_KEYS },
|
|
621
|
+
"FooterSocialLink.url": { type: "https-url", required: true, maximumLength: FOOTER_LIMITS.externalUrlLength },
|
|
622
|
+
"FooterSocialLink.label": {
|
|
623
|
+
type: "plain-text",
|
|
624
|
+
requiredWhen: "platform is other",
|
|
625
|
+
maximumLength: FOOTER_LIMITS.linkLabelLength,
|
|
626
|
+
},
|
|
612
627
|
"FooterRichText.paragraphs": { type: "FooterRichTextParagraph[]", default: [] },
|
|
613
628
|
"FooterRichTextParagraph.runs": { type: "FooterRichTextRun[]", default: [] },
|
|
614
629
|
"FooterRichTextRun.text": { type: "string", required: true },
|
|
@@ -703,6 +718,12 @@ export function getFooterReference() {
|
|
|
703
718
|
"Each link has exactly one target: a tracked pageResourceId, or an externalUrl that is an absolute "
|
|
704
719
|
+ "credential-free http/https URL, or a mailto:/tel: contact URL such as tel:+15555550123. A contact URL is "
|
|
705
720
|
+ "published exactly as authored, so write the number the way it should be dialled.",
|
|
721
|
+
socialLinkRule:
|
|
722
|
+
"Each social link names a platform key (" + SOCIAL_PLATFORM_KEYS.join(", ") + ") and a url that "
|
|
723
|
+
+ SOCIAL_LINK_URL_REQUIREMENT + ". The label is the link's text and what screen readers announce; "
|
|
724
|
+
+ "leave it empty to use the platform's name, and give one to every other (Website) link. Domain names "
|
|
725
|
+
+ "with accented or non-English letters are not supported here; use a bottom link for that site. "
|
|
726
|
+
+ "The row renders between the bottom content and the bottom links.",
|
|
706
727
|
imageRule:
|
|
707
728
|
"Image ids come from pull or media upload. Feature images require alt text; background art is decorative.",
|
|
708
729
|
readOnlyProjections: FOOTER_READ_ONLY_FIELDS,
|
|
@@ -837,7 +858,8 @@ export function formatPresentationReference(reference) {
|
|
|
837
858
|
).join("\n\n");
|
|
838
859
|
return reference.title + "\nUsage: " + reference.usage + "\nWorkspace: "
|
|
839
860
|
+ reference.workspaceFile + " :: " + reference.workspacePath + "\n\nClosed schema:\n"
|
|
840
|
-
+ messages + "\n\nTargets: " + reference.targetRule + "\
|
|
861
|
+
+ messages + "\n\nTargets: " + reference.targetRule + "\nSocial links: " + reference.socialLinkRule
|
|
862
|
+
+ "\nImages: " + reference.imageRule
|
|
841
863
|
+ "\nConcurrency: " + reference.concurrency
|
|
842
864
|
+ "\nTheme interaction: " + reference.themeInteraction + "\nRead-only projections: "
|
|
843
865
|
+ reference.readOnlyProjections.join(", ") + "\n\nWorkflow:\n"
|
package/src/reference-help.js
CHANGED
|
@@ -95,7 +95,7 @@ import {
|
|
|
95
95
|
|
|
96
96
|
export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
|
|
97
97
|
|
|
98
|
-
export const REFERENCE_VERSION =
|
|
98
|
+
export const REFERENCE_VERSION = 48;
|
|
99
99
|
|
|
100
100
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
101
101
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -755,7 +755,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
755
755
|
+ "those away, so '/x/../visit' would be checked as itself and served as '/visit' — past the occupancy, "
|
|
756
756
|
+ "chain, and loop rules that were asked about a different path. Write the resolved path instead.",
|
|
757
757
|
"origin is reported by the site and ignored on push: 'authored' is what you declared, 'path_history' is "
|
|
758
|
-
+ "what a page
|
|
758
|
+
+ "what a page rename recorded. Pull the map, edit it, push the whole thing back — an entry you "
|
|
759
759
|
+ "did not change keeps its origin.",
|
|
760
760
|
"Refused, offline and again at the site, each naming the entry index: a chain (a target that is itself a "
|
|
761
761
|
+ "source in the same map); a loop (an entry targeting itself); a duplicate path; a gone entry carrying a "
|
|
@@ -786,7 +786,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
786
786
|
+ "check does not see may belong to a release the edge has not switched to yet; deploy --staging waits for "
|
|
787
787
|
+ "staging to serve the new release and reports stagingServed.",
|
|
788
788
|
"A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
|
|
789
|
-
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico',
|
|
789
|
+
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico', the published archive's data files ('/published/months.json' "
|
|
790
|
+
+ "and '/published/<year>/<month>.json'), the map page's data file ('/map/places.json'), a path under any of those names, or the "
|
|
790
791
|
+ "search-engine key file Taproot publishes when a site tells search engines about its releases (32 "
|
|
791
792
|
+ "lowercase hexadecimal characters followed by '.txt', in any capitals). Nothing under "
|
|
792
793
|
+ "'/taproot/<version>/' (for example '/taproot/5.0.62/manifest.json') can be a source either: the platform "
|
|
@@ -870,8 +871,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
870
871
|
+ "refused as forms.page_not_found, so push the page first. It need not be published to save the form, but "
|
|
871
872
|
+ "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
|
|
872
873
|
+ "each release is the one the page has in that release, so the published form never asks the site where "
|
|
873
|
-
+ "to go. The page must be one you created, the home page included: a generated page (folder
|
|
874
|
-
+ "archive
|
|
874
|
+
+ "to go. The page must be one you created, the home page included: a generated page (a folder index or "
|
|
875
|
+
+ "the published archive), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
|
|
875
876
|
+ "the 'form' component takes only formKey.",
|
|
876
877
|
"A form's contact address is the value of one email field, stored with each submission. That is what lets the "
|
|
877
878
|
+ "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
|
|
@@ -1937,6 +1938,7 @@ function schemaConstraints(schema) {
|
|
|
1937
1938
|
const constraints = [];
|
|
1938
1939
|
if (schema.minimum !== undefined) constraints.push(`minimum ${schema.minimum}`);
|
|
1939
1940
|
if (schema.maximum !== undefined) constraints.push(`maximum ${schema.maximum}`);
|
|
1941
|
+
if (schema.minItems !== undefined) constraints.push(`minimum ${schema.minItems} items`);
|
|
1940
1942
|
if (schema.maxItems !== undefined) constraints.push(`maximum ${schema.maxItems} items`);
|
|
1941
1943
|
if (schema.minLength !== undefined) constraints.push(`minimum length ${schema.minLength}`);
|
|
1942
1944
|
if (schema.maxLength !== undefined) constraints.push(`maximum length ${schema.maxLength}`);
|
|
@@ -1952,7 +1954,12 @@ function schemaConstraints(schema) {
|
|
|
1952
1954
|
|
|
1953
1955
|
function formatSchemaLine(name, schema, required, indent) {
|
|
1954
1956
|
const constraints = schemaConstraints(schema);
|
|
1955
|
-
|
|
1957
|
+
// A required image (TR01322) takes no null in either form.
|
|
1958
|
+
const label = schema.markdownType
|
|
1959
|
+
? Array.isArray(schema.type)
|
|
1960
|
+
? "media path | null in Markdown; object | null in .pm.json"
|
|
1961
|
+
: "media path in Markdown; object in .pm.json"
|
|
1962
|
+
: schemaLabel(schema);
|
|
1956
1963
|
return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${label}; ${
|
|
1957
1964
|
required ? "required" : "optional"
|
|
1958
1965
|
}${constraints.length > 0 ? `; ${constraints.join("; ")}` : ""}`;
|
package/src/site-checks.js
CHANGED
|
@@ -159,7 +159,9 @@ function addHtmlLinks(links, html, currentPagePath) {
|
|
|
159
159
|
}
|
|
160
160
|
function isUrlProperty(name) {
|
|
161
161
|
const lower = name.toLowerCase();
|
|
162
|
-
|
|
162
|
+
// Component buttons and cards (cta, card-grid, product-grid; TR01323).
|
|
163
|
+
return lower === "href" || lower === "url" || lower === "link" || lower === "pagepath"
|
|
164
|
+
|| lower === "buttonurl" || lower === "linkurl";
|
|
163
165
|
}
|
|
164
166
|
function addEncodedComponentLinks(links, value, currentPagePath) {
|
|
165
167
|
const decoded = htmlDecodeRepeatedly(value);
|
|
@@ -282,6 +284,13 @@ export function normalizeRedirectPath(value) {
|
|
|
282
284
|
const GENERATED_ROOT_FILES = ["404.html", "sitemap.xml", "robots.txt", "favicon.ico"];
|
|
283
285
|
// The IndexNow key file (TR01304) is named by a pattern, so it cannot join the list above.
|
|
284
286
|
const INDEX_NOW_KEY_FILE = /^[a-f0-9]{32}\.txt$/u;
|
|
287
|
+
// The /published archive's data files (TR01309): months.json and yyyy/mm.json, with anything beneath either;
|
|
288
|
+
// a marker at one of those names fails every publish in the generator's occupancy check. The API mirror is
|
|
289
|
+
// SiteRedirectMap.IsPublishedArchiveDataPath.
|
|
290
|
+
const PUBLISHED_ARCHIVE_ROOT = "published";
|
|
291
|
+
const ARCHIVE_MONTHS_FILE = "months.json";
|
|
292
|
+
const ARCHIVE_YEAR_SEGMENT = /^\d{4}$/u;
|
|
293
|
+
const ARCHIVE_MONTH_FILE = /^\d{2}\.json$/iu;
|
|
285
294
|
const INTEGRATION_DATA_ROOT = "integrations";
|
|
286
295
|
const MAX_MARKER_SEGMENT_BYTES = 200;
|
|
287
296
|
const MAX_OUTPUT_PATH_LENGTH = 1_024;
|
|
@@ -290,6 +299,18 @@ function isWindowsDeviceSegment(segment) {
|
|
|
290
299
|
const stem = segment.split(".", 1)[0].toLowerCase();
|
|
291
300
|
return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
|
|
292
301
|
}
|
|
302
|
+
function isPublishedArchiveDataPath(segments) {
|
|
303
|
+
if (segments.length < 2 || segments[0].toLowerCase() !== PUBLISHED_ARCHIVE_ROOT)
|
|
304
|
+
return false;
|
|
305
|
+
if (segments[1].toLowerCase() === ARCHIVE_MONTHS_FILE)
|
|
306
|
+
return true;
|
|
307
|
+
return segments.length >= 3 && ARCHIVE_YEAR_SEGMENT.test(segments[1]) && ARCHIVE_MONTH_FILE.test(segments[2]);
|
|
308
|
+
}
|
|
309
|
+
// The /map page's data file (TR01321), with anything beneath it, for the same reason.
|
|
310
|
+
// The API mirrors this in SiteRedirectMap.IsPlaceMapDataPath.
|
|
311
|
+
function isPlaceMapDataPath(segments) {
|
|
312
|
+
return segments.length >= 2 && segments[0].toLowerCase() === "map" && segments[1].toLowerCase() === "places.json";
|
|
313
|
+
}
|
|
293
314
|
function isStorable(decoded) {
|
|
294
315
|
const output = `${decoded}/index.html`;
|
|
295
316
|
if (output !== output.normalize("NFC") || output.length > MAX_OUTPUT_PATH_LENGTH || output.startsWith("/")
|
|
@@ -319,7 +340,9 @@ export function redirectSourceRule(edgePath) {
|
|
|
319
340
|
return refuse(RULE_REDIRECT_SOURCE_INTEGRATION_DATA);
|
|
320
341
|
if (isRuntimeMirrorPath(decoded))
|
|
321
342
|
return refuse(RULE_REDIRECT_SOURCE_RUNTIME_MIRROR);
|
|
322
|
-
if (GENERATED_ROOT_FILES.includes(first.toLowerCase()) || INDEX_NOW_KEY_FILE.test(first.toLowerCase())
|
|
343
|
+
if (GENERATED_ROOT_FILES.includes(first.toLowerCase()) || INDEX_NOW_KEY_FILE.test(first.toLowerCase())
|
|
344
|
+
|| isPublishedArchiveDataPath(segments)
|
|
345
|
+
|| isPlaceMapDataPath(segments)) {
|
|
323
346
|
return refuse(RULE_REDIRECT_SOURCE_GENERATED_FILE);
|
|
324
347
|
}
|
|
325
348
|
if (!isStorable(decoded))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Generated from shared/social-link-url.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The one owner of which addresses a social link accepts (TR01324).
|
|
4
|
+
*
|
|
5
|
+
* A social link is an absolute https URL to a plain domain name. The rule is
|
|
6
|
+
* literal on purpose: every verdict a URL parser might decide differently in
|
|
7
|
+
* JavaScript and .NET (case folding, IDNA mapping, IPv4 shorthand, length
|
|
8
|
+
* limits, whitespace) is decided here by explicit character checks first, and
|
|
9
|
+
* the parser is only asked whether what remains parses at all. That keeps the
|
|
10
|
+
* CLI, the generator, the editor and the API's own port in agreement, which
|
|
11
|
+
* `shared/social-link-url-corpus.json` proves in each of them.
|
|
12
|
+
*
|
|
13
|
+
* It is pure and dependency-free. The generator and the site-authoring CLI
|
|
14
|
+
* cannot import this file, so `node scripts/sync-field-validation.mjs` writes
|
|
15
|
+
* their copies; edit only this file and re-run it. The editor imports it by
|
|
16
|
+
* relative path.
|
|
17
|
+
*/
|
|
18
|
+
/** The longest address stored anywhere a social link lives. */
|
|
19
|
+
export const SOCIAL_LINK_URL_MAX_LENGTH = 2048;
|
|
20
|
+
/**
|
|
21
|
+
* The whitespace a social link may not contain and a typed value is trimmed
|
|
22
|
+
* of: the .NET `char.IsWhiteSpace` set, which the footer's single-line
|
|
23
|
+
* normalisation already mirrors, plus U+FEFF, which JavaScript's `trim` strips
|
|
24
|
+
* and .NET's does not.
|
|
25
|
+
*/
|
|
26
|
+
export function isSocialLinkWhiteSpace(codeUnit) {
|
|
27
|
+
return (codeUnit >= 0x0009 && codeUnit <= 0x000d)
|
|
28
|
+
|| codeUnit === 0x0020
|
|
29
|
+
|| codeUnit === 0x0085
|
|
30
|
+
|| codeUnit === 0x00a0
|
|
31
|
+
|| codeUnit === 0x1680
|
|
32
|
+
|| (codeUnit >= 0x2000 && codeUnit <= 0x200a)
|
|
33
|
+
|| codeUnit === 0x2028
|
|
34
|
+
|| codeUnit === 0x2029
|
|
35
|
+
|| codeUnit === 0x202f
|
|
36
|
+
|| codeUnit === 0x205f
|
|
37
|
+
|| codeUnit === 0x3000
|
|
38
|
+
|| codeUnit === 0xfeff;
|
|
39
|
+
}
|
|
40
|
+
/** Strips the whitespace set above from both ends; what a typed address is judged on. */
|
|
41
|
+
export function trimSocialLinkInput(value) {
|
|
42
|
+
let start = 0;
|
|
43
|
+
let end = value.length;
|
|
44
|
+
while (start < end && isSocialLinkWhiteSpace(value.charCodeAt(start)))
|
|
45
|
+
start += 1;
|
|
46
|
+
while (end > start && isSocialLinkWhiteSpace(value.charCodeAt(end - 1)))
|
|
47
|
+
end -= 1;
|
|
48
|
+
return value.slice(start, end);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Control characters, the backslash, and the default-ignorable and format
|
|
52
|
+
* code points that can hide inside an address; none has a place in a link.
|
|
53
|
+
*/
|
|
54
|
+
function isRefusedCodeUnit(codeUnit) {
|
|
55
|
+
return codeUnit <= 0x001f
|
|
56
|
+
|| (codeUnit >= 0x007f && codeUnit <= 0x009f)
|
|
57
|
+
|| codeUnit === 0x005c
|
|
58
|
+
|| codeUnit === 0x00ad
|
|
59
|
+
|| codeUnit === 0x034f
|
|
60
|
+
|| (codeUnit >= 0x200b && codeUnit <= 0x200f)
|
|
61
|
+
|| (codeUnit >= 0x202a && codeUnit <= 0x202e)
|
|
62
|
+
|| (codeUnit >= 0x2060 && codeUnit <= 0x206f)
|
|
63
|
+
|| (codeUnit >= 0xfe00 && codeUnit <= 0xfe0f)
|
|
64
|
+
|| codeUnit >= 0xfff0;
|
|
65
|
+
}
|
|
66
|
+
/** Folds only A to Z, so no runtime's case mapping (U+212A to k, U+0130) can reach a verdict; the editors' field copy reads it too. */
|
|
67
|
+
export function asciiLower(value) {
|
|
68
|
+
let result = "";
|
|
69
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
70
|
+
const codeUnit = value.charCodeAt(index);
|
|
71
|
+
result += codeUnit >= 0x41 && codeUnit <= 0x5a ? String.fromCharCode(codeUnit + 0x20) : value[index];
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
const LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
76
|
+
/** A final label that is a number in decimal or hex makes the host an IPv4 form, which is refused. */
|
|
77
|
+
const NUMERIC_FINAL_LABEL_RE = /^(?:[0-9]+|0[xX][0-9a-fA-F]*)$/;
|
|
78
|
+
const PORT_RE = /^[0-9]+$/;
|
|
79
|
+
const HOST_MAX_LENGTH = 253;
|
|
80
|
+
const LABEL_MAX_LENGTH = 63;
|
|
81
|
+
function isAcceptedAuthority(authority) {
|
|
82
|
+
if (authority.length === 0)
|
|
83
|
+
return false;
|
|
84
|
+
for (let index = 0; index < authority.length; index += 1) {
|
|
85
|
+
const codeUnit = authority.charCodeAt(index);
|
|
86
|
+
if (codeUnit > 0x7f)
|
|
87
|
+
return false;
|
|
88
|
+
const character = authority[index];
|
|
89
|
+
if (character === "@" || character === "%" || character === "_")
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
let host = authority;
|
|
93
|
+
const colon = authority.indexOf(":");
|
|
94
|
+
if (colon !== -1) {
|
|
95
|
+
host = authority.slice(0, colon);
|
|
96
|
+
const port = authority.slice(colon + 1);
|
|
97
|
+
if (!PORT_RE.test(port))
|
|
98
|
+
return false;
|
|
99
|
+
const portNumber = Number(port);
|
|
100
|
+
if (portNumber < 1 || portNumber > 65535)
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (host.length > HOST_MAX_LENGTH)
|
|
104
|
+
return false;
|
|
105
|
+
const labels = host.split(".");
|
|
106
|
+
if (labels.length < 2)
|
|
107
|
+
return false;
|
|
108
|
+
for (const label of labels) {
|
|
109
|
+
if (label.length < 1 || label.length > LABEL_MAX_LENGTH || !LABEL_RE.test(label))
|
|
110
|
+
return false;
|
|
111
|
+
if (asciiLower(label).startsWith("xn--"))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return !NUMERIC_FINAL_LABEL_RE.test(labels[labels.length - 1]);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Whether a value, exactly as written, is an address a social link accepts.
|
|
118
|
+
* Typed input is trimmed with `trimSocialLinkInput` first; a hand-written
|
|
119
|
+
* document is judged as it is.
|
|
120
|
+
*/
|
|
121
|
+
export function isSocialLinkUrl(value) {
|
|
122
|
+
if (typeof value !== "string" || value.length === 0 || value.length > SOCIAL_LINK_URL_MAX_LENGTH)
|
|
123
|
+
return false;
|
|
124
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
125
|
+
const codeUnit = value.charCodeAt(index);
|
|
126
|
+
if (isSocialLinkWhiteSpace(codeUnit) || isRefusedCodeUnit(codeUnit))
|
|
127
|
+
return false;
|
|
128
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
129
|
+
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
|
|
130
|
+
if (next < 0xdc00 || next > 0xdfff)
|
|
131
|
+
return false;
|
|
132
|
+
index += 1;
|
|
133
|
+
}
|
|
134
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (asciiLower(value.slice(0, 8)) !== "https://")
|
|
139
|
+
return false;
|
|
140
|
+
const rest = value.slice(8);
|
|
141
|
+
let authorityEnd = rest.length;
|
|
142
|
+
for (const terminator of ["/", "?", "#"]) {
|
|
143
|
+
const at = rest.indexOf(terminator);
|
|
144
|
+
if (at !== -1 && at < authorityEnd)
|
|
145
|
+
authorityEnd = at;
|
|
146
|
+
}
|
|
147
|
+
if (!isAcceptedAuthority(rest.slice(0, authorityEnd)))
|
|
148
|
+
return false;
|
|
149
|
+
try {
|
|
150
|
+
new URL(value);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/** The address as published: the parsed form, as the footer emits its own links. */
|
|
158
|
+
export function socialLinkHref(value) {
|
|
159
|
+
return new URL(value).toString();
|
|
160
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"platforms": [
|
|
3
|
+
{ "key": "instagram", "name": "Instagram", "icon": "brand-instagram" },
|
|
4
|
+
{ "key": "facebook", "name": "Facebook", "icon": "brand-facebook" },
|
|
5
|
+
{ "key": "yelp", "name": "Yelp", "icon": "brand-yelp" },
|
|
6
|
+
{ "key": "youtube", "name": "YouTube", "icon": "brand-youtube" },
|
|
7
|
+
{ "key": "tiktok", "name": "TikTok", "icon": "brand-tiktok" },
|
|
8
|
+
{ "key": "linkedin", "name": "LinkedIn", "icon": "brand-linkedin" },
|
|
9
|
+
{ "key": "x", "name": "X", "icon": "brand-x" },
|
|
10
|
+
{ "key": "threads", "name": "Threads", "icon": "brand-threads" },
|
|
11
|
+
{ "key": "bluesky", "name": "Bluesky", "icon": "brand-bluesky" },
|
|
12
|
+
{ "key": "mastodon", "name": "Mastodon", "icon": "brand-mastodon" },
|
|
13
|
+
{ "key": "pinterest", "name": "Pinterest", "icon": "brand-pinterest" },
|
|
14
|
+
{ "key": "other", "name": "Website", "icon": "external-link" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -77,7 +77,7 @@ const FIELD_HELP = Object.freeze({
|
|
|
77
77
|
description:
|
|
78
78
|
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
|
|
79
79
|
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'. The site publishes the "
|
|
80
|
-
+ "place's category in the byline
|
|
80
|
+
+ "place's category in the byline; change it for this site with "
|
|
81
81
|
+ "'places category set <placeId> <category>'.",
|
|
82
82
|
},
|
|
83
83
|
place: {
|
|
@@ -263,7 +263,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
263
263
|
},
|
|
264
264
|
[TEMPLATE_PLACE_REVIEW]: {
|
|
265
265
|
displayName: "Place review",
|
|
266
|
-
summary: "A review of a place with a three-step rating
|
|
266
|
+
summary: "A review of a place with a three-step rating; its address card opens the place on Google Maps, and its location byline opens the site's map page when it has one.",
|
|
267
267
|
body: "Everything after the front matter is the review body.",
|
|
268
268
|
markdownExample: PLACE_REVIEW_EXAMPLE,
|
|
269
269
|
documentExample: documentExample(
|
|
@@ -284,20 +284,14 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
284
284
|
[TEMPLATE_GENERATED]: {
|
|
285
285
|
displayName: "Generated page",
|
|
286
286
|
summary:
|
|
287
|
-
"A listing Taproot generates from the site's content:
|
|
288
|
-
+ "
|
|
287
|
+
"A listing Taproot generates from the site's content: the published archive, the map or a folder. You cannot create "
|
|
288
|
+
+ "one; you customize its title, breadcrumb, description and introduction. A folder index is a search "
|
|
289
|
+
+ "destination when it has an introduction or renders at least three cards; otherwise it carries noindex.",
|
|
289
290
|
documentExample: documentExample(
|
|
290
291
|
TEMPLATE_GENERATED,
|
|
291
292
|
{},
|
|
292
293
|
{
|
|
293
|
-
kind: "
|
|
294
|
-
tagId: "0198a3f2-7c4e-4a10-9b2d-3f6e5d4c3b2a",
|
|
295
|
-
year: 0,
|
|
296
|
-
month: 0,
|
|
297
|
-
countryCode: "",
|
|
298
|
-
regionCode: "",
|
|
299
|
-
citySlug: "",
|
|
300
|
-
categorySlug: "",
|
|
294
|
+
kind: "GENERATED_PAGE_KIND_FOLDER_INDEX",
|
|
301
295
|
customTitle: "Trail notes",
|
|
302
296
|
breadcrumbTitle: "",
|
|
303
297
|
customDescription: "Field notes from the trails I walk most often.",
|
|
@@ -305,8 +299,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
305
299
|
},
|
|
306
300
|
),
|
|
307
301
|
dataFields: [
|
|
308
|
-
"kind
|
|
309
|
-
+ "page is, written by pull. Leave them as pulled; a push that changes them is refused (pages.generated_identity)",
|
|
302
|
+
"kind: what the page is, written by pull. Leave it as pulled; a push that changes it is refused (pages.generated_identity)",
|
|
310
303
|
"customTitle: string; empty uses the generated title (the page's reader-facing title)",
|
|
311
304
|
"breadcrumbTitle: string; empty uses the generated label (used on pages beneath this one)",
|
|
312
305
|
"customDescription: string, at most 1000 characters, about 160 shown in search results; empty uses the "
|
package/src/typed-pages.js
CHANGED
|
@@ -32,7 +32,7 @@ export const TEMPLATE_ALBUM = "album";
|
|
|
32
32
|
export const TEMPLATE_PLACE_REVIEW = "place-review";
|
|
33
33
|
export const TEMPLATE_LEGAL = "legal";
|
|
34
34
|
/**
|
|
35
|
-
* A page Taproot generates (
|
|
35
|
+
* A page Taproot generates (the published archive, the map or a folder). It is update-only: the
|
|
36
36
|
* owner's title, breadcrumb, description and introduction are authored, and
|
|
37
37
|
* pages push refuses to create one or to change what it is (TR01195).
|
|
38
38
|
*/
|
|
@@ -111,9 +111,18 @@ const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
|
|
|
111
111
|
|
|
112
112
|
/** The generated page kinds the site reports, as the API names them (never the unknown kind). */
|
|
113
113
|
export const GENERATED_PAGE_KINDS = Object.freeze([
|
|
114
|
+
"GENERATED_PAGE_KIND_PUBLISHED_INDEX",
|
|
115
|
+
"GENERATED_PAGE_KIND_FOLDER_INDEX",
|
|
116
|
+
"GENERATED_PAGE_KIND_MAP_INDEX",
|
|
117
|
+
]);
|
|
118
|
+
/**
|
|
119
|
+
* The kinds Taproot no longer generates (TR01308, TR01309, TR01310), which an older
|
|
120
|
+
* pull may have written. Named, so a typo or the unknown kind is refused as a bad
|
|
121
|
+
* document and is never mistaken for a page Taproot retired.
|
|
122
|
+
*/
|
|
123
|
+
export const RETIRED_GENERATED_PAGE_KINDS = Object.freeze([
|
|
114
124
|
"GENERATED_PAGE_KIND_TAG",
|
|
115
125
|
"GENERATED_PAGE_KIND_TAGS_INDEX",
|
|
116
|
-
"GENERATED_PAGE_KIND_PUBLISHED_INDEX",
|
|
117
126
|
"GENERATED_PAGE_KIND_PUBLISHED_YEAR",
|
|
118
127
|
"GENERATED_PAGE_KIND_PUBLISHED_MONTH",
|
|
119
128
|
"GENERATED_PAGE_KIND_PLACES_INDEX",
|
|
@@ -121,18 +130,10 @@ export const GENERATED_PAGE_KINDS = Object.freeze([
|
|
|
121
130
|
"GENERATED_PAGE_KIND_PLACES_STATE",
|
|
122
131
|
"GENERATED_PAGE_KIND_PLACES_CITY",
|
|
123
132
|
"GENERATED_PAGE_KIND_PLACES_CATEGORY",
|
|
124
|
-
"GENERATED_PAGE_KIND_FOLDER_INDEX",
|
|
125
133
|
]);
|
|
126
134
|
/** What a generated page is: system-managed, so a push must send it back unchanged. */
|
|
127
135
|
export const GENERATED_IDENTITY_KEYS = Object.freeze([
|
|
128
136
|
"kind",
|
|
129
|
-
"tagId",
|
|
130
|
-
"year",
|
|
131
|
-
"month",
|
|
132
|
-
"countryCode",
|
|
133
|
-
"regionCode",
|
|
134
|
-
"citySlug",
|
|
135
|
-
"categorySlug",
|
|
136
137
|
]);
|
|
137
138
|
const GENERATED_KEYS = [
|
|
138
139
|
...GENERATED_IDENTITY_KEYS,
|
|
@@ -142,17 +143,10 @@ const GENERATED_KEYS = [
|
|
|
142
143
|
"introductionBody",
|
|
143
144
|
];
|
|
144
145
|
const GENERATED_TEXT_KEYS = [
|
|
145
|
-
"tagId",
|
|
146
|
-
"countryCode",
|
|
147
|
-
"regionCode",
|
|
148
|
-
"citySlug",
|
|
149
|
-
"categorySlug",
|
|
150
146
|
"customTitle",
|
|
151
147
|
"breadcrumbTitle",
|
|
152
148
|
"customDescription",
|
|
153
149
|
];
|
|
154
|
-
const GENERATED_MAXIMUM_YEAR = 9999;
|
|
155
|
-
const GENERATED_MAXIMUM_MONTH = 12;
|
|
156
150
|
const EMPTY_DOCUMENT = Object.freeze({ type: "doc", content: Object.freeze([]) });
|
|
157
151
|
|
|
158
152
|
function fail(code, message, field) {
|
|
@@ -368,13 +362,6 @@ const DATA_BUILDERS = Object.freeze({
|
|
|
368
362
|
[TEMPLATE_GENERATED]: (raw) =>
|
|
369
363
|
definedEntries([
|
|
370
364
|
["kind", raw.kind],
|
|
371
|
-
["tagId", raw.tagId ?? ""],
|
|
372
|
-
["year", raw.year ?? 0],
|
|
373
|
-
["month", raw.month ?? 0],
|
|
374
|
-
["countryCode", raw.countryCode ?? ""],
|
|
375
|
-
["regionCode", raw.regionCode ?? ""],
|
|
376
|
-
["citySlug", raw.citySlug ?? ""],
|
|
377
|
-
["categorySlug", raw.categorySlug ?? ""],
|
|
378
365
|
["customTitle", raw.customTitle ?? ""],
|
|
379
366
|
["breadcrumbTitle", raw.breadcrumbTitle ?? ""],
|
|
380
367
|
["customDescription", raw.customDescription ?? ""],
|
|
@@ -479,9 +466,6 @@ function canonicalSourceData(template, data, file) {
|
|
|
479
466
|
for (const key of GENERATED_TEXT_KEYS) {
|
|
480
467
|
if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
|
|
481
468
|
}
|
|
482
|
-
if (data.tagId !== undefined && data.tagId !== "") requireIdentifier(data.tagId, file, `${path}.tagId`);
|
|
483
|
-
if (data.year !== undefined) requireCount(data.year, file, `${path}.year`, GENERATED_MAXIMUM_YEAR);
|
|
484
|
-
if (data.month !== undefined) requireCount(data.month, file, `${path}.month`, GENERATED_MAXIMUM_MONTH);
|
|
485
469
|
// The API trims a description, so the length that counts is the trimmed one.
|
|
486
470
|
const customDescription = data.customDescription?.trim();
|
|
487
471
|
requireDescriptionLength(customDescription, `${file}:${path}.customDescription`);
|
|
@@ -658,8 +642,6 @@ const PAGE_PROJECTIONS = Object.freeze({
|
|
|
658
642
|
? DATA_BUILDERS[TEMPLATE_GENERATED]({
|
|
659
643
|
kind: raw.kind,
|
|
660
644
|
...Object.fromEntries(GENERATED_TEXT_KEYS.map((key) => [key, typeof raw[key] === "string" ? raw[key] : ""])),
|
|
661
|
-
year: Number.isSafeInteger(raw.year) ? raw.year : 0,
|
|
662
|
-
month: Number.isSafeInteger(raw.month) ? raw.month : 0,
|
|
663
645
|
introductionBody: presentIntroduction(raw.introductionBody),
|
|
664
646
|
})
|
|
665
647
|
: undefined,
|