/*
Catalog and composition helpers for shared section prop fragments: defines SHARED_SECTION_PROPS catalog with versioned fragments and provides resolution/changelog/composition functions.
- Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.
- RFC-0303 Phase 3: extracted from shared-section-props/index.ts as part of the domain split.
*/
import type { JsonSchemaFragment } from "./common.ts";
import { SECTION_VISUAL_FRAGMENT, SECTION_HEADER_FRAGMENT } from "./visual-header.ts";
import {
BODY_LIST_FRAGMENT,
BODY_SPLIT_LIST_FRAGMENT,
BODY_STATS_FRAGMENT,
BODY_CARDS_FRAGMENT,
BODY_PARAGRAPHS_FRAGMENT,
BODY_COMPARISON_FRAGMENT,
BODY_RICH_FRAGMENT,
} from "./body-fragments.ts";
// RFC-0119: every fragment carries `schemaVersion: number` (starts at 1) and
// optional `changelog`. The catalog stores versions under `latest` and
// optionally `prior` so consumers can pin to the prior shape during a
// migration window. Bare ids (without `@version`) resolve to `latest`.
type FragmentVersion = JsonSchemaFragment & {
schemaVersion: number;
changelog?: string;
};
type FragmentEntry = {
latest: FragmentVersion;
prior?: FragmentVersion;
};
function v1(fragment: JsonSchemaFragment, changelog: string): FragmentVersion {
return { schemaVersion: 1, ...fragment, changelog };
}
export const SHARED_SECTION_PROPS: Record = {
"section-visual": {
latest: v1(
SECTION_VISUAL_FRAGMENT,
"Initial release with background, effects, density, tone, containerVariant, motion.",
),
},
"section-header": {
latest: v1(
SECTION_HEADER_FRAGMENT,
"Heading (string or tone-segmented array), eyebrow, subheading, align, level, hideSectionNumber. RFC-0567 added eyebrow.",
),
},
"body-list": {
latest: v1(
BODY_LIST_FRAGMENT,
"Initial release of body.kind: list with items + note + iconColor + align + effects.",
),
},
"body-split-list": {
latest: v1(
BODY_SPLIT_LIST_FRAGMENT,
"Initial release of body.kind: split-list with primary/secondary items, labels, iconColors + effects.",
),
},
"body-stats": {
latest: v1(
BODY_STATS_FRAGMENT,
"Initial release of body.kind: stats with stats[] + animated + align.",
),
},
"body-cards": {
latest: v1(
BODY_CARDS_FRAGMENT,
"Initial release of body.kind: cards with cards[] + layout + columns + align + effects. Added optional number field to cards for numbered lists.",
),
},
"body-paragraphs": {
latest: v1(
BODY_PARAGRAPHS_FRAGMENT,
"Initial release of body.kind: paragraphs with paragraphs[] + align.",
),
},
"body-comparison": {
latest: v1(
BODY_COMPARISON_FRAGMENT,
"Initial release of body.kind: comparison with rows + labels + align + effects.",
),
},
"body-rich": {
latest: v1(
BODY_RICH_FRAGMENT,
"Initial release of body.kind: rich with contentRef + animateNumbers + align.",
),
},
};
export type SharedSectionPropsId = keyof typeof SHARED_SECTION_PROPS;
/** RFC-0119: pinned reference syntax `@` or bare `` (latest). */
export type SharedSectionPropsRef = string;
export function isSharedSectionPropsId(id: string): id is SharedSectionPropsId {
const baseId = id.split("@")[0];
return Object.prototype.hasOwnProperty.call(SHARED_SECTION_PROPS, baseId);
}
/**
* RFC-0119: resolve a fragment reference (`` or `@`) to the
* matching fragment payload. Unknown ids or pinned versions raise with the
* catalog of valid ids in the message.
*/
function resolveFragment(ref: string): FragmentVersion {
const [baseId, pinned] = ref.split("@");
const entry = SHARED_SECTION_PROPS[baseId];
if (!entry) {
throw new Error(
`[shared-section-props] Unknown fragment id "${baseId}". ` +
`Valid ids: ${Object.keys(SHARED_SECTION_PROPS).join(", ")}`,
);
}
if (!pinned) return entry.latest;
const pinnedVersion = Number.parseInt(pinned, 10);
if (Number.isNaN(pinnedVersion)) {
throw new Error(`[shared-section-props] Invalid pinned version "${pinned}" on "${baseId}".`);
}
if (entry.latest.schemaVersion === pinnedVersion) return entry.latest;
if (entry.prior?.schemaVersion === pinnedVersion) return entry.prior;
throw new Error(
`[shared-section-props] Fragment "${baseId}" version ${pinnedVersion} not available ` +
`(latest: ${entry.latest.schemaVersion}${entry.prior ? `, prior: ${entry.prior.schemaVersion}` : ""}).`,
);
}
/**
* RFC-0119: machine-readable changelog snapshot. Used by
* shared.section-props.changelog.report.
*/
export function sharedSectionPropsChangelog(): Array<{
id: string;
schemaVersion: number;
changelog?: string;
}> {
return Object.entries(SHARED_SECTION_PROPS).map(([id, entry]) => ({
id,
schemaVersion: entry.latest.schemaVersion,
changelog: entry.latest.changelog,
}));
}
/**
* Compose fragments + the manifest's local propsSchema into one draft-07
* JSON Schema object. Properties of later inputs override earlier ones;
* `additionalProperties: false` is enforced unless the manifest explicitly
* opts out. Fragment ids accept both `` and `@` forms
* (RFC-0119).
*/
export function composeManifestPropsSchema(input: {
compose?: readonly string[];
local?: Record;
}): Record {
const properties: Record = {};
const required = new Set();
for (const ref of input.compose ?? []) {
const frag = resolveFragment(ref);
Object.assign(properties, frag.properties);
for (const r of frag.required ?? []) required.add(r);
}
if (input.local) {
const localProps = (input.local.properties as Record | undefined) ?? {};
Object.assign(properties, localProps);
const localRequired = Array.isArray(input.local.required)
? (input.local.required as string[])
: [];
for (const r of localRequired) required.add(r);
}
const additionalProperties =
input.local && Object.prototype.hasOwnProperty.call(input.local, "additionalProperties")
? input.local.additionalProperties
: false;
const result: Record = {
type: "object",
additionalProperties,
properties,
};
if (required.size > 0) result.required = Array.from(required);
return result;
}