@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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as Unavailable, B as routeLanguage, C as EntryContent, D as PublishedEntry, E as Missing, F as SiteLanguage, I as WordPressLanguages, L as htmlLang, M as WordPressMenuItem, N as WordPressPost, O as SiteChrome, P as WordPressSettings, R as isLanguageHome, S as DesignPresets, T as HomeContent, V as siteLanguages, _ as SqlDatabase, b as CmsFailureReason, c as EntryDelivery, d as HomeDelivery, f as HomePage, g as ContentBindings, h as NotServed, i as ContentRuntime, j as WordPressImage, k as Translation, l as EntryPage, m as Moved, n as ContentConfig, o as Alternate, p as HomePublication, r as ContentConsentConfig, s as DeliveryFailureReason, t as WordPressClient, u as EntryPublication, v as SqlStatement, w as Found, x as Delivery, y as CmsFailure, z as languageSubject } from "./wordpress-CPL40-vq.js";
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
- /** WordPress preset classes need the CMS palette as well as CSS variables. */
17
- export declare function blockPresetStyles(spacingSizes: SpacingPreset[], colors: ColorPreset[]): BlockPresetStyles;
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 { _ as youtubeVideoId, a as routeOf, c as htmlLang, d as routeLanguage, f as siteLanguages, g as renderWordPressBlocks, h as parseWordPressBlocks, i as responseStatus, l as isLanguageHome, m as hasVideoHero, n as formatDate, o as stripHtml, p as VIDEO_HERO_BLOCK, r as menuItemHref, s as uriPath, t as cmsTime, u as languageSubject, v as containerChildLayoutHtml, y as containerLayoutHtml } from "./formatting-wOk2jz6J.js";
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
- /** WordPress preset classes need the CMS palette as well as CSS variables. */
5
- function blockPresetStyles(spacingSizes, colors) {
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(`.prose-wp .has-${slug}-color{color:var(--wp--preset--color--${slug})}`, `.prose-wp .has-${slug}-background-color{background-color:var(--wp--preset--color--${slug})}`, `.prose-wp .has-${slug}-color.has-link-color a{color:var(--wp--preset--color--${slug})}`);
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