diff --git a/app/src/lib/cms.ts b/app/src/lib/cms.ts new file mode 100644 index 0000000..fdcb99c --- /dev/null +++ b/app/src/lib/cms.ts @@ -0,0 +1,65 @@ +// 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 { + 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 | 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(", "), + ); + } +} diff --git a/app/src/pages/cms/[slug].astro b/app/src/pages/cms/[slug].astro index cb2120c..aaa413d 100644 --- a/app/src/pages/cms/[slug].astro +++ b/app/src/pages/cms/[slug].astro @@ -3,6 +3,7 @@ import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash"; import Base from "../../layouts/Base.astro"; import PluginCard from "../../components/PluginCard.astro"; import { PLUGIN_FETCH_CAP } from "../../lib/statuses"; +import { normCms } from "../../lib/cms"; export const prerender = false; @@ -26,8 +27,12 @@ const { entries: all } = cms : { entries: [] }; if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated"); const cmsName = cms?.data.title as string; -const fromHere = all.filter((p) => p.data.source_cms === cmsName); -const targetingHere = all.filter((p) => p.data.target_cms === cmsName); +// Normalized match (see lib/cms.ts / issue #4): a plugin whose source_cms +// differs from this CMS title only by case/whitespace must still appear here, +// exactly as it links from its own detail page. +const cmsKey = cmsName ? normCms(cmsName) : ""; +const fromHere = all.filter((p) => p.data.source_cms && normCms(p.data.source_cms) === cmsKey); +const targetingHere = all.filter((p) => p.data.target_cms && normCms(p.data.target_cms) === cmsKey); --- {!cms ? ( diff --git a/app/src/pages/cms/index.astro b/app/src/pages/cms/index.astro index ec01823..9156a7b 100644 --- a/app/src/pages/cms/index.astro +++ b/app/src/pages/cms/index.astro @@ -2,6 +2,7 @@ import { getEmDashCollection } from "emdash"; import Base from "../../layouts/Base.astro"; import { PLUGIN_FETCH_CAP } from "../../lib/statuses"; +import { normCms, warnOrphanCmsRefs } from "../../lib/cms"; export const prerender = false; @@ -12,13 +13,16 @@ Astro.cache.set(cacheHint); const { entries: plugins } = await getEmDashCollection("plugins", { limit: PLUGIN_FETCH_CAP }); if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated"); +warnOrphanCmsRefs(plugins, cmses); +// Count by NORMALIZED CMS key so the tallies match the (normalized) joins on +// the CMS detail and plugin detail pages — see lib/cms.ts / issue #4. const countBySource = new Map(); const countByTarget = new Map(); for (const p of plugins) { const s = p.data.source_cms; - if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1); + if (s) countBySource.set(normCms(s), (countBySource.get(normCms(s)) ?? 0) + 1); const t = p.data.target_cms; - if (t) countByTarget.set(t, (countByTarget.get(t) ?? 0) + 1); + if (t) countByTarget.set(normCms(t), (countByTarget.get(normCms(t)) ?? 0) + 1); } --- @@ -28,7 +32,7 @@ for (const p of plugins) { {cmses.map((c) => (
  • {c.data.title}

    -
    {countBySource.get(c.data.title) ?? 0} plugins from · {countByTarget.get(c.data.title) ?? 0} targeting
    +
    {countBySource.get(normCms(c.data.title)) ?? 0} plugins from · {countByTarget.get(normCms(c.data.title)) ?? 0} targeting
    {c.data.description &&

    {c.data.description}

    }
  • ))} diff --git a/app/src/pages/plugins/[slug].astro b/app/src/pages/plugins/[slug].astro index eac60f1..81f4c8d 100644 --- a/app/src/pages/plugins/[slug].astro +++ b/app/src/pages/plugins/[slug].astro @@ -3,6 +3,7 @@ import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash"; import { PortableText } from "emdash/ui"; import Base from "../../layouts/Base.astro"; import StatusBadge from "../../components/StatusBadge.astro"; +import { cmsSlugByTitle, resolveCmsSlug } from "../../lib/cms"; export const prerender = false; @@ -24,9 +25,10 @@ if (cacheHint) Astro.cache.set(cacheHint); const d = entry?.data; const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] }; -const norm = (s: string) => s.trim().toLowerCase(); -const cmsSlugByTitle = new Map(cmses.map((c) => [norm(c.data.title as string), c.id])); -const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(norm(d.source_cms)) : undefined; +// Shared normalized join (lib/cms.ts / issue #4) — same match the CMS pages use. +const cmsIndex = cmsSlugByTitle(cmses); +const sourceCmsSlug = resolveCmsSlug(d?.source_cms, cmsIndex); +const targetCmsSlug = resolveCmsSlug(d?.target_cms, cmsIndex); --- {!entry || !d ? ( @@ -56,8 +58,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(norm(d.source_cms)) : u
    {d.category && (<>
    Category
    {d.category}
    )} - {d.source_cms && (<>
    Source CMS
    {d.source_cms}
    )} - {d.target_cms && (<>
    Target CMS
    {d.target_cms}
    )} + {d.source_cms && (<>
    Source CMS
    {sourceCmsSlug ? {d.source_cms} : d.source_cms}
    )} + {d.target_cms && (<>
    Target CMS
    {targetCmsSlug ? {d.target_cms} : d.target_cms}
    )} {d.source_repo_url && (<>
    Source repo
    {d.source_repo_url}
    )} {d.target_repo_url && (<>
    Target repo
    {d.target_repo_url}
    )}