Plugin↔CMS link is a free-text title match with no referential integrity, and join semantics differ across pages #4

Open
opened 2026-06-02 04:56:04 +03:00 by oleks · 1 comment
Owner

Problem

A plugin's association to a CMS is modeled as a free-text string (source_cms / target_cms) matched against the cmses collection's title field at render time. There is no reference/relation and no validation, so:

  1. No referential integrity. A typo, rename, or case/whitespace drift in a plugin's source_cms/target_cms produces an orphan: the plugin appears under no CMS and is excluded from that CMS's counts, with no error surfaced.
  2. Inconsistent match semantics across pages. The plugin detail page matches CMS case-insensitively / trim-tolerant, while the CMS index and CMS detail pages match exactly. A plugin whose source_cms differs from a CMS title only by case/whitespace still renders a working link from its own page, yet is silently missing from that CMS's "Plugins from this CMS" list and from both counters.

Evidence

  • app/seed/seed.jsonplugins: source_cms (type: "string", required) + target_cms (type: "string"); cmses keyed by free-text title (type: "string"). No relation/reference field type.
  • app/src/pages/cms/index.astro:18-22,31 — counts keyed by exact p.data.source_cms/target_cms, looked up via countBySource.get(c.data.title) (exact).
  • app/src/pages/cms/[slug].astro:22-24all.filter((p) => p.data.source_cms === cmsName) (exact).
  • app/src/pages/plugins/[slug].astro:20-23norm = s => s.trim().toLowerCase(); joins cmsSlugByTitle.get(norm(d.source_cms)) (NORMALIZED), and only links source_cms, not target_cms.
  • Convention (CLAUDE.md Emdash gotchas): entry.data.id is the DB ULID for cross-collection refs; the current join uses display title instead.

Options

  1. Real reference (durable). Make source_cms/target_cms an Emdash relation/reference field (confirm Emdash supports relation fields in seed.json), store by CMS entry.data.id (ULID), join on id across all pages. Requires a one-time seed.json migration (title→ULID) + admin-side enforcement.
  2. Keep strings but enforce consistency + integrity (interim). (a) Single shared normalize helper used by ALL THREE pages (kill the exact-vs-normalized split; also link target_cms on the plugin page). (b) Build/dev validation that flags any plugin whose source_cms/target_cms doesn't resolve to an existing CMS.
  3. Document-only. Accept the title-match design, document as intentional in ARCHITECTURE.md.

Recommendation

Short term: Option 2a (one normalize helper shared by all three pages + link target_cms from the plugin page) to remove the exact-vs-normalized inconsistency, paired with 2b validation so orphans are caught not silently dropped. Treat Option 1 (true reference + ULID join + seed migration) as the durable fix, gated on an owner decision and Emdash relation-field support. Phase-0 status is irrelevant here — this is app render logic, not the chart.


Surfaced by the deploy-hardening review pass; deferred from auto-fix because it's a data-model/schema design decision.

