/* RFC-0148: the single, framework-agnostic organization-profile assembler. The mapping from business-schema fields to SemanticOrganization lives here once; the disk llms path and the Astro JSON-LD path both resolve their own data (each reads + substitutes content references in its own way) and delegate the assembly here. Replaces the two duplicated org builders (no legacy). Do not read files or substitute content references — callers pass resolved values. Do not iterate pages — this builds the site-wide profile only. RFC-0148: extracted the shared org-profile assembler from the disk + Astro builders. RFC-0910: Organization.url uses canonicalRootUrl (unprefixed root) instead of language-prefixed path. RFC-1075: populate canonicalUri on SemanticOrganization using deriveCanonicalUri. */ import { canonicalRootUrl, getBaseUrl, toAbsoluteUrl } from "./ids.ts"; import { deriveCanonicalUri } from "./canonical-uri.ts"; import type { SemanticInitiative, SemanticLocation, SemanticOffer, SemanticOrganization, SemanticPerson, SemanticService, } from "./models.ts"; export type SemanticSiteProfile = { organization: SemanticOrganization; people: SemanticPerson[]; initiatives: SemanticInitiative[]; }; /** Resolved owner address primitives (content references already substituted). */ export interface ProfileAddressInput { street?: string; streetNumber?: string; zip?: string; city?: string; country?: string; } /** Resolved bank primitives (accountHolder already substituted). */ export interface ProfileBankInput { accountHolder?: string; iban?: string; bic?: string; bankName?: string; konto?: string; blz?: string; } /** * Normalized, fully-resolved business primitives. Each caller (disk fs reader, * Astro content-layer reader) maps its source into this shape — including any * RFC-0045 content-reference substitution — so the assembly below is identical * regardless of source. */ export interface OrganizationProfileInput { lang: string; siteUrl: URL | string; /** Resolved brand name. */ brandName: string; description: string; foundingYear?: string; areaServed?: string[]; founders: SemanticPerson[]; boardMembers: SemanticPerson[]; legalName?: string; registration?: string; representativeName?: string; address?: ProfileAddressInput; /** Present when a bank block exists (drives donationAccount emission). */ bank?: ProfileBankInput; email?: string; contactType?: string; offer?: SemanticOffer; location?: SemanticLocation; team?: SemanticPerson[]; /* RFC-0163: social/profile URLs → Organization.sameAs. */ sameAs?: string[]; /* RFC-0163: absolute logo URL → Organization.logo. */ logoUrl?: string; /** RFC-0373: projected business service catalog. */ services?: SemanticService[]; /** RFC-0242: explicit schema.org @type override (e.g. Bodenstation → Organization/ProfessionalService). */ schemaType?: string[]; } /** RFC-0148: assemble the site profile from resolved business primitives. */ export function buildOrganizationProfile(input: OrganizationProfileInput): SemanticSiteProfile { const baseUrl = getBaseUrl(input.siteUrl); // Use the full team when available (includes founders, board, team, patron, etc.), // otherwise fall back to founders + boardMembers. Deduplicate by name. const rawPeople = input.team?.length ? [...input.team] : [...input.founders, ...input.boardMembers]; const seen = new Set(); const people: SemanticPerson[] = []; for (const p of rawPeople) { if (!seen.has(p.name)) { seen.add(p.name); people.push(p); } } const organization: SemanticOrganization = { name: input.brandName, legalName: input.legalName, description: input.description, url: canonicalRootUrl(baseUrl), ...({ canonicalUri: deriveCanonicalUri(baseUrl, "business") } as { canonicalUri?: string }), foundingYear: input.foundingYear, email: input.email, registration: input.registration, representative: input.representativeName, ...(input.address ? { address: { streetAddress: `${input.address.street} ${input.address.streetNumber}`, postalCode: input.address.zip, addressLocality: input.address.city, addressCountry: input.address.country, }, } : {}), ...(input.areaServed?.length ? { areaServed: input.areaServed } : {}), ...(input.founders.length ? { founders: input.founders } : {}), ...(input.boardMembers.length ? { boardMembers: input.boardMembers } : {}), ...(input.contactType || input.email ? { contactPoints: [ { contactType: input.contactType ?? "", email: input.email, }, ], } : {}), ...(input.bank ? { donationAccount: { accountHolder: input.bank.accountHolder ?? "", iban: input.bank.iban, bic: input.bank.bic, bankName: input.bank.bankName, accountNumber: input.bank.konto, bankCode: input.bank.blz, }, } : {}), ...(input.offer ? { offer: input.offer } : {}), ...(input.location ? { location: input.location } : {}), ...(input.team?.length ? { team: input.team } : {}), // RFC-0163: entity-grounding signals. `image` always points at the guaranteed // /og-image.png fallback (a real, present asset); sameAs/logo emit only when authored. image: toAbsoluteUrl(baseUrl, "/og-image.png"), ...(input.sameAs?.length ? { sameAs: input.sameAs } : {}), ...(input.logoUrl ? { logo: input.logoUrl } : {}), ...(input.schemaType?.length ? { schemaType: input.schemaType } : {}), ...(input.services?.length ? { services: input.services } : {}), }; return { organization, people, initiatives: [] }; }