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.
This commit is contained in:
Oleks
2026-06-02 05:05:30 +03:00
parent 6e6fd76459
commit 90a4b8088b
4 changed files with 86 additions and 10 deletions
+65
View File
@@ -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<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(", "),
);
}
}
+7 -2
View File
@@ -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 ? (
<Base title="Not found">
+7 -3
View File
@@ -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<string, number>();
const countByTarget = new Map<string, number>();
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);
}
---
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
@@ -28,7 +32,7 @@ for (const p of plugins) {
{cmses.map((c) => (
<li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<div class="meta">{countBySource.get(c.data.title) ?? 0} plugins from · {countByTarget.get(c.data.title) ?? 0} targeting</div>
<div class="meta">{countBySource.get(normCms(c.data.title)) ?? 0} plugins from · {countByTarget.get(normCms(c.data.title)) ?? 0} targeting</div>
{c.data.description && <p>{c.data.description}</p>}
</li>
))}
+7 -5
View File
@@ -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 ? (
<Base title="Not found">
@@ -56,8 +58,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(norm(d.source_cms)) : u
<dl>
{d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{d.target_cms}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{targetCmsSlug ? <a href={`/cms/${targetCmsSlug}`}>{d.target_cms}</a> : d.target_cms}</dd></>)}
{d.source_repo_url && (<><dt>Source repo</dt><dd><a href={d.source_repo_url}>{d.source_repo_url}</a></dd></>)}
{d.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
</dl>