first commit
This commit is contained in:
93
packages/core/src/page/context.ts
Normal file
93
packages/core/src/page/context.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Public page context builder
|
||||
*
|
||||
* Templates call this to describe the page being rendered.
|
||||
* The resulting context is passed to EmDashHead / EmDashBodyStart / EmDashBodyEnd.
|
||||
*/
|
||||
|
||||
import type { PublicPageContext } from "../plugins/types.js";
|
||||
|
||||
/** Fields shared by both input forms */
|
||||
interface PageContextFields {
|
||||
kind: "content" | "custom";
|
||||
pageType?: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
canonical?: string | null;
|
||||
image?: string | null;
|
||||
content?: { collection: string; id: string; slug?: string | null };
|
||||
/** SEO overrides for OG/Twitter meta generation */
|
||||
seo?: {
|
||||
ogTitle?: string | null;
|
||||
ogDescription?: string | null;
|
||||
ogImage?: string | null;
|
||||
robots?: string | null;
|
||||
};
|
||||
/** Article metadata for Open Graph article: tags */
|
||||
articleMeta?: {
|
||||
publishedTime?: string | null;
|
||||
modifiedTime?: string | null;
|
||||
author?: string | null;
|
||||
};
|
||||
/** Site name for structured data and og:site_name */
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
/** Input with Astro global -- used in .astro files */
|
||||
interface AstroInput extends PageContextFields {
|
||||
Astro: { url: URL; currentLocale?: string };
|
||||
}
|
||||
|
||||
/** Input with explicit URL -- used outside .astro files */
|
||||
interface UrlInput extends PageContextFields {
|
||||
url: URL | string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export type CreatePublicPageContextInput = AstroInput | UrlInput;
|
||||
|
||||
function isAstroInput(input: CreatePublicPageContextInput): input is AstroInput {
|
||||
return "Astro" in input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PublicPageContext from template input.
|
||||
*/
|
||||
export function createPublicPageContext(input: CreatePublicPageContextInput): PublicPageContext {
|
||||
let url: string;
|
||||
let path: string;
|
||||
let locale: string | null;
|
||||
|
||||
if (isAstroInput(input)) {
|
||||
url = input.Astro.url.href;
|
||||
path = input.Astro.url.pathname;
|
||||
locale = input.Astro.currentLocale ?? null;
|
||||
} else {
|
||||
const parsed = typeof input.url === "string" ? new URL(input.url) : input.url;
|
||||
url = parsed.href;
|
||||
path = parsed.pathname;
|
||||
locale = input.locale ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
path,
|
||||
locale,
|
||||
kind: input.kind,
|
||||
pageType: input.pageType ?? (input.kind === "content" ? "article" : "website"),
|
||||
title: input.title ?? null,
|
||||
description: input.description ?? null,
|
||||
canonical: input.canonical ?? null,
|
||||
image: input.image ?? null,
|
||||
content: input.content
|
||||
? {
|
||||
collection: input.content.collection,
|
||||
id: input.content.id,
|
||||
slug: input.content.slug ?? null,
|
||||
}
|
||||
: undefined,
|
||||
seo: input.seo,
|
||||
articleMeta: input.articleMeta,
|
||||
siteName: input.siteName,
|
||||
};
|
||||
}
|
||||
89
packages/core/src/page/fragments.ts
Normal file
89
packages/core/src/page/fragments.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Page fragment collection and rendering
|
||||
*
|
||||
* Collects raw markup / script contributions from trusted plugins via
|
||||
* the page:fragments hook. Sandboxed plugins are never invoked.
|
||||
*/
|
||||
|
||||
import type { PageFragmentContribution, PagePlacement } from "../plugins/types.js";
|
||||
import { escapeHtmlAttr } from "./metadata.js";
|
||||
|
||||
/** Escape sequences that would break out of a script tag */
|
||||
const SCRIPT_CLOSE_RE = /<\//g;
|
||||
|
||||
// ── Dedupe and filter ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter contributions to a specific placement and deduplicate.
|
||||
* - Contributions with the same `key + placement` are deduped (first wins).
|
||||
* - External scripts with the same `src + placement` are deduped.
|
||||
*/
|
||||
export function resolveFragments(
|
||||
contributions: PageFragmentContribution[],
|
||||
placement: PagePlacement,
|
||||
): PageFragmentContribution[] {
|
||||
const filtered = contributions.filter((c) => c.placement === placement);
|
||||
const seen = new Set<string>();
|
||||
const result: PageFragmentContribution[] = [];
|
||||
|
||||
for (const c of filtered) {
|
||||
// Key-based dedupe
|
||||
if (c.key) {
|
||||
const dedupeKey = `key:${c.key}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
} else if (c.kind === "external-script") {
|
||||
const dedupeKey = `src:${c.src}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
}
|
||||
|
||||
result.push(c);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── HTML rendering ──────────────────────────────────────────────
|
||||
|
||||
const EVENT_HANDLER_RE = /^on/i;
|
||||
|
||||
function renderAttributes(attrs: Record<string, string>): string {
|
||||
return Object.entries(attrs)
|
||||
.filter(([k]) => !EVENT_HANDLER_RE.test(k))
|
||||
.map(([k, v]) => ` ${escapeHtmlAttr(k)}="${escapeHtmlAttr(v)}"`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Render a single fragment contribution to HTML */
|
||||
function renderFragment(c: PageFragmentContribution): string {
|
||||
switch (c.kind) {
|
||||
case "external-script": {
|
||||
let tag = `<script src="${escapeHtmlAttr(c.src)}"`;
|
||||
if (c.async) tag += " async";
|
||||
if (c.defer) tag += " defer";
|
||||
if (c.attributes) tag += renderAttributes(c.attributes);
|
||||
tag += "></script>";
|
||||
return tag;
|
||||
}
|
||||
case "inline-script": {
|
||||
let tag = "<script";
|
||||
if (c.attributes) tag += renderAttributes(c.attributes);
|
||||
// Escape </ to <\/ to prevent breaking out of the script tag.
|
||||
// This is valid JS and protects against code built from user data.
|
||||
tag += `>${c.code.replace(SCRIPT_CLOSE_RE, "<\\/")}</script>`;
|
||||
return tag;
|
||||
}
|
||||
case "html":
|
||||
return c.html;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a list of fragment contributions to an HTML string */
|
||||
export function renderFragments(
|
||||
contributions: PageFragmentContribution[],
|
||||
placement: PagePlacement,
|
||||
): string {
|
||||
const resolved = resolveFragments(contributions, placement);
|
||||
return resolved.map(renderFragment).join("\n");
|
||||
}
|
||||
58
packages/core/src/page/index.ts
Normal file
58
packages/core/src/page/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* emdash/page — Public page contribution API
|
||||
*
|
||||
* Template integration points for plugin-driven head metadata
|
||||
* and trusted body fragments.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PublicPageContext,
|
||||
PageMetadataContribution,
|
||||
PageFragmentContribution,
|
||||
} from "../plugins/types.js";
|
||||
|
||||
export { createPublicPageContext } from "./context.js";
|
||||
export type { CreatePublicPageContextInput } from "./context.js";
|
||||
|
||||
export {
|
||||
resolvePageMetadata,
|
||||
renderPageMetadata,
|
||||
safeJsonLdSerialize,
|
||||
escapeHtmlAttr,
|
||||
} from "./metadata.js";
|
||||
export type { ResolvedPageMetadata } from "./metadata.js";
|
||||
|
||||
export { resolveFragments, renderFragments } from "./fragments.js";
|
||||
|
||||
export { generateBaseSeoContributions } from "./seo-contributions.js";
|
||||
export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
||||
|
||||
/**
|
||||
* Shape of the EmDash runtime methods used by the render components.
|
||||
* Extracted here so all three components share a single type definition.
|
||||
*/
|
||||
export interface EmDashPageRuntime {
|
||||
collectPageMetadata: (page: PublicPageContext) => Promise<PageMetadataContribution[]>;
|
||||
collectPageFragments: (page: PublicPageContext) => Promise<PageFragmentContribution[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page runtime from Astro locals. Returns undefined when
|
||||
* EmDash is not initialized (components render nothing in that case).
|
||||
*/
|
||||
export function getPageRuntime(locals: Record<string, unknown>): EmDashPageRuntime | undefined {
|
||||
const emdash = locals.emdash;
|
||||
if (
|
||||
emdash &&
|
||||
typeof emdash === "object" &&
|
||||
"collectPageMetadata" in emdash &&
|
||||
"collectPageFragments" in emdash
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structural check above confirms presence of required methods
|
||||
return emdash as EmDashPageRuntime;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Astro render components are exported from "emdash/ui":
|
||||
// import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
|
||||
94
packages/core/src/page/jsonld.ts
Normal file
94
packages/core/src/page/jsonld.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* JSON-LD structured data builders
|
||||
*
|
||||
* Moved from template SEO.astro components into core so all JSON-LD
|
||||
* is serialized via safeJsonLdSerialize() and never hand-rolled in templates.
|
||||
*/
|
||||
|
||||
import type { PublicPageContext } from "../plugins/types.js";
|
||||
|
||||
/**
|
||||
* Remove null/undefined values from a JSON-LD object recursively.
|
||||
* JSON-LD validators prefer absent keys over null values.
|
||||
*/
|
||||
export function cleanJsonLd(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-null, non-array object is safely treated as Record<string, unknown> for JSON-LD traversal
|
||||
cleaned[key] = cleanJsonLd(value as Record<string, unknown>);
|
||||
} else {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a BlogPosting JSON-LD graph from page context.
|
||||
* Used for article-type content pages.
|
||||
*/
|
||||
export function buildBlogPostingJsonLd(page: PublicPageContext): Record<string, unknown> | null {
|
||||
if (page.pageType !== "article" || !page.canonical) return null;
|
||||
|
||||
const ogTitle = page.seo?.ogTitle || page.title;
|
||||
const description = page.seo?.ogDescription || page.description;
|
||||
const ogImage = page.seo?.ogImage || page.image;
|
||||
const publishedTime = page.articleMeta?.publishedTime;
|
||||
const modifiedTime = page.articleMeta?.modifiedTime;
|
||||
const author = page.articleMeta?.author;
|
||||
const siteName = page.siteName;
|
||||
|
||||
return cleanJsonLd({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: ogTitle,
|
||||
description,
|
||||
image: ogImage || undefined,
|
||||
url: page.canonical,
|
||||
datePublished: publishedTime || undefined,
|
||||
dateModified: modifiedTime || publishedTime || undefined,
|
||||
author: author
|
||||
? {
|
||||
"@type": "Person",
|
||||
name: author,
|
||||
}
|
||||
: undefined,
|
||||
publisher: siteName
|
||||
? {
|
||||
"@type": "Organization",
|
||||
name: siteName,
|
||||
}
|
||||
: undefined,
|
||||
mainEntityOfPage: {
|
||||
"@type": "WebPage",
|
||||
"@id": page.canonical,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WebSite JSON-LD graph from page context.
|
||||
* Used for non-article pages (homepage, listing pages, etc.)
|
||||
*/
|
||||
export function buildWebSiteJsonLd(page: PublicPageContext): Record<string, unknown> | null {
|
||||
const siteName = page.siteName;
|
||||
if (!siteName) return null;
|
||||
|
||||
// Use origin from the page URL for the site URL
|
||||
let siteUrl: string;
|
||||
try {
|
||||
siteUrl = new URL(page.url).origin;
|
||||
} catch {
|
||||
siteUrl = page.canonical || page.url;
|
||||
}
|
||||
|
||||
return cleanJsonLd({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
});
|
||||
}
|
||||
185
packages/core/src/page/metadata.ts
Normal file
185
packages/core/src/page/metadata.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Page metadata collection and rendering
|
||||
*
|
||||
* Collects typed metadata contributions from plugins via the page:metadata hook,
|
||||
* validates them, and resolves them into a deduplicated structure ready to render.
|
||||
*/
|
||||
|
||||
import type { PageMetadataContribution, PageMetadataLinkRel } from "../plugins/types.js";
|
||||
|
||||
// ── Resolved output ─────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedPageMetadata {
|
||||
meta: Array<{ name: string; content: string }>;
|
||||
properties: Array<{ property: string; content: string }>;
|
||||
links: Array<{
|
||||
rel: PageMetadataLinkRel;
|
||||
href: string;
|
||||
hreflang?: string;
|
||||
}>;
|
||||
jsonld: Array<{ id?: string; json: string }>;
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────
|
||||
|
||||
/** Schemes safe for use in link href attributes */
|
||||
const SAFE_HREF_RE = /^(https?|at):\/\//i;
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
const HTML_ESCAPE_RE = /[&<>"']/g;
|
||||
|
||||
/** Escape a string for safe use in an HTML attribute value */
|
||||
export function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch] ?? ch);
|
||||
}
|
||||
|
||||
/** Validate that a URL uses a safe scheme (http, https, at) */
|
||||
function isSafeHref(url: string): boolean {
|
||||
return SAFE_HREF_RE.test(url);
|
||||
}
|
||||
|
||||
// ── JSON-LD serialization ───────────────────────────────────────
|
||||
|
||||
const JSONLD_LT_RE = /</g;
|
||||
const JSONLD_GT_RE = />/g;
|
||||
const JSONLD_U2028_RE = /\u2028/g;
|
||||
const JSONLD_U2029_RE = /\u2029/g;
|
||||
|
||||
/**
|
||||
* Safely serialize a value for embedding in a <script type="application/ld+json"> tag.
|
||||
*
|
||||
* Plain JSON.stringify is not sufficient because:
|
||||
* - "</script>" in a nested string breaks out of the script tag
|
||||
* - "<!--" can open an HTML comment
|
||||
* - U+2028/U+2029 are line terminators in some JS engines
|
||||
*/
|
||||
export function safeJsonLdSerialize(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(JSONLD_LT_RE, "\\u003c")
|
||||
.replace(JSONLD_GT_RE, "\\u003e")
|
||||
.replace(JSONLD_U2028_RE, "\\u2028")
|
||||
.replace(JSONLD_U2029_RE, "\\u2029");
|
||||
}
|
||||
|
||||
// ── Merge / dedupe ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a flat list of contributions into deduplicated metadata.
|
||||
* First contribution wins for any given dedupe key.
|
||||
*/
|
||||
export function resolvePageMetadata(
|
||||
contributions: PageMetadataContribution[],
|
||||
): ResolvedPageMetadata {
|
||||
const result: ResolvedPageMetadata = {
|
||||
meta: [],
|
||||
properties: [],
|
||||
links: [],
|
||||
jsonld: [],
|
||||
};
|
||||
|
||||
const seenMeta = new Set<string>();
|
||||
const seenProperties = new Set<string>();
|
||||
const seenLinks = new Set<string>();
|
||||
const seenJsonLd = new Set<string>();
|
||||
|
||||
for (const c of contributions) {
|
||||
switch (c.kind) {
|
||||
case "meta": {
|
||||
const dedupeKey = c.key ?? c.name;
|
||||
if (seenMeta.has(dedupeKey)) continue;
|
||||
seenMeta.add(dedupeKey);
|
||||
result.meta.push({ name: c.name, content: c.content });
|
||||
break;
|
||||
}
|
||||
case "property": {
|
||||
const dedupeKey = c.key ?? c.property;
|
||||
if (seenProperties.has(dedupeKey)) continue;
|
||||
seenProperties.add(dedupeKey);
|
||||
result.properties.push({
|
||||
property: c.property,
|
||||
content: c.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "link": {
|
||||
if (!isSafeHref(c.href)) {
|
||||
if (import.meta.env?.DEV) {
|
||||
console.warn(
|
||||
`[page:metadata] Rejected link contribution with unsafe href scheme: ${c.href}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c.rel === "canonical") {
|
||||
if (seenLinks.has("canonical")) continue;
|
||||
seenLinks.add("canonical");
|
||||
} else {
|
||||
const dedupeKey = c.key ?? c.hreflang ?? c.href;
|
||||
if (seenLinks.has(dedupeKey)) continue;
|
||||
seenLinks.add(dedupeKey);
|
||||
}
|
||||
result.links.push({
|
||||
rel: c.rel,
|
||||
href: c.href,
|
||||
...(c.hreflang && { hreflang: c.hreflang }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "jsonld": {
|
||||
if (c.id) {
|
||||
if (seenJsonLd.has(c.id)) continue;
|
||||
seenJsonLd.add(c.id);
|
||||
}
|
||||
result.jsonld.push({
|
||||
id: c.id,
|
||||
json: safeJsonLdSerialize(c.graph),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown contribution kind -- skip silently at runtime.
|
||||
// TypeScript catches this at compile time for typed callers,
|
||||
// but sandboxed plugins may return unexpected shapes.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── HTML rendering ──────────────────────────────────────────────
|
||||
|
||||
/** Render resolved metadata to an HTML string for embedding in <head> */
|
||||
export function renderPageMetadata(metadata: ResolvedPageMetadata): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const m of metadata.meta) {
|
||||
parts.push(`<meta name="${escapeHtmlAttr(m.name)}" content="${escapeHtmlAttr(m.content)}">`);
|
||||
}
|
||||
|
||||
for (const p of metadata.properties) {
|
||||
parts.push(
|
||||
`<meta property="${escapeHtmlAttr(p.property)}" content="${escapeHtmlAttr(p.content)}">`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const l of metadata.links) {
|
||||
let tag = `<link rel="${escapeHtmlAttr(l.rel)}" href="${escapeHtmlAttr(l.href)}"`;
|
||||
if (l.hreflang) {
|
||||
tag += ` hreflang="${escapeHtmlAttr(l.hreflang)}"`;
|
||||
}
|
||||
tag += ">";
|
||||
parts.push(tag);
|
||||
}
|
||||
|
||||
for (const j of metadata.jsonld) {
|
||||
parts.push(`<script type="application/ld+json">${j.json}</script>`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
136
packages/core/src/page/seo-contributions.ts
Normal file
136
packages/core/src/page/seo-contributions.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Generate base SEO metadata contributions from PublicPageContext.
|
||||
*
|
||||
* These contributions are prepended BEFORE plugin contributions in
|
||||
* resolvePageMetadata(), which uses first-wins dedup. This means
|
||||
* plugins can override any base SEO tag by contributing the same key.
|
||||
*
|
||||
* This replaces the per-template SEO.astro components, eliminating
|
||||
* the class of XSS bugs where templates hand-rolled JSON-LD serialization.
|
||||
*/
|
||||
|
||||
import type { PageMetadataContribution, PublicPageContext } from "../plugins/types.js";
|
||||
import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
||||
|
||||
/**
|
||||
* Generate base metadata contributions from a page context's SEO data.
|
||||
* Returns an empty array if no SEO-relevant data is present.
|
||||
*/
|
||||
export function generateBaseSeoContributions(page: PublicPageContext): PageMetadataContribution[] {
|
||||
const contributions: PageMetadataContribution[] = [];
|
||||
|
||||
const description = page.description;
|
||||
const ogTitle = page.seo?.ogTitle || page.title;
|
||||
const ogDescription = page.seo?.ogDescription || description;
|
||||
const ogImage = page.seo?.ogImage || page.image;
|
||||
const robots = page.seo?.robots;
|
||||
const canonical = page.canonical;
|
||||
const siteName = page.siteName;
|
||||
|
||||
// -- Meta tags --
|
||||
|
||||
if (description) {
|
||||
contributions.push({ kind: "meta", name: "description", content: description });
|
||||
}
|
||||
|
||||
if (robots) {
|
||||
contributions.push({ kind: "meta", name: "robots", content: robots });
|
||||
}
|
||||
|
||||
// -- Canonical link --
|
||||
|
||||
if (canonical) {
|
||||
contributions.push({ kind: "link", rel: "canonical", href: canonical });
|
||||
}
|
||||
|
||||
// -- Open Graph --
|
||||
|
||||
contributions.push({
|
||||
kind: "property",
|
||||
property: "og:type",
|
||||
content: page.pageType === "article" ? "article" : "website",
|
||||
});
|
||||
|
||||
if (ogTitle) {
|
||||
contributions.push({ kind: "property", property: "og:title", content: ogTitle });
|
||||
}
|
||||
|
||||
if (ogDescription) {
|
||||
contributions.push({ kind: "property", property: "og:description", content: ogDescription });
|
||||
}
|
||||
|
||||
if (ogImage) {
|
||||
contributions.push({ kind: "property", property: "og:image", content: ogImage });
|
||||
}
|
||||
|
||||
if (canonical) {
|
||||
contributions.push({ kind: "property", property: "og:url", content: canonical });
|
||||
}
|
||||
|
||||
if (siteName) {
|
||||
contributions.push({ kind: "property", property: "og:site_name", content: siteName });
|
||||
}
|
||||
|
||||
// -- Twitter Card --
|
||||
|
||||
contributions.push({
|
||||
kind: "meta",
|
||||
name: "twitter:card",
|
||||
content: ogImage ? "summary_large_image" : "summary",
|
||||
});
|
||||
|
||||
if (ogTitle) {
|
||||
contributions.push({ kind: "meta", name: "twitter:title", content: ogTitle });
|
||||
}
|
||||
|
||||
if (ogDescription) {
|
||||
contributions.push({ kind: "meta", name: "twitter:description", content: ogDescription });
|
||||
}
|
||||
|
||||
if (ogImage) {
|
||||
contributions.push({ kind: "meta", name: "twitter:image", content: ogImage });
|
||||
}
|
||||
|
||||
// -- Article metadata --
|
||||
|
||||
if (page.pageType === "article" && page.articleMeta) {
|
||||
const { publishedTime, modifiedTime, author } = page.articleMeta;
|
||||
if (publishedTime) {
|
||||
contributions.push({
|
||||
kind: "property",
|
||||
property: "article:published_time",
|
||||
content: publishedTime,
|
||||
});
|
||||
}
|
||||
if (modifiedTime) {
|
||||
contributions.push({
|
||||
kind: "property",
|
||||
property: "article:modified_time",
|
||||
content: modifiedTime,
|
||||
});
|
||||
}
|
||||
if (author) {
|
||||
contributions.push({
|
||||
kind: "property",
|
||||
property: "article:author",
|
||||
content: author,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- JSON-LD --
|
||||
|
||||
if (page.pageType === "article") {
|
||||
const blogPosting = buildBlogPostingJsonLd(page);
|
||||
if (blogPosting) {
|
||||
contributions.push({ kind: "jsonld", id: "primary", graph: blogPosting });
|
||||
}
|
||||
} else if (siteName) {
|
||||
const webSite = buildWebSiteJsonLd(page);
|
||||
if (webSite) {
|
||||
contributions.push({ kind: "jsonld", id: "primary", graph: webSite });
|
||||
}
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
Reference in New Issue
Block a user