/* Price marker resolution for semantic projections (RFC-0767, RFC-1288). resolvePriceMarkersStrict resolves {price:offering:chargeRef} and {amount:N} markers to source-currency (EUR) strings, reporting unresolved markers explicitly. Offerings are matched by "/id/offerings/<id>" suffix — no site-specific host. Also hosts the relocated DerivedPriceEntry type and marker regexes shared between the semantic layer and packages/ui. Does not read files — derived prices are passed as a parameter. File I/O lives in derived-prices-loader.ts. Does not append recurrence suffixes — JSON-LD headline/description are free-text strings where recurrence is in the surrounding sentence. Does not resolve markers in block-derived content or prose body — only heading, lead, description (RFC-0767 nonGoals). RFC-0767: relocated DerivedPriceEntry, OFFERING_URI_PREFIX, PRICE_MARKER_RE from packages/ui to packages/share to break circular dependency. RFC-0767: added formatSourcePrice and resolvePriceMarkersForSemantic for semantic-layer marker resolution. RFC-1288: added resolvePriceMarkersStrict, findDerivedPrice, offeringIdFromRef; removed OFFERING_URI_PREFIX and the "0 €" fallback — unresolved markers stay verbatim and are reported for PRICE-RESOLVE-01 diagnostics. */ /** * Derived price entry shape (moved from packages/ui to packages/share * so both the semantic layer and UI components share a single type). */ export interface DerivedPriceEntry { chargeRef: string; targetCurrency: string; amount: { value: string; currency: string }; trace: { source: { amount: string; currency: string }; rate: { value: string; pair: string }; calculation?: { rounding?: { mode: string; increment?: string; decimalPlaces?: number; output: string; }; }; }; } export const PRICE_MARKER_RE = /\{price:([a-zA-Z0-9_-]+):([a-zA-Z0-9_.-]+)\}/g; export const AMOUNT_MARKER_RE = /\{amount:([0-9]+(?:\.[0-9]+)?)\}/g; /** Canonical infix of offering URIs ("/id/offerings/"). */ const OFFERING_ID_INFIX = "/id/offerings/"; /** * Format a source-currency amount for semantic projections. * Unlike formatPrice in packages/ui, this does NOT append a recurrence * suffix — JSON-LD headline/description fields are free-text strings * where the recurrence context is already in the surrounding sentence. * Uses Intl.NumberFormat with currencyDisplay: "narrowSymbol". * Output contains a non-breaking space (U+00A0) between number and symbol. */ export function formatSourcePrice(amount: string, lang: string): string { const numericAmount = Number(amount); if (!Number.isFinite(numericAmount)) return "0\u00A0€"; return new Intl.NumberFormat(lang, { style: "currency", currency: "EUR", currencyDisplay: "narrowSymbol", minimumFractionDigits: 0, maximumFractionDigits: 2, }).format(numericAmount); } export type UnresolvedPriceReason = | "no-derived-prices" | "unknown-offering" | "unknown-charge" | "ambiguous-offering" | "no-source-amount"; export interface UnresolvedPriceMarker { /** Literal marker text, e.g. "{price:digital-foundation:firstYearMonthly}". */ marker: string; offeringId: string; chargeRef: string; reason: UnresolvedPriceReason; } export interface StrictPriceResolution { /** Markers that could not be resolved are left verbatim. */ text: string; unresolved: UnresolvedPriceMarker[]; } export type DerivedPriceLookup = | { /** First entry matching the chargeRef (all matches share trace.source). */ entry: DerivedPriceEntry; /** The matched canonical derived-prices key (offering URI). */ offeringRef: string; /** All entries for the chargeRef — one per target currency. */ matching: DerivedPriceEntry[]; } | { reason: UnresolvedPriceReason }; /** * Extract the offering id suffix from a canonical offering ref. Refs that * already are bare ids (no "/id/offerings/" infix) pass through unchanged. */ export function offeringIdFromRef(ref: string): string { const idx = ref.lastIndexOf(OFFERING_ID_INFIX); return idx >= 0 ? ref.slice(idx + OFFERING_ID_INFIX.length) : ref; } /** * RFC-1288: single offering/charge lookup for every renderer and projection. * Offerings are matched by id suffix (`key.endsWith("/id/offerings/" + * offeringId)`) so no site-specific host is baked into shared code. Exactly * one key must match — zero gives `unknown-offering`, more than one gives * `ambiguous-offering`. `trace.source.amount` is the only authoritative * amount: absent or non-numeric gives `no-source-amount` even when * `amount.value` exists. */ export function findDerivedPrice( derivedPrices: Record | null | undefined, offeringId: string, chargeRef: string, ): DerivedPriceLookup { if (!derivedPrices) return { reason: "no-derived-prices" }; const suffix = OFFERING_ID_INFIX + offeringId; const keys = Object.keys(derivedPrices).filter((key) => key.endsWith(suffix)); if (keys.length === 0) return { reason: "unknown-offering" }; if (keys.length > 1) return { reason: "ambiguous-offering" }; const offeringRef = keys[0]!; const matching = (derivedPrices[offeringRef] ?? []).filter((e) => e.chargeRef === chargeRef); if (matching.length === 0) return { reason: "unknown-charge" }; const entry = matching[0]!; const sourceAmount = entry.trace?.source?.amount; if ( typeof sourceAmount !== "string" || sourceAmount.trim() === "" || !Number.isFinite(Number(sourceAmount)) ) { return { reason: "no-source-amount" }; } return { entry, offeringRef, matching }; } /** * RFC-1288: strict marker resolution for projections. Resolves * {price:offering:chargeRef} and {amount:N} markers to source-currency (EUR) * strings. Unresolvable markers stay verbatim in the text and are reported in * `unresolved` — callers turn them into PRICE-RESOLVE-01 diagnostics or let * DIST-LEAK-06 flag the residue. There is no fallback amount. */ export function resolvePriceMarkersStrict( text: string, lang: string, derivedPrices: Record | null | undefined, ): StrictPriceResolution { const unresolved: UnresolvedPriceMarker[] = []; const resolvedPrices = text.replace( PRICE_MARKER_RE, (marker: string, offeringId: string, chargeRef: string) => { const found = findDerivedPrice(derivedPrices, offeringId, chargeRef); if (!("entry" in found)) { unresolved.push({ marker, offeringId, chargeRef, reason: found.reason }); return marker; } return formatSourcePrice(found.entry.trace.source.amount, lang); }, ); return { text: resolvedPrices.replace(AMOUNT_MARKER_RE, (_match, amount: string) => formatSourcePrice(amount, lang), ), unresolved, }; } /** * Resolve {price:offering:chargeRef} markers in a string to source-currency * (EUR) formatted strings for use in semantic projections (JSON-LD, meta tags). * * Returns the input string unchanged if no markers are present. * RFC-1288: unresolved markers stay verbatim — no "0 €" fallback. DIST-LEAK-06 * is the enforcement boundary for residue that reaches rendered artifacts. */ export function resolvePriceMarkersForSemantic( text: string, lang: string, derivedPrices?: Record | null, ): string { return resolvePriceMarkersStrict(text, lang, derivedPrices).text; } /** * Resolve {amount:NNNN} markers to source-currency (EUR) formatted strings * for semantic projections (JSON-LD, meta tags). These markers represent * literal EUR amounts (e.g. thresholds) that are not tied to an offering. */ export function resolveAmountMarkersForSemantic(text: string, lang: string): string { return text.replace(AMOUNT_MARKER_RE, (_match, amount: string) => { return formatSourcePrice(amount, lang); }); }