/*
Maintains packages/share/src/semantic/jsonld/organization.ts as an authored share authored module so agents can evolve it without rediscovering local boundaries.
- Do not validate input context.
- Do not fetch external data.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import type { JsonLdContext } from "./context.ts";
import type { JsonLdNode } from "./types.ts";
export function buildOrganizationNode(context: JsonLdContext): JsonLdNode {
const { page, ids } = context;
return {
// RFC-0242: a Bodenstation studio deployment overrides this to ["Organization", "ProfessionalService"].
"@type": page.organization.schemaType ?? ["Organization", "NGO"],
"@id": ids.organization,
name: page.organization.name,
legalName: page.organization.legalName,
description: page.organization.description,
url: page.organization.url,
email: page.organization.email,
...(page.organization.foundingYear ? { foundingDate: page.organization.foundingYear } : {}),
...(page.organization.registration ? { identifier: page.organization.registration } : {}),
...(page.organization.founders?.length
? {
founder: page.organization.founders.map((person) => ({
"@id": ids.person(person.name),
})),
}
: {}),
...(page.organization.boardMembers?.length
? {
member: page.organization.boardMembers.map((person) => ({
"@id": ids.person(person.name),
})),
}
: {}),
...(page.organization.contactPoints?.length
? {
contactPoint: page.organization.contactPoints.map((contactPoint) => ({
"@type": "ContactPoint",
contactType: contactPoint.contactType,
email: contactPoint.email,
})),
}
: {}),
...(page.organization.address
? {
address: {
"@type": "PostalAddress",
streetAddress: page.organization.address.streetAddress,
postalCode: page.organization.address.postalCode,
addressLocality: page.organization.address.addressLocality,
addressCountry: page.organization.address.addressCountry,
},
}
: {}),
...(page.organization.areaServed?.length ? { areaServed: page.organization.areaServed } : {}),
...(page.organization.location?.serviceArea?.length && !page.organization.areaServed?.length
? { areaServed: page.organization.location.serviceArea }
: {}),
...(page.organization.team?.length
? {
// RFC-0148: public team members as schema.org employees.
employee: page.organization.team.map((member) => ({
"@type": "Person",
name: member.name,
...(member.description ? { description: member.description } : {}),
})),
}
: {}),
...(page.organization.offer?.prices?.length
? {
// RFC-0147: project the canonical offer as schema.org Offer nodes.
// RFC-0745: emit priceCurrency from canonical source currency when available.
makesOffer: page.organization.offer.prices.map((price) => ({
"@type": "Offer",
name: price.label,
priceSpecification: {
"@type": "PriceSpecification",
price: price.amount,
},
...(price.currency ? { priceCurrency: price.currency } : {}),
})),
}
: {}),
// RFC-0163: entity-grounding signals for Google Knowledge Graph + LLM disambiguation.
...(page.organization.sameAs?.length ? { sameAs: page.organization.sameAs } : {}),
...(page.organization.logo
? { logo: { "@type": "ImageObject", url: page.organization.logo } }
: {}),
...(page.organization.image ? { image: page.organization.image } : {}),
...(page.organization.offer?.prices?.length
? { priceRange: buildPriceRange(page.organization.offer.prices) }
: {}),
};
}
function buildPriceRange(prices: Array<{ amount: string; currency?: string }>): string {
const numericAmounts = prices
.map((p) => Number.parseFloat(p.amount))
.filter((n) => !Number.isNaN(n));
if (numericAmounts.length === 0) return "";
const min = Math.min(...numericAmounts);
const max = Math.max(...numericAmounts);
const currency = prices.find((p) => p.currency)?.currency;
const formatAmount = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
if (min === max) {
return currency ? `${formatAmount(min)} ${currency}` : formatAmount(min);
}
return currency
? `${formatAmount(min)}–${formatAmount(max)} ${currency}`
: `${formatAmount(min)}–${formatAmount(max)}`;
}