@getquick/content 0.2.0 → 0.3.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 +114 -2
- package/dist/consent-astro.js +1 -1
- package/dist/consent.js +1 -1
- package/dist/dist-D4-CMkrf.js +7047 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +2 -2
- package/dist/metadata-content.d.ts +36 -0
- package/dist/metadata-content.js +51 -0
- package/dist/metadata-testing.d.ts +34 -0
- package/dist/metadata-testing.js +142 -0
- package/dist/metadata.d.ts +94 -0
- package/dist/metadata.js +218 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +843 -55
- package/dist/{formatting-wOk2jz6J.js → site-language-cBP8SFrS.js} +302 -50
- package/dist/{version-DyyNhsuU.js → version-D_Ao31D3.js} +1 -1
- package/dist/{wordpress-CUFb6YZZ.d.ts → wordpress-D_HG2rEt.d.ts} +273 -51
- package/package.json +13 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { $ as Found, A as htmlLang, B as designMenuItems, C as Moved, D as SqlStatement, E as SqlDatabase, F as DesignBlock, G as CmsFailure, H as parseDesignBlocks, I as DesignBlockRenderer, J as CollectionTerm, K as CmsFailureReason, L as DesignPattern, M as languageSubject, N as routeLanguage, O as SiteLanguage, P as siteLanguages, Q as Extended, R as DesignRenderContext, S as HomePublication, T as ContentBindings, U as renderDesignPattern, V as escapeHtml, W as Archives, X as DesignPresets, Y as Delivery, Z as EntryContent, _ as EntryDelivery, a as ContentExtension, at as Unavailable, b as HomeDelivery, ct as WordPressPost, d as CollectionListing, et as HomeContent, f as CollectionPage, g as DeliveryFailureReason, h as CollectionTermListing, i as ContentConsentConfig, it as Translation, j as isLanguageHome, k as WordPressLanguages, l as Alternate, lt as WordPressSettings, m as CollectionTermCount, n as CollectionConfig, nt as PublishedEntry, o as ContentRuntime, ot as WordPressImage, p as CollectionQuery, q as CollectionMembership, r as ContentConfig, rt as SiteChrome, s as ExtensionNode, st as WordPressMenuItem, t as WordPressClient, tt as Missing, u as CollectionItem, v as EntryPage, w as NotServed, x as HomePage, y as EntryPublication, z as DesignRenderOptions } from "./wordpress-D_HG2rEt.js";
|
|
2
2
|
//#region frontend/src/wp-block-styles.d.ts
|
|
3
3
|
export interface Preset {
|
|
4
4
|
slug: string;
|
|
@@ -69,7 +69,12 @@ export declare function cmsTime(value: string | null | undefined): number | null
|
|
|
69
69
|
export declare function uriPath(uri: string): string;
|
|
70
70
|
export declare function menuItemHref(url: string, internalOrigins: string[]): string;
|
|
71
71
|
export declare function stripHtml(value: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Text with its HTML character references decoded: numeric ones, and the
|
|
74
|
+
* named ones in NAMED_ENTITIES (any other is left as written).
|
|
75
|
+
*/
|
|
76
|
+
export declare function decodeEntities(value: string): string;
|
|
72
77
|
export declare function formatDate(value: string, lang?: string): string;
|
|
73
78
|
export declare function routeOf(path: string): string;
|
|
74
79
|
//#endregion
|
|
75
|
-
export { Alternate, CmsFailure, CmsFailureReason, ContentBindings, type ContentConfig, type ContentConsentConfig, type ContentExtension, type ContentRuntime, Delivery, DeliveryFailureReason, DesignPresets, EntryContent, EntryDelivery, EntryPage, EntryPublication, Extended, type ExtensionNode, Found, HomeContent, HomeDelivery, HomePage, HomePublication, Missing, Moved, NotServed, PublishedEntry, SiteChrome, SiteLanguage, SqlDatabase, SqlStatement, Translation, Unavailable, type WordPressClient, WordPressImage, WordPressLanguages, WordPressMenuItem, WordPressPost, WordPressSettings, htmlLang, isLanguageHome, languageSubject, routeLanguage, siteLanguages };
|
|
80
|
+
export { Alternate, Archives, CmsFailure, CmsFailureReason, type CollectionConfig, CollectionItem, CollectionListing, CollectionMembership, CollectionPage, CollectionQuery, CollectionTerm, CollectionTermCount, CollectionTermListing, ContentBindings, type ContentConfig, type ContentConsentConfig, type ContentExtension, type ContentRuntime, Delivery, DeliveryFailureReason, DesignBlock, DesignBlockRenderer, DesignPattern, DesignPresets, DesignRenderContext, DesignRenderOptions, EntryContent, EntryDelivery, EntryPage, EntryPublication, Extended, type ExtensionNode, Found, HomeContent, HomeDelivery, HomePage, HomePublication, Missing, Moved, NotServed, PublishedEntry, SiteChrome, SiteLanguage, SqlDatabase, SqlStatement, Translation, Unavailable, type WordPressClient, WordPressImage, WordPressLanguages, WordPressMenuItem, WordPressPost, WordPressSettings, designMenuItems, escapeHtml, htmlLang, isLanguageHome, languageSubject, parseDesignBlocks, renderDesignPattern, routeLanguage, siteLanguages };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as containerChildLayoutHtml, S as youtubeVideoId, _ as uriPath, a as siteLanguages, b as parseWordPressBlocks, c as parseDesignBlocks, d as decodeEntities, f as formatDate, g as stripHtml, h as routeOf, i as routeLanguage, l as renderDesignPattern, m as responseStatus, n as isLanguageHome, o as designMenuItems, p as menuItemHref, r as languageSubject, s as escapeHtml, t as htmlLang, u as cmsTime, v as VIDEO_HERO_BLOCK, w as containerLayoutHtml, x as renderWordPressBlocks, y as hasVideoHero } from "./site-language-cBP8SFrS.js";
|
|
2
2
|
//#region frontend/src/wp-block-styles.ts
|
|
3
3
|
const validSlug = (slug) => /^[a-z0-9][a-z0-9-]*$/.test(slug);
|
|
4
4
|
/** A CSS value safe inside a stylesheet: a color or length, nothing that ends a rule. */
|
|
@@ -36,4 +36,4 @@ function presetRootStyle(spacingSizes, colors) {
|
|
|
36
36
|
return declarations.length ? `:root{${declarations.join(";")}}` : "";
|
|
37
37
|
}
|
|
38
38
|
//#endregion
|
|
39
|
-
export { VIDEO_HERO_BLOCK, blockPresetStyles, cmsTime, containerChildLayoutHtml, containerLayoutHtml, formatDate, hasVideoHero, htmlLang, isLanguageHome, languageSubject, menuItemHref, parseWordPressBlocks, presetRootStyle, renderWordPressBlocks, responseStatus, routeLanguage, routeOf, siteLanguages, stripHtml, uriPath, youtubeVideoId };
|
|
39
|
+
export { VIDEO_HERO_BLOCK, blockPresetStyles, cmsTime, containerChildLayoutHtml, containerLayoutHtml, decodeEntities, designMenuItems, escapeHtml, formatDate, hasVideoHero, htmlLang, isLanguageHome, languageSubject, menuItemHref, parseDesignBlocks, parseWordPressBlocks, presetRootStyle, renderDesignPattern, renderWordPressBlocks, responseStatus, routeLanguage, routeOf, siteLanguages, stripHtml, uriPath, youtubeVideoId };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MetadataImage, MetadataInput, MetadataLocale, MetadataSite } from "./metadata.js";
|
|
2
|
+
//#region frontend/src/metadata-content.d.ts
|
|
3
|
+
/** Minimal structural slice of existing HomePublication/EntryPublication; no CMS reads. */
|
|
4
|
+
export interface PublishedContentMetadata {
|
|
5
|
+
/** WordPress title/excerpt are authored HTML; featuredImage.altText is already plain text. */
|
|
6
|
+
title: string;
|
|
7
|
+
excerpt?: string;
|
|
8
|
+
date?: string;
|
|
9
|
+
featuredImage?: {
|
|
10
|
+
sourceUrl: string;
|
|
11
|
+
altText: string;
|
|
12
|
+
} | null;
|
|
13
|
+
}
|
|
14
|
+
export interface ContentMetadataOptions {
|
|
15
|
+
site: MetadataSite;
|
|
16
|
+
route: string;
|
|
17
|
+
language?: MetadataLocale;
|
|
18
|
+
kind?: "website" | "article";
|
|
19
|
+
/** Existing Alternate records: language-home fallbacks are deliberately excluded. */
|
|
20
|
+
alternates?: readonly {
|
|
21
|
+
language: MetadataLocale;
|
|
22
|
+
route: string;
|
|
23
|
+
translation: boolean;
|
|
24
|
+
}[];
|
|
25
|
+
/** Verified dimensions/MIME unavailable in the existing CMS image contract; supplied by asset evidence. */
|
|
26
|
+
featuredImageFacts?: {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
type?: MetadataImage["type"];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Decodes entities and extracts authored text without executing HTML or reading body markup. */
|
|
33
|
+
export declare function contentMetadataText(html: string): string;
|
|
34
|
+
/** Call only for the same published snapshot being served, including a retained snapshot. */
|
|
35
|
+
export declare function mapPublishedContentMetadata(content: PublishedContentMetadata, options: ContentMetadataOptions): MetadataInput;
|
|
36
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { normalizeMetadataText } from "./metadata.js";
|
|
2
|
+
import { n as parseFragment } from "./dist-D4-CMkrf.js";
|
|
3
|
+
//#region frontend/src/metadata-content.ts
|
|
4
|
+
/** Decodes entities and extracts authored text without executing HTML or reading body markup. */
|
|
5
|
+
function contentMetadataText(html) {
|
|
6
|
+
const walk = (node) => {
|
|
7
|
+
if ("tagName" in node && [
|
|
8
|
+
"script",
|
|
9
|
+
"style",
|
|
10
|
+
"template"
|
|
11
|
+
].includes(node.tagName)) return "";
|
|
12
|
+
if ("value" in node) return node.value;
|
|
13
|
+
if (!("childNodes" in node)) return "";
|
|
14
|
+
const text = node.childNodes.map(walk).join("");
|
|
15
|
+
return "tagName" in node && /^(?:p|div|br|li|h[1-6]|blockquote|section|article)$/u.test(node.tagName) ? ` ${text} ` : text;
|
|
16
|
+
};
|
|
17
|
+
return normalizeMetadataText(walk(parseFragment(html)));
|
|
18
|
+
}
|
|
19
|
+
/** Call only for the same published snapshot being served, including a retained snapshot. */
|
|
20
|
+
function mapPublishedContentMetadata(content, options) {
|
|
21
|
+
const absolute = (route) => new URL(route, options.site.origin).href;
|
|
22
|
+
const title = contentMetadataText(content.title);
|
|
23
|
+
const description = content.excerpt === void 0 ? void 0 : contentMetadataText(content.excerpt);
|
|
24
|
+
const image = content.featuredImage;
|
|
25
|
+
const facts = options.featuredImageFacts;
|
|
26
|
+
return {
|
|
27
|
+
site: options.site,
|
|
28
|
+
state: "public",
|
|
29
|
+
status: 200,
|
|
30
|
+
canonical: absolute(options.route),
|
|
31
|
+
language: options.language,
|
|
32
|
+
kind: options.kind,
|
|
33
|
+
page: {
|
|
34
|
+
...title ? { title } : {},
|
|
35
|
+
...description ? { description } : {},
|
|
36
|
+
...image && facts ? { images: [{
|
|
37
|
+
url: image.sourceUrl,
|
|
38
|
+
alt: normalizeMetadataText(image.altText),
|
|
39
|
+
...facts
|
|
40
|
+
}] } : {}
|
|
41
|
+
},
|
|
42
|
+
translations: options.alternates?.filter((a) => a.translation).map((a) => ({
|
|
43
|
+
...a.language,
|
|
44
|
+
canonical: absolute(a.route),
|
|
45
|
+
published: true
|
|
46
|
+
})),
|
|
47
|
+
...options.kind === "article" && content.date && /(?:Z|[+-]\d\d:\d\d)$/u.test(content.date) ? { article: { publishedTime: content.date } } : {}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { contentMetadataText, mapPublishedContentMetadata };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MetadataImage } from "./metadata.js";
|
|
2
|
+
//#region frontend/src/metadata-testing.d.ts
|
|
3
|
+
export interface MetadataHtmlExpectation {
|
|
4
|
+
baseline: "OG-1";
|
|
5
|
+
applicable: boolean;
|
|
6
|
+
/** false checks preserved document basics while explicitly asserting OG is pending. */
|
|
7
|
+
graph?: boolean;
|
|
8
|
+
lang: string;
|
|
9
|
+
dir: "ltr" | "rtl";
|
|
10
|
+
robots: string;
|
|
11
|
+
/** Required for applicable pages; supplied independently of resolver output. */
|
|
12
|
+
title?: string;
|
|
13
|
+
shareTitle?: string;
|
|
14
|
+
canonical?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
siteName?: string;
|
|
17
|
+
locale?: string;
|
|
18
|
+
type?: "website" | "article";
|
|
19
|
+
images?: readonly MetadataImage[];
|
|
20
|
+
alternates?: readonly {
|
|
21
|
+
lang: string;
|
|
22
|
+
url: string;
|
|
23
|
+
}[];
|
|
24
|
+
localeAlternates?: readonly string[];
|
|
25
|
+
article?: {
|
|
26
|
+
publishedTime?: string;
|
|
27
|
+
modifiedTime?: string;
|
|
28
|
+
authors?: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Parses delivered bytes with HTML-standard tree construction, never resolver descriptors. */
|
|
32
|
+
export declare function inspectMetadataHtml(html: string, expected: MetadataHtmlExpectation): string[];
|
|
33
|
+
export declare function assertMetadataHtml(html: string, expected: MetadataHtmlExpectation): void;
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { t as parse } from "./dist-D4-CMkrf.js";
|
|
2
|
+
//#region frontend/src/metadata-testing.ts
|
|
3
|
+
const attr = (node, name) => node.attrs.find((a) => a.name === name)?.value;
|
|
4
|
+
const hasRel = (node, rel) => node.tagName === "link" && (attr(node, "rel") ?? "").toLowerCase().split(/\s+/u).includes(rel);
|
|
5
|
+
const isElement = (node) => "tagName" in node;
|
|
6
|
+
const content = (node) => "value" in node ? node.value : "childNodes" in node ? node.childNodes.map(content).join("") : "";
|
|
7
|
+
/** Parses delivered bytes with HTML-standard tree construction, never resolver descriptors. */
|
|
8
|
+
function inspectMetadataHtml(html, expected) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
const document = parse(html, {
|
|
11
|
+
sourceCodeLocationInfo: true,
|
|
12
|
+
onParseError: (error) => {
|
|
13
|
+
if (error.code !== "missing-doctype") issues.push(`HTML parse error: ${error.code}`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
const all = [];
|
|
17
|
+
const walk = (node) => {
|
|
18
|
+
if (isElement(node)) all.push(node);
|
|
19
|
+
if ("childNodes" in node) node.childNodes.forEach(walk);
|
|
20
|
+
};
|
|
21
|
+
walk(document);
|
|
22
|
+
const root = all.find((node) => node.tagName === "html");
|
|
23
|
+
const head = all.find((node) => node.tagName === "head");
|
|
24
|
+
const equal = (label, actual, wanted) => {
|
|
25
|
+
if (JSON.stringify(actual) !== JSON.stringify(wanted)) issues.push(`${label}: expected ${JSON.stringify(wanted)}, received ${JSON.stringify(actual)}`);
|
|
26
|
+
};
|
|
27
|
+
equal("baseline", expected.baseline, "OG-1");
|
|
28
|
+
equal("html.lang", root && attr(root, "lang"), expected.lang);
|
|
29
|
+
equal("html.dir", root && attr(root, "dir"), expected.dir);
|
|
30
|
+
if (!head?.sourceCodeLocation?.startTag || !head.sourceCodeLocation.endTag) issues.push("Explicit complete head required in delivered HTML");
|
|
31
|
+
const tags = all.filter((node) => [
|
|
32
|
+
"title",
|
|
33
|
+
"meta",
|
|
34
|
+
"link"
|
|
35
|
+
].includes(node.tagName));
|
|
36
|
+
const relevant = tags.filter((node) => node.tagName === "title" || /^(og:|article:)/u.test(attr(node, "property") ?? attr(node, "name") ?? "") || ["description", "robots"].includes(attr(node, "name") ?? "") || hasRel(node, "canonical") || hasRel(node, "alternate"));
|
|
37
|
+
for (const node of relevant) {
|
|
38
|
+
const location = node.sourceCodeLocation;
|
|
39
|
+
if (node.parentNode !== head || !location || !head?.sourceCodeLocation?.endTag || location.startOffset < head.sourceCodeLocation.startOffset || location.endOffset > head.sourceCodeLocation.endTag.startOffset) issues.push(`Metadata outside actual head: ${node.tagName}`);
|
|
40
|
+
}
|
|
41
|
+
const metas = tags.filter((node) => node.tagName === "meta");
|
|
42
|
+
const values = (key, attribute = "property") => metas.filter((node) => attr(node, attribute) === key).map((node) => attr(node, "content"));
|
|
43
|
+
const one = (key, wanted, attribute = "property") => equal(key, values(key, attribute), wanted === void 0 ? [] : [wanted]);
|
|
44
|
+
one("robots", expected.robots, "name");
|
|
45
|
+
const graph = metas.filter((node) => /^(og:|article:)/u.test(attr(node, "property") ?? attr(node, "name") ?? ""));
|
|
46
|
+
const canonicals = tags.filter((node) => hasRel(node, "canonical"));
|
|
47
|
+
for (const node of relevant) {
|
|
48
|
+
const allowedAttrs = node.tagName === "meta" ? [
|
|
49
|
+
"name",
|
|
50
|
+
"property",
|
|
51
|
+
"content"
|
|
52
|
+
] : node.tagName === "link" ? [
|
|
53
|
+
"rel",
|
|
54
|
+
"href",
|
|
55
|
+
"hreflang"
|
|
56
|
+
] : [];
|
|
57
|
+
if (node.attrs.some((a) => !allowedAttrs.includes(a.name))) issues.push(`Unexpected attribute on ${node.tagName}`);
|
|
58
|
+
if (node.tagName === "title" && node.childNodes.some(isElement)) issues.push("Markup inside title");
|
|
59
|
+
}
|
|
60
|
+
if (!expected.applicable) {
|
|
61
|
+
equal("excluded graph", graph.length, 0);
|
|
62
|
+
equal("excluded canonical", canonicals.length, 0);
|
|
63
|
+
equal("excluded title", tags.filter((node) => node.tagName === "title").map(content), expected.title === void 0 ? [] : [expected.title]);
|
|
64
|
+
one("description", void 0, "name");
|
|
65
|
+
equal("excluded alternates", tags.filter((node) => hasRel(node, "alternate")).length, 0);
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
if (expected.graph === false) {
|
|
69
|
+
equal("pending graph", graph.length, 0);
|
|
70
|
+
equal("document title", tags.filter((node) => node.tagName === "title").map(content), expected.title === void 0 ? [] : [expected.title]);
|
|
71
|
+
equal("canonical", canonicals.map((node) => attr(node, "href")), expected.canonical === void 0 ? [] : [expected.canonical]);
|
|
72
|
+
one("description", expected.description, "name");
|
|
73
|
+
equal("hreflang links", tags.filter((node) => hasRel(node, "alternate")).map((node) => ({
|
|
74
|
+
lang: attr(node, "hreflang"),
|
|
75
|
+
url: attr(node, "href")
|
|
76
|
+
})), expected.alternates ?? []);
|
|
77
|
+
return issues;
|
|
78
|
+
}
|
|
79
|
+
for (const key of [
|
|
80
|
+
"title",
|
|
81
|
+
"canonical",
|
|
82
|
+
"description",
|
|
83
|
+
"siteName",
|
|
84
|
+
"locale",
|
|
85
|
+
"type"
|
|
86
|
+
]) if (!expected[key]) issues.push(`Missing independent expectation: ${key}`);
|
|
87
|
+
if (!expected.images?.length) issues.push("Missing independent image expectations");
|
|
88
|
+
if (!/(?:^|\s)og:\s+https:\/\/ogp\.me\/ns#(?:\s|$)/u.test(root && attr(root, "prefix") || "")) issues.push("Missing OG namespace");
|
|
89
|
+
equal("document title", tags.filter((node) => node.tagName === "title").map(content), [expected.title]);
|
|
90
|
+
equal("canonical", canonicals.map((node) => attr(node, "href")), [expected.canonical]);
|
|
91
|
+
one("description", expected.description, "name");
|
|
92
|
+
for (const [key, value] of Object.entries({
|
|
93
|
+
title: expected.shareTitle ?? expected.title,
|
|
94
|
+
type: expected.type,
|
|
95
|
+
url: expected.canonical,
|
|
96
|
+
description: expected.description,
|
|
97
|
+
site_name: expected.siteName,
|
|
98
|
+
locale: expected.locale
|
|
99
|
+
})) one(`og:${key}`, value);
|
|
100
|
+
const imageTags = graph.filter((node) => /^og:image(?::|$)/u.test(attr(node, "property") ?? ""));
|
|
101
|
+
const wantedImages = expected.images?.flatMap((image) => [
|
|
102
|
+
["og:image", image.url],
|
|
103
|
+
["og:image:alt", image.alt],
|
|
104
|
+
["og:image:width", String(image.width)],
|
|
105
|
+
["og:image:height", String(image.height)],
|
|
106
|
+
...image.type ? [["og:image:type", image.type]] : []
|
|
107
|
+
]) ?? [];
|
|
108
|
+
equal("ordered image groups", imageTags.map((node) => [attr(node, "property"), attr(node, "content")]), wantedImages);
|
|
109
|
+
equal("locale alternates", values("og:locale:alternate"), expected.localeAlternates ?? []);
|
|
110
|
+
equal("hreflang links", tags.filter((node) => hasRel(node, "alternate")).map((node) => ({
|
|
111
|
+
lang: attr(node, "hreflang"),
|
|
112
|
+
url: attr(node, "href")
|
|
113
|
+
})), expected.alternates ?? []);
|
|
114
|
+
one("article:published_time", expected.article?.publishedTime);
|
|
115
|
+
one("article:modified_time", expected.article?.modifiedTime);
|
|
116
|
+
equal("article authors", values("article:author"), expected.article?.authors ?? []);
|
|
117
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
118
|
+
"og:title",
|
|
119
|
+
"og:type",
|
|
120
|
+
"og:url",
|
|
121
|
+
"og:description",
|
|
122
|
+
"og:site_name",
|
|
123
|
+
"og:locale",
|
|
124
|
+
"og:locale:alternate",
|
|
125
|
+
"og:image",
|
|
126
|
+
"og:image:alt",
|
|
127
|
+
"og:image:width",
|
|
128
|
+
"og:image:height",
|
|
129
|
+
"og:image:type",
|
|
130
|
+
"article:published_time",
|
|
131
|
+
"article:modified_time",
|
|
132
|
+
"article:author"
|
|
133
|
+
]);
|
|
134
|
+
for (const node of graph) if (!allowed.has(attr(node, "property") ?? "")) issues.push("Unexpected graph property or name-only OG tag");
|
|
135
|
+
return issues;
|
|
136
|
+
}
|
|
137
|
+
function assertMetadataHtml(html, expected) {
|
|
138
|
+
const issues = inspectMetadataHtml(html, expected);
|
|
139
|
+
if (issues.length) throw new Error(`Metadata HTML acceptance failed:\n${issues.join("\n")}`);
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
export { assertMetadataHtml, inspectMetadataHtml };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//#region frontend/src/metadata.d.ts
|
|
2
|
+
/** Scoped OG-1 candidate contract; adoption and public asset verification are separate gates. */
|
|
3
|
+
export declare const metadataBaseline: "OG-1";
|
|
4
|
+
export interface MetadataImage {
|
|
5
|
+
url: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
/** Verified asset MIME, never inferred from the URL extension. */
|
|
10
|
+
type?: "image/jpeg" | "image/png";
|
|
11
|
+
}
|
|
12
|
+
export interface MetadataLocale {
|
|
13
|
+
/** BCP 47 HTML language. */
|
|
14
|
+
lang: string;
|
|
15
|
+
/** WordPress regional locale or an explicitly configured OG regional locale. */
|
|
16
|
+
locale: string;
|
|
17
|
+
dir: "ltr" | "rtl";
|
|
18
|
+
}
|
|
19
|
+
export interface MetadataFacts {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
images?: readonly MetadataImage[];
|
|
23
|
+
}
|
|
24
|
+
export interface MetadataSite extends MetadataFacts {
|
|
25
|
+
/** Reviewed configuration, never an incoming Host header. One HTTPS origin, no path. */
|
|
26
|
+
origin: string;
|
|
27
|
+
name: string;
|
|
28
|
+
/** Configured Site default; input.language identifies the actual page language. */
|
|
29
|
+
language: MetadataLocale;
|
|
30
|
+
/** Only these query keys may participate in canonical identity. */
|
|
31
|
+
identityQueryKeys?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export interface MetadataTranslation extends MetadataLocale {
|
|
34
|
+
canonical: string;
|
|
35
|
+
published: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface MetadataArticle {
|
|
38
|
+
publishedTime?: string;
|
|
39
|
+
modifiedTime?: string;
|
|
40
|
+
/** Verified public author profile URLs, not names or private account URLs. */
|
|
41
|
+
authors?: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
export interface MetadataInput {
|
|
44
|
+
site: MetadataSite;
|
|
45
|
+
state: "public" | "private" | "preview" | "withdrawn" | "error" | "redirect";
|
|
46
|
+
/** A successful graph applies only to status 200. */
|
|
47
|
+
status: number;
|
|
48
|
+
/** Explicit safe public status copy for excluded routes; never selected from page/site facts. */
|
|
49
|
+
statusTitle?: string;
|
|
50
|
+
canonical?: string;
|
|
51
|
+
kind?: "website" | "article" | "product" | "listing";
|
|
52
|
+
language?: MetadataLocale;
|
|
53
|
+
page?: MetadataFacts;
|
|
54
|
+
overrides?: MetadataFacts & {
|
|
55
|
+
shareTitle?: string;
|
|
56
|
+
};
|
|
57
|
+
translations?: readonly MetadataTranslation[];
|
|
58
|
+
article?: MetadataArticle;
|
|
59
|
+
/** A public page may intentionally opt out of indexing. This is not access control. */
|
|
60
|
+
noindex?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export type HeadDescriptor = {
|
|
63
|
+
tag: "title";
|
|
64
|
+
text: string;
|
|
65
|
+
} | {
|
|
66
|
+
tag: "meta" | "link";
|
|
67
|
+
attrs: Record<string, string>;
|
|
68
|
+
};
|
|
69
|
+
export interface MetadataDiagnostic {
|
|
70
|
+
code: string;
|
|
71
|
+
field: string;
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
|
74
|
+
export interface ResolvedMetadata {
|
|
75
|
+
baseline: typeof metadataBaseline;
|
|
76
|
+
/** Overall applicable metadata readiness; an OG gap does not remove valid document basics. */
|
|
77
|
+
ready: boolean;
|
|
78
|
+
documentReady: boolean;
|
|
79
|
+
graphReady: boolean;
|
|
80
|
+
applicable: boolean;
|
|
81
|
+
htmlAttributes: {
|
|
82
|
+
lang: string;
|
|
83
|
+
dir: "ltr" | "rtl";
|
|
84
|
+
prefix?: string;
|
|
85
|
+
};
|
|
86
|
+
head: HeadDescriptor[];
|
|
87
|
+
diagnostics: MetadataDiagnostic[];
|
|
88
|
+
}
|
|
89
|
+
/** Inputs are plain text. Source-specific HTML/entity decoding belongs in the mapper. */
|
|
90
|
+
export declare function normalizeMetadataText(value: string): string;
|
|
91
|
+
export declare function resolveMetadata(input: MetadataInput): ResolvedMetadata;
|
|
92
|
+
/** Safe HTML fragment for Astro set:html or other server serialization; preserves repeated image groups. */
|
|
93
|
+
export declare function renderMetadataHead(metadata: ResolvedMetadata): string;
|
|
94
|
+
//#endregion
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
//#region frontend/src/metadata.ts
|
|
2
|
+
/** Scoped OG-1 candidate contract; adoption and public asset verification are separate gates. */
|
|
3
|
+
const metadataBaseline = "OG-1";
|
|
4
|
+
/** Inputs are plain text. Source-specific HTML/entity decoding belongs in the mapper. */
|
|
5
|
+
function normalizeMetadataText(value) {
|
|
6
|
+
return value.replace(/\s+/gu, " ").trim();
|
|
7
|
+
}
|
|
8
|
+
function resolveMetadata(input) {
|
|
9
|
+
const diagnostics = [];
|
|
10
|
+
const fail = (field, message) => diagnostics.push({
|
|
11
|
+
code: "invalid-metadata",
|
|
12
|
+
field,
|
|
13
|
+
message
|
|
14
|
+
});
|
|
15
|
+
const language = input.language ?? input.site.language;
|
|
16
|
+
const text = (value, field) => {
|
|
17
|
+
const result = normalizeMetadataText(value ?? "");
|
|
18
|
+
if (!result) fail(field, "Expected nonempty decoded plain text");
|
|
19
|
+
if (result.includes("\0") || /[\uD800-\uDFFF]/u.test(result)) fail(field, "Text contains NUL or unpaired UTF-16 surrogate and cannot survive HTML/UTF-8 serialization");
|
|
20
|
+
return result;
|
|
21
|
+
};
|
|
22
|
+
const locale = (value, field) => {
|
|
23
|
+
let htmlLocale;
|
|
24
|
+
try {
|
|
25
|
+
htmlLocale = new Intl.Locale(value.lang);
|
|
26
|
+
} catch {
|
|
27
|
+
fail(field, "Invalid BCP 47 HTML language");
|
|
28
|
+
}
|
|
29
|
+
if (!["ltr", "rtl"].includes(value.dir)) fail(field, "Invalid HTML direction");
|
|
30
|
+
const match = /^([a-z]{2,3})[_-]([a-z]{2})(?:_[a-z0-9]+)*$/iu.exec(value.locale);
|
|
31
|
+
if (!match) {
|
|
32
|
+
fail(field, "Explicit regional locale required; no territory is inferred");
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
if (htmlLocale && (htmlLocale.language.toLowerCase() !== match[1].toLowerCase() || htmlLocale.region && htmlLocale.region.toUpperCase() !== match[2].toUpperCase())) fail(field, "HTML and OG locales disagree");
|
|
36
|
+
return `${match[1].toLowerCase()}_${match[2].toUpperCase()}`;
|
|
37
|
+
};
|
|
38
|
+
const ogLocale = locale(language, "language");
|
|
39
|
+
const head = [];
|
|
40
|
+
const meta = (name, content, property = true) => head.push({
|
|
41
|
+
tag: "meta",
|
|
42
|
+
attrs: {
|
|
43
|
+
[property ? "property" : "name"]: name,
|
|
44
|
+
content
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const applicable = input.state === "public" && input.status === 200;
|
|
48
|
+
meta("robots", applicable && !input.noindex ? "index, follow" : "noindex, nofollow", false);
|
|
49
|
+
const result = {
|
|
50
|
+
baseline: metadataBaseline,
|
|
51
|
+
ready: false,
|
|
52
|
+
documentReady: false,
|
|
53
|
+
graphReady: false,
|
|
54
|
+
applicable,
|
|
55
|
+
htmlAttributes: {
|
|
56
|
+
lang: language.lang,
|
|
57
|
+
dir: language.dir
|
|
58
|
+
},
|
|
59
|
+
head,
|
|
60
|
+
diagnostics
|
|
61
|
+
};
|
|
62
|
+
if (!applicable) {
|
|
63
|
+
if (input.statusTitle !== void 0) {
|
|
64
|
+
const title = text(input.statusTitle, "statusTitle");
|
|
65
|
+
if (!diagnostics.some((d) => d.field === "statusTitle")) head.push({
|
|
66
|
+
tag: "title",
|
|
67
|
+
text: title
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
result.ready = diagnostics.length === 0;
|
|
71
|
+
result.documentReady = result.ready && head.some((node) => node.tag === "title");
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
let origin = "";
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(input.site.origin);
|
|
77
|
+
if (parsed.href !== `${parsed.origin}/` || parsed.protocol !== "https:" || parsed.username || parsed.password || /^(localhost|.*\.localhost|.*\.local|.*\.invalid|127\..*|0\.0\.0\.0|\[::1\])$/iu.test(parsed.hostname)) throw new Error();
|
|
78
|
+
origin = parsed.origin;
|
|
79
|
+
} catch {
|
|
80
|
+
fail("site.origin", "Expected reviewed public HTTPS origin");
|
|
81
|
+
}
|
|
82
|
+
const url = (value, field, identity) => {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = new URL(value ?? "");
|
|
85
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || /^(localhost|.*\.localhost|.*\.local|.*\.invalid|127\..*|0\.0\.0\.0|\[::1\])$/iu.test(parsed.hostname)) throw new Error();
|
|
86
|
+
if (identity && parsed.origin !== origin) throw new Error();
|
|
87
|
+
for (const key of parsed.searchParams.keys()) if (/^(utm_|fbclid$|gclid$|token$|sig$|signature$|expires$|x-amz-|x-goog-|awsaccesskeyid$|preview)/iu.test(key) || identity && !input.site.identityQueryKeys?.includes(key)) throw new Error();
|
|
88
|
+
return parsed.href;
|
|
89
|
+
} catch {
|
|
90
|
+
fail(field, "Invalid public HTTPS URL or unreviewed canonical identity/query");
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const selected = (key) => input.overrides?.[key] ?? input.page?.[key] ?? input.site[key];
|
|
95
|
+
const title = text(selected("title"), "title");
|
|
96
|
+
const shareTitle = text(input.overrides?.shareTitle ?? title, "shareTitle");
|
|
97
|
+
const description = text(selected("description"), "description");
|
|
98
|
+
const name = text(input.site.name, "site.name");
|
|
99
|
+
const canonical = url(input.canonical, "canonical", true);
|
|
100
|
+
head.push({
|
|
101
|
+
tag: "title",
|
|
102
|
+
text: title
|
|
103
|
+
}, {
|
|
104
|
+
tag: "link",
|
|
105
|
+
attrs: {
|
|
106
|
+
rel: "canonical",
|
|
107
|
+
href: canonical
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
meta("description", description, false);
|
|
111
|
+
for (const [key, value] of Object.entries({
|
|
112
|
+
title: shareTitle,
|
|
113
|
+
type: input.kind === "article" ? "article" : "website",
|
|
114
|
+
url: canonical,
|
|
115
|
+
description,
|
|
116
|
+
site_name: name,
|
|
117
|
+
locale: ogLocale
|
|
118
|
+
})) meta(`og:${key}`, value);
|
|
119
|
+
const images = selected("images");
|
|
120
|
+
if (!images?.length) fail("images", "At least one reviewed share image required");
|
|
121
|
+
images?.forEach((image, index) => {
|
|
122
|
+
const field = `images.${index}`;
|
|
123
|
+
const src = url(image.url, `${field}.url`, false);
|
|
124
|
+
const alt = text(image.alt, `${field}.alt`);
|
|
125
|
+
if (![image.width, image.height].every((n) => Number.isSafeInteger(n) && n > 0)) fail(field, "Known positive integer dimensions required");
|
|
126
|
+
if (image.type && !["image/jpeg", "image/png"].includes(image.type)) fail(field, "OG-1 supports verified JPEG/PNG MIME only");
|
|
127
|
+
meta("og:image", src);
|
|
128
|
+
meta("og:image:alt", alt);
|
|
129
|
+
meta("og:image:width", String(image.width));
|
|
130
|
+
meta("og:image:height", String(image.height));
|
|
131
|
+
if (image.type) meta("og:image:type", image.type);
|
|
132
|
+
});
|
|
133
|
+
const hasPublishedTranslations = input.translations?.some((t) => t.published) ?? false;
|
|
134
|
+
const languageKey = (value) => {
|
|
135
|
+
try {
|
|
136
|
+
return new Intl.Locale(value.lang).baseName;
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (hasPublishedTranslations && input.language) locale(input.site.language, "site.language");
|
|
142
|
+
const defaultLanguage = languageKey(input.site.language);
|
|
143
|
+
let defaultCanonical = defaultLanguage && languageKey(language) === defaultLanguage ? canonical : void 0;
|
|
144
|
+
const locales = /* @__PURE__ */ new Set([ogLocale]);
|
|
145
|
+
const langs = /* @__PURE__ */ new Set([language.lang.toLowerCase()]);
|
|
146
|
+
if (hasPublishedTranslations) head.push({
|
|
147
|
+
tag: "link",
|
|
148
|
+
attrs: {
|
|
149
|
+
rel: "alternate",
|
|
150
|
+
hreflang: language.lang,
|
|
151
|
+
href: canonical
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
input.translations?.filter((t) => t.published).forEach((translation, index) => {
|
|
155
|
+
const field = `translations.${index}`;
|
|
156
|
+
const translatedLocale = locale(translation, field);
|
|
157
|
+
const href = url(translation.canonical, field, true);
|
|
158
|
+
if (langs.has(translation.lang.toLowerCase())) {
|
|
159
|
+
fail(field, "Duplicate/current translation language");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
langs.add(translation.lang.toLowerCase());
|
|
163
|
+
if (defaultLanguage && languageKey(translation) === defaultLanguage) defaultCanonical = href;
|
|
164
|
+
head.push({
|
|
165
|
+
tag: "link",
|
|
166
|
+
attrs: {
|
|
167
|
+
rel: "alternate",
|
|
168
|
+
hreflang: translation.lang,
|
|
169
|
+
href
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (!locales.has(translatedLocale)) {
|
|
173
|
+
meta("og:locale:alternate", translatedLocale);
|
|
174
|
+
locales.add(translatedLocale);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
if (hasPublishedTranslations && defaultCanonical) head.push({
|
|
178
|
+
tag: "link",
|
|
179
|
+
attrs: {
|
|
180
|
+
rel: "alternate",
|
|
181
|
+
hreflang: "x-default",
|
|
182
|
+
href: defaultCanonical
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (input.kind === "article" && input.article) {
|
|
186
|
+
for (const [key, value] of [["published_time", input.article.publishedTime], ["modified_time", input.article.modifiedTime]]) {
|
|
187
|
+
if (value === void 0) continue;
|
|
188
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?(?:Z|[+-]\d\d:\d\d)$/u.test(value) || !Number.isFinite(Date.parse(value)) || Number(value.slice(8, 10)) > new Date(Date.UTC(Number(value.slice(0, 4)), Number(value.slice(5, 7)), 0)).getUTCDate()) fail(`article.${key}`, "Timestamp must include a timezone");
|
|
189
|
+
else meta(`article:${key}`, value);
|
|
190
|
+
}
|
|
191
|
+
input.article.authors?.forEach((author) => meta("article:author", url(author, "article.authors", false)));
|
|
192
|
+
}
|
|
193
|
+
result.ready = diagnostics.length === 0;
|
|
194
|
+
result.graphReady = result.ready;
|
|
195
|
+
result.documentReady = !diagnostics.some((d) => [
|
|
196
|
+
"title",
|
|
197
|
+
"description",
|
|
198
|
+
"canonical",
|
|
199
|
+
"site.origin"
|
|
200
|
+
].includes(d.field) || d.field === "language" && /HTML|BCP/u.test(d.message));
|
|
201
|
+
if (!result.ready) {
|
|
202
|
+
const invalid = (field) => diagnostics.some((d) => d.field === field);
|
|
203
|
+
result.head = head.filter((node) => {
|
|
204
|
+
if (node.tag === "title") return !invalid("title");
|
|
205
|
+
if (node.tag === "meta") return node.attrs.name === "robots" || node.attrs.name === "description" && !invalid("description");
|
|
206
|
+
if (node.attrs.rel === "canonical") return !invalid("canonical");
|
|
207
|
+
return node.attrs.rel === "alternate" && !invalid("canonical") && !diagnostics.some((d) => d.field.startsWith("translations."));
|
|
208
|
+
});
|
|
209
|
+
} else result.htmlAttributes.prefix = "og: https://ogp.me/ns#";
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
const escape = (value) => value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
213
|
+
/** Safe HTML fragment for Astro set:html or other server serialization; preserves repeated image groups. */
|
|
214
|
+
function renderMetadataHead(metadata) {
|
|
215
|
+
return metadata.head.map((node) => node.tag === "title" ? `<title>${escape(node.text)}</title>` : `<${node.tag} ${Object.entries(node.attrs).map(([key, value]) => `${key}="${escape(value)}"`).join(" ")}>`).join("\n");
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
export { metadataBaseline, normalizeMetadataText, renderMetadataHead, resolveMetadata };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { type ContentBindings, ContentConfig, ContentConsentConfig, ContentExtension, ContentRuntime, ExtensionNode, type WordPressClient, createContentRuntime };
|
|
1
|
+
import { T as ContentBindings, a as ContentExtension, c as createContentRuntime, i as ContentConsentConfig, n as CollectionConfig, o as ContentRuntime, r as ContentConfig, s as ExtensionNode, t as WordPressClient } from "./wordpress-D_HG2rEt.js";
|
|
2
|
+
export { CollectionConfig, type ContentBindings, ContentConfig, ContentConsentConfig, ContentExtension, ContentRuntime, ExtensionNode, type WordPressClient, createContentRuntime };
|