Files
cms-plugins/app/src/lib/cms.ts
T
Oleks 90a4b8088b fix(#4): consistent normalized plugin↔CMS join + orphan-ref check
Interim fix for the free-text title-match footguns (issue #4); durable ULID
reference + seed migration tracked separately.

- New lib/cms.ts: single normCms() match key + cmsSlugByTitle / resolveCmsSlug,
  used by all three join sites so a plugin can no longer link from its own page
  yet vanish from a CMS list over case/whitespace drift.
- cms/index.astro + cms/[slug].astro: counts and "plugins from / targeting"
  lists now use the normalized key (were exact-match).
- plugins/[slug].astro: drop the local normalize copy; link target_cms too
  (was source-only) for parity.
- warnOrphanCmsRefs(): logs any source_cms/target_cms that resolves to no CMS,
  so silent orphans surface in the server log.
2026-06-02 05:05:30 +03:00

66 lines
2.3 KiB
TypeScript

// Plugin↔CMS relation helpers.
//
// A plugin references a CMS by the free-text `source_cms` / `target_cms`
// string, which is matched against a CMS entry's `title`. There is no
// reference field / foreign key yet (tracked in issue #4 — the durable fix is
// an Emdash relation field keyed by the CMS ULID + a seed migration). Until
// then, EVERY page that joins plugins to CMSes must use the SAME normalized
// match so a plugin can't link from its own page yet vanish from a CMS list
// over mere casing/whitespace drift.
interface CmsEntry {
id: string;
data: { title?: string | null };
}
interface PluginRefData {
source_cms?: string | null;
target_cms?: string | null;
}
/** Canonical CMS-match key: trim + lowercase. The ONE place case/whitespace is normalized. */
export const normCms = (s: string): string => s.trim().toLowerCase();
/** Map of normalized CMS title → CMS entry slug (`entry.id`, used in URLs). */
export function cmsSlugByTitle(cmses: CmsEntry[]): Map<string, string> {
return new Map(
cmses.filter((c) => c.data.title).map((c) => [normCms(c.data.title as string), c.id]),
);
}
/** Resolve a plugin's CMS reference to a CMS slug, or undefined if it matches no CMS. */
export function resolveCmsSlug(
ref: string | null | undefined,
index: Map<string, string>,
): string | undefined {
return ref ? index.get(normCms(ref)) : undefined;
}
/**
* Integrity check: log any plugin `source_cms`/`target_cms` that resolves to no
* CMS entry. These are silent orphans (excluded from CMS counts/lists with no
* user-visible error), so surfacing them in the server log is the cheap guard
* until the relation becomes a real reference (issue #4). Call from a page that
* already has both collections in scope; it does not fetch.
*/
export function warnOrphanCmsRefs(
plugins: Array<{ id: string; data: PluginRefData }>,
cmses: CmsEntry[],
): void {
const known = new Set(
cmses.filter((c) => c.data.title).map((c) => normCms(c.data.title as string)),
);
const orphans: string[] = [];
for (const p of plugins) {
for (const field of ["source_cms", "target_cms"] as const) {
const ref = p.data[field];
if (ref && !known.has(normCms(ref))) orphans.push(`${p.id}.${field}="${ref}"`);
}
}
if (orphans.length) {
console.warn(
`[cms] ${orphans.length} plugin CMS reference(s) match no CMS entry (issue #4):`,
orphans.join(", "),
);
}
}