@getquick/content 0.1.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 +155 -6
- 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 +20 -4
- package/dist/index.js +24 -5
- 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 +943 -58
- package/dist/{formatting-wOk2jz6J.js → site-language-cBP8SFrS.js} +302 -50
- package/dist/{version-Dn6VLZJO.js → version-D_Ao31D3.js} +1 -1
- package/dist/wordpress-D_HG2rEt.d.ts +606 -0
- package/package.json +13 -1
- package/dist/wordpress-CPL40-vq.d.ts +0 -318
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;
|
|
@@ -13,8 +13,19 @@ export interface BlockPresetStyles {
|
|
|
13
13
|
vars: Record<string, string>;
|
|
14
14
|
colorRules: string;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* WordPress preset classes need the CMS palette as well as CSS variables.
|
|
18
|
+
* `scope` is the content's container (`.prose-wp` by default; another for
|
|
19
|
+
* content a Site renders itself).
|
|
20
|
+
*/
|
|
21
|
+
export declare function blockPresetStyles(spacingSizes: SpacingPreset[], colors: ColorPreset[], scope?: string): BlockPresetStyles;
|
|
22
|
+
/**
|
|
23
|
+
* The design presets as WordPress defines them, on `:root`, for styles that
|
|
24
|
+
* read the palette and spacing from these variables outside the content's
|
|
25
|
+
* container. Presets with an invalid slug, or a value that could end the
|
|
26
|
+
* rule, are left out; "" without any.
|
|
27
|
+
*/
|
|
28
|
+
export declare function presetRootStyle(spacingSizes: SpacingPreset[], colors: ColorPreset[]): string;
|
|
18
29
|
//#endregion
|
|
19
30
|
//#region frontend/src/wp-block-renderer.d.ts
|
|
20
31
|
export interface WordPressBlock {
|
|
@@ -58,7 +69,12 @@ export declare function cmsTime(value: string | null | undefined): number | null
|
|
|
58
69
|
export declare function uriPath(uri: string): string;
|
|
59
70
|
export declare function menuItemHref(url: string, internalOrigins: string[]): string;
|
|
60
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;
|
|
61
77
|
export declare function formatDate(value: string, lang?: string): string;
|
|
62
78
|
export declare function routeOf(path: string): string;
|
|
63
79
|
//#endregion
|
|
64
|
-
export { Alternate, CmsFailure, CmsFailureReason, ContentBindings, type ContentConfig, type ContentConsentConfig, type ContentRuntime, Delivery, DeliveryFailureReason, DesignPresets, EntryContent, EntryDelivery, EntryPage, EntryPublication, 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,20 +1,39 @@
|
|
|
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
|
-
/**
|
|
5
|
-
|
|
4
|
+
/** A CSS value safe inside a stylesheet: a color or length, nothing that ends a rule. */
|
|
5
|
+
const safeValue = (value) => /^[#(),.%\w\s-]+$/.test(value);
|
|
6
|
+
/** A selector for the content's container: classes, ids, elements and descendants. */
|
|
7
|
+
const validScope = (scope) => /^[.#]?[A-Za-z][\w-]*(?:[ .#>][A-Za-z][\w-]*)*$/.test(scope);
|
|
8
|
+
/**
|
|
9
|
+
* WordPress preset classes need the CMS palette as well as CSS variables.
|
|
10
|
+
* `scope` is the content's container (`.prose-wp` by default; another for
|
|
11
|
+
* content a Site renders itself).
|
|
12
|
+
*/
|
|
13
|
+
function blockPresetStyles(spacingSizes, colors, scope = ".prose-wp") {
|
|
14
|
+
if (!validScope(scope)) throw new Error(`${JSON.stringify(scope)} isn't a container selector`);
|
|
6
15
|
const vars = {};
|
|
7
16
|
const colorRules = [];
|
|
8
17
|
for (const { slug, size } of spacingSizes) if (validSlug(slug)) vars[`--wp--preset--spacing--${slug}`] = size;
|
|
9
18
|
for (const { slug, color } of colors) {
|
|
10
19
|
if (!validSlug(slug)) continue;
|
|
11
20
|
vars[`--wp--preset--color--${slug}`] = color;
|
|
12
|
-
colorRules.push(
|
|
21
|
+
colorRules.push(`${scope} .has-${slug}-color{color:var(--wp--preset--color--${slug})}`, `${scope} .has-${slug}-background-color{background-color:var(--wp--preset--color--${slug})}`, `${scope} .has-${slug}-color.has-link-color a{color:var(--wp--preset--color--${slug})}`);
|
|
13
22
|
}
|
|
14
23
|
return {
|
|
15
24
|
vars,
|
|
16
25
|
colorRules: colorRules.join("\n")
|
|
17
26
|
};
|
|
18
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The design presets as WordPress defines them, on `:root`, for styles that
|
|
30
|
+
* read the palette and spacing from these variables outside the content's
|
|
31
|
+
* container. Presets with an invalid slug, or a value that could end the
|
|
32
|
+
* rule, are left out; "" without any.
|
|
33
|
+
*/
|
|
34
|
+
function presetRootStyle(spacingSizes, colors) {
|
|
35
|
+
const declarations = [...spacingSizes.filter(({ slug, size }) => validSlug(slug) && safeValue(size)).map(({ slug, size }) => `--wp--preset--spacing--${slug}:${size}`), ...colors.filter(({ slug, color }) => validSlug(slug) && safeValue(color)).map(({ slug, color }) => `--wp--preset--color--${slug}:${color}`)];
|
|
36
|
+
return declarations.length ? `:root{${declarations.join(";")}}` : "";
|
|
37
|
+
}
|
|
19
38
|
//#endregion
|
|
20
|
-
export { VIDEO_HERO_BLOCK, blockPresetStyles, cmsTime, containerChildLayoutHtml, containerLayoutHtml, formatDate, hasVideoHero, htmlLang, isLanguageHome, languageSubject, menuItemHref, parseWordPressBlocks, 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
|