/*
RFC-0144: the single, framework-agnostic per-page semantic builder. Orchestration that was duplicated between the disk loader (@warpgogol/site-kernel-content) and the Astro path (formerly @warpgogol/business, deleted in RFC-0471) lives here once. Where the bytes come from is injected via a small SemanticContentReader seam; the disk path supplies an fs reader, the Astro path an astro:content reader. (A future RFC-0141 node ContentSourceProvider can back both readers — see RFC-0144 staging.)
- Do not read files or import astro:content — all I/O flows through the injected reader.
- Do not assemble the organization profile or iterate system.md pages — callers own that.
- Do not resolve output projection (RFC-0143) — callers attach model.output.
- RFC-0372: unified all page types through extractContentBlocks + extractPageHeading; removed home-specific branch and extractMarkdownProps; extractContentBlocks returns SemanticBlock[].
- RFC-0912: extract seo.videoObject opt-in from block props and attach VideoSeoData to SemanticBlock.video.
- RFC-1197: resolveFaqEntries merges inline faq-list items[] (baked surfaces) with tag-filtered collection entries — empty tag-filter no longer suppresses the FAQPage when inline items exist.
- RFC-1197: step 3 — faq-list inline items + featureFlag + orderTags ordering
faq-list-section renders inline items[] through the same accordion markup
(authored order preserved, no collection read) and skips featureFlag draft
entries in collection mode; collection ordering now honors RFC-0567 orderTags
per tag with order fallback (was alphabetical-by-slug). build-page
resolveFaqEntries merges inline faq-list items with tag-filtered collection
entries — empty tag-filter no longer suppresses the FAQPage when inline items
exist. 3 new build-page tests + 8 existing green.
- warpgogol-m000185: optional resolveInlineText on SemanticContentReader — inline faq-list items[] carry =(…) expressions on baked surfaces (raw baked props reach JSON-LD pre-substitution); resolveFaqEntries resolves them through the reader seam so accordion and FAQPage derive from the same resolved text.
RFC-0144
*/
import type {
SemanticBlock,
SemanticBreadcrumb,
SemanticFaqEntry,
SemanticInitiative,
SemanticOrganization,
SemanticPageModel,
SemanticPageType,
SemanticPerson,
VideoSeoData,
} from "./models.ts";
import { BLOCK_EXTRACTORS } from "./block-extraction.ts";
import "./block-extractors/index.ts";
import {
buildMarkdownPageSemantic,
type MarkdownPageInput,
} from "./page-builders/markdown-page.ts";
import { extractAnswerBlocksFromMarkdown, toSemanticAnswerBlocks } from "./page-utils.ts";
import type { DerivedPriceEntry } from "./price-marker-resolver.ts";
import {
resolvePriceMarkersForSemantic,
resolveAmountMarkersForSemantic,
} from "./price-marker-resolver.ts";
/** The organization profile + enrichment pools the builder draws from. */
export interface SemanticBuildProfile {
organization: SemanticOrganization;
people: SemanticPerson[];
initiatives: SemanticInitiative[];
}
/**
* The injected content-read seam. Each implementation keeps its source's
* behavior (the fs reader substitutes content references per RFC-0045; the
* astro:content reader returns raw body). Returning `null` frontmatter means
* the page does not exist for the language and is skipped by the caller.
*/
export interface SemanticContentReader {
getPageFrontmatter(pageId: string, lang: string): Promise | null>;
getProseBody(proseSlug: string, lang: string): Promise;
getHomeLabel(lang: string): Promise;
getFaqEntries(lang: string): Promise;
/**
* RFC-0767: load derived prices for price marker resolution. Returns null
* when the derived prices file does not exist (ENOENT). Synchronous because
* the underlying loader uses readFileSync — the method is on the reader
* interface to keep build-page.ts I/O-free per its module contract.
*/
getDerivedPrices(): Record | null;
/**
* warpgogol-m000185: resolve =(collection/file.field | pipe) content
* references in arbitrary text. Inline faq-list items[] on baked surfaces
* carry such expressions — the visible accordion gets them resolved via
* substituteBlockPropReferences, but the JSON-LD path reads the raw baked
* props, so it must resolve through the same index (single derivation).
* Optional: readers without a ref index may omit it (text passes through).
*/
resolveInlineText?(text: string, lang: string): Promise;
}
export interface SemanticPageBuildArgs {
pageId: string;
semanticType: SemanticPageType;
lang: string;
url: URL | string;
profile: SemanticBuildProfile;
/**
* RFC-0377: optional audience override for the page. If omitted, consumers fall back to the
* SemanticPageType derivation map (AUDIENCE_BY_PAGE_TYPE).
*/
audience?: string;
/**
* RFC-0195: synthetic frontmatter used when the reader has no content entry for `pageId`
* (e.g. a Programmatic Surface page baked into the route artifact rather than a pages/*.md file).
* Shaped like a page entry: `{ title, description, blocks }`.
*/
fallbackFrontmatter?: Record;
/**
* RFC-0229: the canonical breadcrumb trail (Home → live ancestors → self) resolved by the render
* pipeline. When supplied it becomes the page's `breadcrumbs` (driving both the JSON-LD
* BreadcrumbList and the visible section); when omitted the builder falls back to a flat trail.
*/
breadcrumbs?: SemanticBreadcrumb[];
}
/** semanticType → which enrichment pool to attach. */
const PAGE_ENRICHMENT_MAP: Partial> = {
about: "people",
projects: "initiatives",
};
/** semanticType values whose pages embed the FAQ list. */
const PAGE_INCLUDE_FAQ: ReadonlySet = new Set([
"donationContact",
]);
/**
* Resolve the FAQ entries a page should embed (drives the FAQPage JSON-LD node).
* A page carries FAQ when its semanticType opts in (PAGE_INCLUDE_FAQ), it is the
* canonical `faq` page, or it declares a visible `faq-list` block. RFC-1197:
* `faq-list` blocks carrying non-empty inline `items[]` (baked surfaces)
* contribute ONLY those items — the section skips the collection read in
* inline mode, so the JSON-LD side mirrors it (one derivation, one node).
* Blocks with `tag` contribute tag-filtered collection entries; blocks with
* neither tag nor items contribute the whole lang collection; a page with no
* faq-list block but an opt-in type/pageId embeds the whole collection.
* Returns undefined when the page should carry no FAQPage. Shared by the home
* and markdown builder paths so a `faq-list` block on the home emits FAQPage too.
*/
async function resolveFaqEntries(
reader: SemanticContentReader,
lang: string,
blocks: Array>,
pageId: string,
semanticType: SemanticPageType,
): Promise {
const faqBlocks = blocks.filter((b) => b["type"] === "faq-list");
if (!(PAGE_INCLUDE_FAQ.has(semanticType) || pageId === "faq" || faqBlocks.length > 0)) {
return undefined;
}
const resolveInline = reader.resolveInlineText;
const inlineNested = await Promise.all(
faqBlocks.map(async (b) => {
const props = (b["props"] ?? b) as Record;
const blockId = typeof b["id"] === "string" ? b["id"] : "faq-inline";
const items = Array.isArray(props["items"])
? (props["items"] as Array<{ question?: unknown; answer?: unknown }>)
: [];
const resolved: SemanticFaqEntry[] = [];
for (const [i, item] of items.entries()) {
if (typeof item?.question !== "string" || typeof item?.answer !== "string") continue;
resolved.push({
id: `faq/${blockId}-${i}`,
question: resolveInline ? await resolveInline(item.question, lang) : item.question,
answer: resolveInline ? await resolveInline(item.answer, lang) : item.answer,
});
}
return resolved;
}),
);
const inlineItems = inlineNested.flat();
const faqEntries = await reader.getFaqEntries(lang);
// Mirror the section's per-block semantics: a block with non-empty items[]
// renders ONLY those items (no collection read); a block with tag renders
// the tag-filtered collection; a block with neither renders all entries.
// No faq-list blocks at all means the page type itself opts in (hub) → all.
const seenIds = new Set();
const collectionEntries: SemanticFaqEntry[] = [];
const pushUnique = (entries: SemanticFaqEntry[]) => {
for (const e of entries) {
if (seenIds.has(e.id)) continue;
seenIds.add(e.id);
collectionEntries.push(e);
}
};
if (faqBlocks.length === 0) {
pushUnique(faqEntries);
} else {
for (const b of faqBlocks) {
const props = (b["props"] ?? b) as Record;
const hasInline = Array.isArray(props["items"]) && props["items"].length > 0;
if (hasInline) continue;
const tag = props["tag"];
pushUnique(
typeof tag === "string" && tag.length > 0
? faqEntries.filter((e) => e.tags?.includes(tag))
: faqEntries,
);
}
}
const merged = [...collectionEntries, ...inlineItems];
return merged.length > 0 ? merged : undefined;
}
/** RFC-0912: extract video SEO data from block props when seo.videoObject opt-in is present. */
function extractVideoSeoData(props: Record): VideoSeoData | undefined {
const seo = props["seo"];
if (!seo || typeof seo !== "object") return undefined;
const seoRecord = seo as Record;
if (seoRecord["videoObject"] !== true) return undefined;
const name = seoRecord["name"];
const description = seoRecord["description"];
const uploadDate = seoRecord["uploadDate"];
if (
typeof name !== "string" ||
typeof description !== "string" ||
typeof uploadDate !== "string"
) {
return undefined;
}
// The manifest data (posterUrl, durationSec, contentUrl) is populated by the
// render layer when the variant manifest is available. At build-page time we
// carry the seo fields; the render layer fills in the manifest data.
return {
seo: { name, description, uploadDate },
manifest: { posterUrl: "", contentUrl: "" },
};
}
/** RFC-0372: extract semantic text from declared blocks into SemanticBlock[]. */
function extractContentBlocks(
blocks: Array>,
ctx: { pageId: string; lang: string; siteUrl: string },
): SemanticBlock[] {
const result: SemanticBlock[] = [];
for (const block of blocks) {
const blockType = String(block["type"] ?? "");
const blockId = block["id"];
if (typeof blockId !== "string" || !blockId) {
throw new Error(
`[extractContentBlocks] Block #${result.length} in page ${ctx.pageId} (${ctx.lang}) is missing required \`id\` field. Run \`block.id.generate\` to backfill.`,
);
}
if (!blockType) continue;
const extractor = BLOCK_EXTRACTORS.get(blockType);
if (!extractor) continue;
const props = (block["props"] ?? block) as Record;
const extracted = extractor.extract(props, ctx);
// RFC-0372: always push the block — no-op extractors return heading: "".
result.push({
id: blockId,
blockType,
heading: extracted.heading ?? "",
summary: extracted.lead,
body: extracted.body,
items: extracted.items,
extractedAt: new Date().toISOString(),
extractorVersion: "1.0.0",
// RFC-0912: attach video SEO data when the block has seo.videoObject opt-in.
...((block["props"] ?? block) &&
typeof (block["props"] ?? block) === "object" &&
extractVideoSeoData((block["props"] ?? block) as Record)
? { video: extractVideoSeoData((block["props"] ?? block) as Record) }
: {}),
});
}
return result;
}
/**
* RFC-0372: Scan all frontmatter blocks in declaration order for header.heading / header.subheading.
* Works with any block type that has header props (hero-decision-card, markdown, etc.) — not
* limited to type: "markdown". Falls back to frontmatter title/description.
*/
function extractPageHeading(
blocks: Array>,
fallbackTitle: string,
fallbackDescription: string,
): { heading: string; lead?: string } {
let heading = fallbackTitle;
let lead: string | undefined;
for (const block of blocks) {
const props = (block["props"] ?? block) as Record;
const headerHeading =
props["header"] && typeof props["header"] === "object"
? safeString((props["header"] as Record)["heading"])
: safeString(props["heading"]);
const headerSubheading =
props["header"] && typeof props["header"] === "object"
? safeString((props["header"] as Record)["subheading"])
: safeString(props["lead"]);
if (!heading || heading === fallbackTitle) {
if (headerHeading) heading = headerHeading;
}
if (!lead) {
if (headerSubheading) lead = headerSubheading;
}
if (heading !== fallbackTitle && lead) break;
}
if (!lead) lead = fallbackDescription || undefined;
return { heading, ...(lead ? { lead } : {}) };
}
function safeString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
/**
* RFC-0144/RFC-0372: build a single SemanticPageModel via the injected reader. This is
* the one place the per-page construction logic lives. All page types (including home)
* go through the same unified pipeline: extractContentBlocks → extractPageHeading →
* buildMarkdownPageSemantic with a merged blocks array. `model.organization`
* and `model.output` (RFC-0143) are attached by the caller.
*/
export async function buildSemanticPageModelWith(
reader: SemanticContentReader,
args: SemanticPageBuildArgs,
): Promise {
const { pageId, semanticType, lang, url, profile } = args;
const frontmatter = (await reader.getPageFrontmatter(pageId, lang)) ?? args.fallbackFrontmatter;
if (!frontmatter) return null;
const title = (frontmatter["title"] as string) ?? "";
const rawDescription = (frontmatter["description"] as string) ?? "";
const allBlocks = (frontmatter["blocks"] as Array> | undefined) ?? [];
// RFC-0372: Extract block-derived SemanticBlocks from frontmatter.
const blockDerived = extractContentBlocks(allBlocks, { pageId, lang, siteUrl: url.toString() });
// RFC-0372: Extract prose from contentRef (if any) and convert to SemanticBlock[].
// Scan frontmatter blocks for a contentRef in any block's props (typically markdown blocks).
let proseBlocks: SemanticBlock[] = [];
for (const block of allBlocks) {
const props = (block["props"] ?? block) as Record;
const contentRef = props["contentRef"] as string | undefined;
if (contentRef) {
const proseSlug = contentRef.replace(/^prose\//, "");
const bodyText = await reader.getProseBody(proseSlug, lang);
const markdownBlocks = extractAnswerBlocksFromMarkdown(bodyText);
proseBlocks = toSemanticAnswerBlocks(markdownBlocks);
break;
}
}
// RFC-0372: Merge block-derived and prose-derived blocks in frontmatter declaration order.
// For pages with both frontmatter blocks and prose, prose blocks are appended after
// block-derived blocks (prose contentRef is typically the markdown body following the
// structured blocks).
const mergedBlocks = [...blockDerived, ...proseBlocks];
// RFC-0372: Extract page heading from frontmatter block props (any block type with
// header.heading), falling back to title/description.
const { heading, lead } = extractPageHeading(allBlocks, title, rawDescription);
// RFC-0767: resolve {price:offering:chargeRef} markers to source-currency (EUR)
// strings for JSON-LD and meta tags. Markers in heading, lead, and description
// are resolved before entering the SemanticPageModel.
const derivedPrices = reader.getDerivedPrices();
const resolvedHeading = resolveAmountMarkersForSemantic(
resolvePriceMarkersForSemantic(heading, lang, derivedPrices),
lang,
);
const resolvedLead = lead
? resolveAmountMarkersForSemantic(
resolvePriceMarkersForSemantic(lead, lang, derivedPrices),
lang,
)
: undefined;
const resolvedDescription = resolveAmountMarkersForSemantic(
resolvePriceMarkersForSemantic(rawDescription, lang, derivedPrices),
lang,
);
const baseInput = { lang, url, title, description: resolvedDescription };
const homeLabel = await reader.getHomeLabel(lang);
const enrichmentKey = PAGE_ENRICHMENT_MAP[semanticType];
const input: MarkdownPageInput = {
type: semanticType,
...baseInput,
...(args.audience ? { audience: args.audience } : {}),
heading: resolvedHeading,
...(resolvedLead ? { lead: resolvedLead } : {}),
blocks: mergedBlocks,
breadcrumbsContent: { homeLabel },
...(args.breadcrumbs && args.breadcrumbs.length > 0 ? { breadcrumbs: args.breadcrumbs } : {}),
...(enrichmentKey === "people" ? { people: profile.people } : {}),
...(enrichmentKey === "initiatives" ? { initiatives: profile.initiatives } : {}),
// RFC-0200: a person profile page attaches just the profiled person (matched by
// name) so a Person JSON-LD node emits as the page's subject.
...(semanticType === "person"
? { people: profile.people.filter((p) => p.name === title) }
: {}),
};
const faqEntries = await resolveFaqEntries(reader, lang, allBlocks, pageId, semanticType);
if (faqEntries) {
input.faqEntries = faqEntries.map((e) => ({
...e,
question: resolveAmountMarkersForSemantic(
resolvePriceMarkersForSemantic(e.question, lang, derivedPrices),
lang,
),
answer: resolveAmountMarkersForSemantic(
resolvePriceMarkersForSemantic(e.answer, lang, derivedPrices),
lang,
),
}));
}
return buildMarkdownPageSemantic(input);
}