/* 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); }