first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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,
};
}

View 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");
}

View 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";

View 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,
});
}

View 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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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");
}

View 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;
}