## Problem A plugin's association to a CMS is modeled as a free-text string (`source_cms` / `target_cms`) matched against the `cmses` collection's `title` field at render time. There is no reference/relation and no validation, so: 1. **No referential integrity.** A typo, rename, or case/whitespace drift in a plugin's `source_cms`/`target_cms` produces an orphan: the plugin appears under no CMS and is excluded from that CMS's counts, with no error surfaced. 2. **Inconsistent match semantics across pages.** The plugin detail page matches CMS *case-insensitively / trim-tolerant*, while the CMS index and CMS detail pages match *exactly*. A plugin whose `source_cms` differs from a CMS `title` only by case/whitespace still renders a working link from its own page, yet is silently missing from that CMS's "Plugins from this CMS" list and from both counters. ## Evidence - `app/seed/seed.json` — `plugins`: `source_cms` (`type: "string"`, required) + `target_cms` (`type: "string"`); `cmses` keyed by free-text `title` (`type: "string"`). No relation/reference field type. - `app/src/pages/cms/index.astro:18-22,31` — counts keyed by exact `p.data.source_cms`/`target_cms`, looked up via `countBySource.get(c.data.title)` (exact). - `app/src/pages/cms/[slug].astro:22-24` — `all.filter((p) => p.data.source_cms === cmsName)` (exact). - `app/src/pages/plugins/[slug].astro:20-23` — `norm = s => s.trim().toLowerCase()`; joins `cmsSlugByTitle.get(norm(d.source_cms))` (NORMALIZED), and only links `source_cms`, not `target_cms`. - Convention (`CLAUDE.md` Emdash gotchas): `entry.data.id` is the DB ULID for cross-collection refs; the current join uses display title instead. ## Options 1. **Real reference (durable).** Make `source_cms`/`target_cms` an Emdash relation/reference field (confirm Emdash supports relation fields in seed.json), store by CMS `entry.data.id` (ULID), join on id across all pages. Requires a one-time seed.json migration (title→ULID) + admin-side enforcement. 2. **Keep strings but enforce consistency + integrity (interim).** (a) Single shared normalize helper used by ALL THREE pages (kill the exact-vs-normalized split; also link `target_cms` on the plugin page). (b) Build/dev validation that flags any plugin whose `source_cms`/`target_cms` doesn't resolve to an existing CMS. 3. **Document-only.** Accept the title-match design, document as intentional in `ARCHITECTURE.md`. ## Recommendation Short term: Option 2a (one normalize helper shared by all three pages + link `target_cms` from the plugin page) to remove the exact-vs-normalized inconsistency, paired with 2b validation so orphans are caught not silently dropped. Treat Option 1 (true reference + ULID join + seed migration) as the durable fix, gated on an owner decision and Emdash relation-field support. Phase-0 status is irrelevant here — this is app render logic, not the chart. --- _Surfaced by the deploy-hardening review pass; deferred from auto-fix because it's a data-model/schema design decision._
Author
Owner

Interim fix shipped (Option 2a + 2b) — durable fix still open

Commit 90a4b80 on develop. Removed the exact-vs-normalized inconsistency and added orphan detection; the schema change is deferred.

  • New app/src/lib/cms.ts — single normCms() match key + cmsSlugByTitle() / resolveCmsSlug(), now the only place the plugin↔CMS join is normalized.
  • cms/index.astro + cms/[slug].astro — counts and "plugins from / targeting" lists use the normalized key (were exact-match), so a plugin can no longer link from its own page yet vanish from a CMS list over case/whitespace drift.
  • plugins/[slug].astro — dropped the local normalize copy; now also links target_cms (was source-only) for parity.
  • warnOrphanCmsRefs() (2b) — logs any source_cms/target_cms that resolves to no CMS, called from cms/index.astro; silent orphans now surface in the server log. astro check green.

Remaining (Option 1, durable): make source_cms/target_cms a real Emdash relation field keyed by the CMS ULID + a one-time seed.json migration (title→ULID) + admin enforcement. Gated on confirming Emdash relation-field support and your sign-off on the schema/seed change. Keeping this issue open for that.

## Interim fix shipped (Option 2a + 2b) — durable fix still open Commit `90a4b80` on `develop`. Removed the exact-vs-normalized inconsistency and added orphan detection; the schema change is deferred. - **New `app/src/lib/cms.ts`** — single `normCms()` match key + `cmsSlugByTitle()` / `resolveCmsSlug()`, now the only place the plugin↔CMS join is normalized. - **`cms/index.astro` + `cms/[slug].astro`** — counts and "plugins from / targeting" lists use the normalized key (were exact-match), so a plugin can no longer link from its own page yet vanish from a CMS list over case/whitespace drift. - **`plugins/[slug].astro`** — dropped the local normalize copy; now also links `target_cms` (was source-only) for parity. - **`warnOrphanCmsRefs()`** (2b) — logs any `source_cms`/`target_cms` that resolves to no CMS, called from `cms/index.astro`; silent orphans now surface in the server log. `astro check` green. **⏳ Remaining (Option 1, durable):** make `source_cms`/`target_cms` a real Emdash relation field keyed by the CMS ULID + a one-time `seed.json` migration (title→ULID) + admin enforcement. Gated on confirming Emdash relation-field support and your sign-off on the schema/seed change. Keeping this issue open for that.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: oleks/cms-plugins#4