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:
@@ -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(", "),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user