/*
RFC-0208: block text extractor implementations for all known block types. Registers extractors into the global BLOCK_EXTRACTORS registry.
- Do not handle block types that contain no text-bearing props.
- Do not resolve content references — extraction is shallow and deterministic.
- RFC-0208: registered extractors for warpgogol-com block types.
- RFC-0372: added video-section, donation-card, credits, passport no-op extractors; people extractor now extracts heading; all extractors return heading (possibly empty) for unified SemanticBlock.
- warpgogol-m000196: added commitment-table, check-questions, verifiable-case extractors for the semantic home sections.
- RFC-1288: Resolve price markers through one resolver for every projection
*/
import { BLOCK_EXTRACTORS } from "../block-extraction.ts";
import { offeringIdFromRef } from "../price-marker-resolver.ts";
// RFC-1288: the {price:chargeRef} shorthand is scoped to blocks that bind an
// offering via props.offeringRef (ownership-block). Extraction expands it to
// the canonical {price::} form so one resolver serves
// every projection. The char class excludes ":" so full-form markers pass
// through untouched; without offeringRef the marker stays verbatim — it does
// not match PRICE_MARKER_RE, so it bypasses PRICE-RESOLVE-01 and is caught by
// DIST-LEAK-06 residue scanning downstream.
const SHORTHAND_PRICE_MARKER_RE = /\{price:([a-zA-Z0-9_.-]+)\}/g;
function expandShorthandPriceMarkers(text: string, offeringId: string | undefined): string {
if (!offeringId) return text;
return text.replace(SHORTHAND_PRICE_MARKER_RE, `{price:${offeringId}:$1}`);
}
function safeGet(obj: unknown, path: string): unknown {
return path.split(".").reduce((acc: unknown, key: string) => {
if (acc && typeof acc === "object") return (acc as Record)[key];
return undefined;
}, obj);
}
function safeString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function safeArray(v: unknown, mapper: (item: unknown) => T | undefined): T[] | undefined {
if (!Array.isArray(v)) return undefined;
const result = v.map(mapper).filter((x): x is T => x !== undefined);
return result.length > 0 ? result : undefined;
}
// ---------------------------------------------------------------------------
// hero-decision-card
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "hero-decision-card",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead =
safeString(safeGet(props, "header.subheading")) ?? safeString(safeGet(props, "tagline"));
const items = safeArray(safeGet(props, "decisionCard.items"), (item) => {
const label = safeString(safeGet(item, "label"));
const value = safeString(safeGet(item, "value"));
if (!label) return undefined;
return { title: `${label}: ${value ?? ""}` };
});
const ctaItems: Array<{ title: string; description?: string }> = [];
const primaryLabel = safeString(safeGet(props, "primaryCta.label"));
const secondaryLabel = safeString(safeGet(props, "secondaryCta.label"));
if (primaryLabel) ctaItems.push({ title: `CTA: ${primaryLabel}` });
if (secondaryLabel) ctaItems.push({ title: `CTA: ${secondaryLabel}` });
return { heading, lead, items: items?.length ? [...items, ...ctaItems] : ctaItems };
},
});
// ---------------------------------------------------------------------------
// audience-cards (guarantees, included-features, price-economics, growth-modules, referral-club, growth-timeline, etc.)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "audience-cards",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead = safeString(safeGet(props, "header.subheading"));
const cards = safeArray(safeGet(props, "body.cards"), (card) => {
const title = safeString(safeGet(card, "title"));
const description = safeString(safeGet(card, "description"));
if (!title) return undefined;
return { title, description };
});
return { heading, lead, items: cards };
},
});
// ---------------------------------------------------------------------------
// comparison-cards
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "comparison-cards",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const _labels = safeGet(props, "labels") as Record | undefined;
const rows = safeArray(safeGet(props, "rows"), (row) => {
const left = safeString(safeGet(row, "left"));
const right = safeString(safeGet(row, "right"));
if (!left || !right) return undefined;
return { title: `${left} → ${right}` };
});
return { heading, items: rows };
},
});
// ---------------------------------------------------------------------------
// price-card
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "price-card",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const monthly = safeString(safeGet(props, "monthly"));
const yearly = safeString(safeGet(props, "yearly"));
const setup = safeString(safeGet(props, "setup"));
const includes = safeArray(safeGet(props, "includes"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: text };
});
const priceItems: Array<{ title: string; description?: string }> = [];
if (monthly) priceItems.push({ title: `Monatlich: ${monthly}` });
if (yearly) priceItems.push({ title: `Jährlich: ${yearly}` });
if (setup) priceItems.push({ title: `Einrichtung: ${setup}` });
return { heading, items: includes?.length ? [...priceItems, ...includes] : priceItems };
},
});
// ---------------------------------------------------------------------------
// ownership-block
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "ownership-block",
extract(props) {
const offeringRef = safeString(safeGet(props, "offeringRef"));
const offeringId = offeringRef ? offeringIdFromRef(offeringRef) : undefined;
const heading = safeString(safeGet(props, "header.heading"));
const items = safeArray(safeGet(props, "body.items"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: expandShorthandPriceMarkers(text, offeringId) };
});
return { heading, items };
},
});
// ---------------------------------------------------------------------------
// controlled-responsibility-block
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "controlled-responsibility-block",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead = safeString(safeGet(props, "header.subheading"));
const primaryLabel = safeString(safeGet(props, "labels.primary"));
const secondaryLabel = safeString(safeGet(props, "labels.secondary"));
const primaryItems = safeArray(safeGet(props, "primaryItems"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: `${primaryLabel ?? ""}: ${text}`.replace(/^:\s*/, "") };
});
const secondaryItems = safeArray(safeGet(props, "secondaryItems"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: `${secondaryLabel ?? ""}: ${text}`.replace(/^:\s*/, "") };
});
return { heading, lead, items: [...(primaryItems ?? []), ...(secondaryItems ?? [])] };
},
});
// ---------------------------------------------------------------------------
// notausgang-block
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "notausgang-block",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const items = safeArray(safeGet(props, "body.items"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: text };
});
return { heading, items };
},
});
// ---------------------------------------------------------------------------
// faq-list — extracts heading only; FAQ entries come from business/faq via reader
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "faq-list",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
return { heading };
},
});
// ---------------------------------------------------------------------------
// final-cta
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "final-cta",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const paragraphs = safeArray(safeGet(props, "body.paragraphs"), (p) => {
const text = safeString(p);
if (!text) return undefined;
return { title: text };
});
const ctaItems = safeArray(safeGet(props, "ctaGroup.items"), (item) => {
const label = safeString(safeGet(item, "label"));
if (!label) return undefined;
return { title: `CTA: ${label}` };
});
const allItems = [...(paragraphs ?? []), ...(ctaItems ?? [])];
return { heading, items: allItems.length > 0 ? allItems : undefined };
},
});
// ---------------------------------------------------------------------------
// trust-strip
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "trust-strip",
extract(props) {
const items = safeArray(safeGet(props, "body.items"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: text };
});
return { items };
},
});
// ---------------------------------------------------------------------------
// send-message
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "send-message",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const paragraphs = safeArray(safeGet(props, "body.paragraphs"), (p) => {
const text = safeString(p);
if (!text) return undefined;
return { title: text };
});
return { heading, items: paragraphs };
},
});
// ---------------------------------------------------------------------------
// chat-widget
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "chat-widget",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
return { heading };
},
});
// ---------------------------------------------------------------------------
// markdown (RFC-0325: Programmatic Surface Q&A/prose sections baked as repeated
// `type: "markdown"` blocks — e.g. the Ratgeber FAQ items). The first markdown
// block on a page is separately consumed for the page's prose `contentRef`
// body (build-page.ts `extractMarkdownProps`); this extractor additionally
// surfaces EVERY markdown block's heading/lead as a content block so repeated
// Q&A-style sections are not silently dropped from Markdown twins.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "markdown",
extract(props) {
const heading = safeString(safeGet(props, "heading"));
const lead = safeString(safeGet(props, "lead"));
return { heading, lead };
},
});
// ---------------------------------------------------------------------------
// hero (nicaragua-projekt home page)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "hero",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead =
safeString(safeGet(props, "header.subheading")) ?? safeString(safeGet(props, "tagline"));
const description = safeString(safeGet(props, "description"));
const stats = safeArray(safeGet(props, "stats"), (s) => {
const value = safeString(safeGet(s, "value"));
const label = safeString(safeGet(s, "label"));
if (!value || !label) return undefined;
return { title: `${value} ${label}` };
});
const ctaItems: Array<{ title: string }> = [];
const primary = safeString(safeGet(props, "ctaPrimaryLabel"));
const secondary = safeString(safeGet(props, "ctaSecondaryLabel"));
if (primary) ctaItems.push({ title: `CTA: ${primary}` });
if (secondary) ctaItems.push({ title: `CTA: ${secondary}` });
const allItems = [...(stats ?? []), ...ctaItems];
return {
heading,
lead: lead ?? description,
items: allItems.length > 0 ? allItems : undefined,
};
},
});
// ---------------------------------------------------------------------------
// problem (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "problem",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const paragraphs = safeArray(safeGet(props, "body.paragraphs"), (p) => {
const text = safeString(p);
if (!text) return undefined;
return { title: text };
});
return { heading, items: paragraphs };
},
});
// ---------------------------------------------------------------------------
// approach (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "approach",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const cards = safeArray(safeGet(props, "body.cards"), (card) => {
const title = safeString(safeGet(card, "title"));
const description = safeString(safeGet(card, "description"));
if (!title) return undefined;
return { title, description };
});
return { heading, items: cards };
},
});
// ---------------------------------------------------------------------------
// impact (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "impact",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const stats = safeArray(safeGet(props, "body.stats"), (s) => {
const value = safeString(safeGet(s, "value"));
const label = safeString(safeGet(s, "label"));
if (!value || !label) return undefined;
return { title: `${value} ${label}` };
});
return { heading, items: stats };
},
});
// ---------------------------------------------------------------------------
// social-proof (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "social-proof",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const paragraphs = safeArray(safeGet(props, "body.paragraphs"), (p) => {
const text = safeString(p);
if (!text) return undefined;
return { title: text };
});
const note = safeString(safeGet(props, "registrationNote"));
const items = [...(paragraphs ?? [])];
if (note) items.push({ title: note });
return { heading, items: items.length > 0 ? items : undefined };
},
});
// ---------------------------------------------------------------------------
// donation-use (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "donation-use",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const cards = safeArray(safeGet(props, "body.cards"), (card) => {
const number = safeString(safeGet(card, "number"));
const title = safeString(safeGet(card, "title"));
if (!title) return undefined;
return { title: number ? `${number}. ${title}` : title };
});
return { heading, items: cards };
},
});
// ---------------------------------------------------------------------------
// women (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "women",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const paragraphs = safeArray(safeGet(props, "body.paragraphs"), (p) => {
const text = safeString(p);
if (!text) return undefined;
return { title: text };
});
return { heading, items: paragraphs };
},
});
// ---------------------------------------------------------------------------
// transparency (nicaragua-projekt)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "transparency",
extract(props) {
const rawHeading = safeGet(props, "header.heading");
let heading: string | undefined;
if (Array.isArray(rawHeading)) {
heading = rawHeading
.map((h: unknown) => {
if (h && typeof h === "object") {
const text = (h as Record)["text"];
return typeof text === "string" ? text : "";
}
return "";
})
.filter(Boolean)
.join(" ");
} else {
heading = safeString(rawHeading);
}
const note = safeString(safeGet(props, "body.note"));
const items = safeArray(safeGet(props, "body.items"), (item) => {
const text = safeString(safeGet(item, "text"));
if (!text) return undefined;
return { title: text };
});
const reportLink = safeString(safeGet(props, "reportLinkLabel"));
const allItems = [...(items ?? [])];
if (note) allItems.unshift({ title: note });
if (reportLink) allItems.push({ title: reportLink });
return { heading: heading || undefined, items: allItems.length > 0 ? allItems : undefined };
},
});
// ---------------------------------------------------------------------------
// people (nicaragua-projekt, warpgogol-com) — extracts heading from header;
// people content itself is sourced from business/people enrichment, not block props.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "people",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
return { heading };
},
});
// ---------------------------------------------------------------------------
// video-section (warpgogol-com) — extracts heading and lead from header.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "video-section",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead = safeString(safeGet(props, "header.subheading"));
return { heading, lead };
},
});
// ---------------------------------------------------------------------------
// donation-card (nicaragua-projekt) — extracts heading from labels.title.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "donation-card",
extract(props) {
const heading = safeString(safeGet(props, "labels.title"));
return { heading };
},
});
// ---------------------------------------------------------------------------
// credits (nicaragua-projekt, warpgogol-com) — extracts heading and lead.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "credits",
extract(props) {
const heading = safeString(safeGet(props, "heading"));
const lead = safeString(safeGet(props, "lead"));
return { heading, lead };
},
});
// ---------------------------------------------------------------------------
// RFC-0372: Passport-reserved moon no-op extractors. These block types render
// visual components (passport-header, pulsar, passport-score-grid,
// passport-provenance, passport-star-map) with no text content to project into
// the semantic layer. They must be registered so page.blocks.extract.validate
// does not fail, but they return empty headings.
// ---------------------------------------------------------------------------
const PASSPORT_NOOP_TYPES = [
"passport-header",
"pulsar",
"passport-score-grid",
"passport-provenance",
"passport-star-map",
] as const;
for (const blockType of PASSPORT_NOOP_TYPES) {
BLOCK_EXTRACTORS.register>({
blockType,
extract() {
return { heading: "" };
},
});
}
// ---------------------------------------------------------------------------
// entity-grid (RFC-1062) — extracts heading and body items with descriptions.
// ---------------------------------------------------------------------------
// Site-specific block types that have no semantic text to extract but must be
// registered so page.blocks.extract.validate does not fail.
BLOCK_EXTRACTORS.register>({
blockType: "entity-grid",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead = safeString(safeGet(props, "header.subheading"));
const items = safeArray(safeGet(props, "body.items"), (item) => {
const number = safeString(safeGet(item, "number"));
const title = safeString(safeGet(item, "title"));
const description = safeString(safeGet(item, "description"));
if (!title) return undefined;
return { title: number ? `${number}. ${title}` : title, description };
});
return { heading, lead, items };
},
});
// ---------------------------------------------------------------------------
// agent-surface-overview (RFC-1170) — extracts authored heading + intro voice.
// Projected manifest facts are not authored props, so extraction stays shallow.
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "agent-surface-overview",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const lead = safeString(safeGet(props, "intro"));
return { heading, lead };
},
});
// ---------------------------------------------------------------------------
// commitment-table — condition → consequence rows (no struck left column)
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "commitment-table",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const rows = safeArray(safeGet(props, "rows"), (row) => {
const left = safeString(safeGet(row, "left"));
const right = safeString(safeGet(row, "right"));
if (!left || !right) return undefined;
return { title: `${left} → ${right}` };
});
const note = safeString(safeGet(props, "note"));
const items = rows ? [...rows] : [];
if (note) items.push({ title: note });
return { heading, items: items.length ? items : undefined };
},
});
// ---------------------------------------------------------------------------
// check-questions — check question → provider standard rows
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "check-questions",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const rows = safeArray(safeGet(props, "rows"), (row) => {
const left = safeString(safeGet(row, "left"));
const right = safeString(safeGet(row, "right"));
if (!left || !right) return undefined;
return { title: `${left} → ${right}` };
});
return { heading, items: rows };
},
});
// ---------------------------------------------------------------------------
// verifiable-case — numbered steps + business link + disclaimer footer
// ---------------------------------------------------------------------------
BLOCK_EXTRACTORS.register>({
blockType: "verifiable-case",
extract(props) {
const heading = safeString(safeGet(props, "header.heading"));
const steps = safeArray(safeGet(props, "steps"), (step) => {
const title = safeString(safeGet(step, "title"));
const description = safeString(safeGet(step, "description"));
if (!title) return undefined;
return { title, description };
});
const items = steps ? [...steps] : [];
const linkLabel = safeString(safeGet(props, "businessLink.label"));
const linkHref = safeString(safeGet(props, "businessLink.href"));
if (linkLabel)
items.push({
title: linkHref ? `${linkLabel} (${linkHref})` : linkLabel,
description: undefined,
});
const footer = safeString(safeGet(props, "footer"));
if (footer) items.push({ title: footer, description: undefined });
return { heading, items: items.length ? items : undefined };
},
});
const SITE_NOOP_TYPES = [
"nachweis-list",
"nachweis-detail",
"nachweis-verify",
"gratitude",
"open-source-registry",
"mountain-journey",
"dynamic-status-block",
"service-metadata-block",
"toc",
"exit-diagram",
] as const;
for (const blockType of SITE_NOOP_TYPES) {
BLOCK_EXTRACTORS.register>({
blockType,
extract() {
return { heading: "" };
},
});
}