/*
Canonical on-disk layout and serialization of PBP documents under
src/content/business-profile// — the single type→directory map and the
deterministic frontmatter writer shared by sternsystem.register --intake seeding
and the Nachweis I/O helpers (RFC-1307).
- Do not validate entity shape — callers parse with pbpSchemaById before serializing (DNA-103).
- Do not read or write files; this module only computes paths and bytes.
- One map for every writer: register seeding
- Singleton entity types (business, legal-identity, brand) are type-named files
- Serialization is independent of input key order: envelope keys first in fixed order, every other mapping key sorted lexicographically.
- YAML quoting follows RFC-0493 — plain scalars by default, double quotes when required, never single quotes; no line folding.
- RFC-1307: introduce shared PBP layout map and canonical PBP document writer.
- RFC-1307: step 1 — shared PBP layout map, canonical writer, typed onboarding state
- RFC-1307: canonical org-singleton PBP paths, seeded hreflang+publication.gate, full rollback
- RFC-1307: record review-fix decisions in compass headers
*/
import YAML from "yaml";
/**
* Entity type → directory below `business-profile//`.
* `""` means the document lives at the locale root (`business.md`).
* Covers every `INTAKE_PBP_SCHEMA_ALLOWLIST` type plus `consent` (Nachweis).
*/
export const PBP_ENTITY_DIR_MAP: Readonly> = Object.freeze({
business: "",
"legal-identity": "organization",
brand: "organization",
"contact-point": "contact",
offering: "offerings",
claim: "trust/claims",
"evidence-source": "trust/evidence",
disclosure: "trust/disclosures",
consent: "trust/consents",
});
/** Envelope keys emitted first, in this order; everything else is lexicographic. */
export const PBP_ENVELOPE_KEY_ORDER = ["schema", "id", "type", "status", "name"] as const;
/**
* Types with exactly one document per locale — the file is named after the
* type, never the entity id: root `business.md`, `organization/legal-identity.md`,
* `organization/brand.md`. Collection types take the id slug instead
* (`trust/claims/.md`).
*/
export const PBP_SINGLETON_TYPES: ReadonlySet = new Set([
"business",
"legal-identity",
"brand",
]);
/**
* Directory (relative to `business-profile//`) for an entity type.
* Unknown types fall back to the type name itself — the Nachweis behaviour
* before the map was shared.
*/
export function pbpEntityDir(entityType: string): string {
return PBP_ENTITY_DIR_MAP[entityType] ?? entityType;
}
/**
* Slug of a PBP entity id: the segment after the last `/` (`claim/european-quality`
* → `european-quality`). Ids without `/` are their own slug. Throws when the
* derived slug is not a kebab-case filename token — a malformed id must fail
* at plan time, not land as an unresolvable file name.
*/
export function pbpEntitySlug(entityId: string): string {
const slash = entityId.lastIndexOf("/");
const slug = slash === -1 ? entityId : entityId.slice(slash + 1);
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new Error(
`[pbp-layout] entity id '${entityId}' does not yield a kebab-case slug ('${slug}')`,
);
}
return slug;
}
/**
* Path of a PBP document relative to `src/content/business-profile/`, using
* POSIX separators: `//.md` for collection types,
* `//.md` for singleton types (see PBP_SINGLETON_TYPES).
*/
export function pbpDocumentPath(locale: string, entity: { type: string; id: string }): string {
const dir = pbpEntityDir(entity.type);
const base = PBP_SINGLETON_TYPES.has(entity.type) ? entity.type : pbpEntitySlug(entity.id);
return dir === "" ? `${locale}/${base}.md` : `${locale}/${dir}/${base}.md`;
}
function isPlainObject(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sortDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortDeep);
if (!isPlainObject(value)) return value;
const out: Record = {};
for (const key of Object.keys(value).sort()) {
if (value[key] === undefined) continue;
out[key] = sortDeep(value[key]);
}
return out;
}
/** Reorder an entity: envelope keys first (fixed order), the rest sorted at every depth. */
export function canonicalizePbpEntity(entity: Record): Record {
const out: Record = {};
for (const key of PBP_ENVELOPE_KEY_ORDER) {
if (entity[key] !== undefined) out[key] = sortDeep(entity[key]);
}
for (const key of Object.keys(entity).sort()) {
if ((PBP_ENVELOPE_KEY_ORDER as readonly string[]).includes(key)) continue;
if (entity[key] === undefined) continue;
out[key] = sortDeep(entity[key]);
}
return out;
}
/**
* Serialize a (schema-parsed) PBP entity as a frontmatter-only markdown
* document. Output bytes depend only on the entity's content, never on the
* key order it arrived in.
*/
export function serializePbpDocument(entity: Record): string {
const yaml = YAML.stringify(canonicalizePbpEntity(entity), {
lineWidth: 0,
singleQuote: false,
defaultStringType: "PLAIN",
defaultKeyType: "PLAIN",
});
return `---\n${yaml}---\n`;
}