fix(app): architecture + UI/UX review fixes

Multi-agent arch/UX review pass. Architecture: real HTTP 404 on not-found,
breadcrumb links by slug not lowercased title, CMS pages surface their
plugins, shared status taxonomy (src/lib/statuses.ts) consumed by all three
frontend consumers, data-driven status filter, typed emdash collections
(src/emdash-collections.d.ts), removed unused @astrojs/react + react deps and
dead pnpm block, tsconfig extends Astro strict preset, dev deps pruned from
the runtime image. UI/UX: fixed StatusBadge WCAG-AA contrast, labelled the
search/filter controls, canonical URL + BreadcrumbList JSON-LD + og:type,
human status labels, filtered result count + recoverable empty state,
auto-submit filters, mobile overflow fixes, skip-to-content, :focus-visible.

Commit the npm lockfile so the Dockerfile's `npm ci` path engages.
astro check: 0 errors / 0 warnings / 0 hints.
This commit is contained in:
Oleks
2026-06-02 03:24:52 +03:00
parent 4beb58ff48
commit bfc6a65638
18 changed files with 10794 additions and 87 deletions
-2
View File
@@ -1,5 +1,4 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig, memoryCache } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
@@ -27,7 +26,6 @@ export default defineConfig({
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: sqlite({ url: `file:${stateDir}/data.db` }),
storage: local({
+10590
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -17,20 +17,11 @@
},
"dependencies": {
"@astrojs/node": "^10.0.5",
"@astrojs/react": "^5.0.0",
"astro": "^6.3.0",
"better-sqlite3": "^12.8.0",
"emdash": "^0.10.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"emdash": "^0.10.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}
}
+4 -11
View File
@@ -1,18 +1,11 @@
---
import { STATUS_LABELS, STATUSES } from "../lib/statuses";
interface Props {
status: string | null | undefined;
}
const { status } = Astro.props;
const value = (status ?? "proposed").toLowerCase();
const labels: Record<string, string> = {
port: "Port",
"built-in": "Built-in",
drop: "Drop",
saas: "SaaS",
gated: "Gated",
done: "Done",
proposed: "Proposed",
};
const label = labels[value] ?? value;
const label = STATUS_LABELS[value] ?? value;
const desc = STATUSES.find((s) => s.value === value)?.desc;
---
<span class={`badge badge--${value}`}>{label}</span>
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span>
+3 -11
View File
@@ -1,17 +1,9 @@
---
import StatusBadge from "./StatusBadge.astro";
const items = [
{ status: "port", desc: "must be reimplemented on the target CMS" },
{ status: "built-in", desc: "covered by target CMS core / framework" },
{ status: "saas", desc: "replaced by an external service" },
{ status: "drop", desc: "not needed on the target CMS" },
{ status: "gated", desc: "fate depends on an unresolved decision" },
{ status: "done", desc: "ported and shipped" },
{ status: "proposed", desc: "submitted for cataloging" },
];
import { STATUSES } from "../lib/statuses";
---
<div class="legend">
{items.map((i) => (
<span class="item"><StatusBadge status={i.status} /> {i.desc}</span>
{STATUSES.map((i) => (
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
))}
</div>
+42
View File
@@ -0,0 +1,42 @@
// Types for the content collections defined in seed/seed.json.
//
// emdash's getEmDashCollection/getEmDashEntry resolve their entry `.data`
// shape from `InferCollectionData<T>`, which reads `EmDashCollections[T]` if
// this interface is augmented and otherwise falls back to
// `Record<string, unknown>`. Augmenting it here gives every page typed
// `entry.data`. Keep these field shapes in sync with seed/seed.json.
import "emdash";
// Fields emdash adds to every entry's `data` regardless of the seed schema.
// `id` is the database ULID (entry.id is the slug); used for API calls and
// cross-collection refs.
interface EmDashSystemFields {
id: string;
}
declare module "emdash" {
interface EmDashCollections {
cmses: EmDashSystemFields & {
title: string;
website?: string | null;
description?: string | null;
};
plugins: EmDashSystemFields & {
title: string;
purpose?: string | null;
source_cms: string;
target_cms?: string | null;
category?: string | null;
status: string;
source_repo_url?: string | null;
target_repo_url?: string | null;
notes?: import("emdash").PortableTextBlock[] | null;
};
pages: EmDashSystemFields & {
title: string;
content?: import("emdash").PortableTextBlock[] | null;
excerpt?: string | null;
};
}
}
+10 -4
View File
@@ -8,9 +8,10 @@ interface Props {
title: string;
description?: string | null;
content?: { collection: string; id: string; slug?: string | null };
breadcrumbs?: { name: string; url: string }[];
}
const { title, description, content } = Astro.props;
const { title, description, content, breadcrumbs } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title ?? "CMS plugins catalog";
const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity catalog";
@@ -18,15 +19,20 @@ const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity cat
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
const pageDescription = description ?? siteTagline;
const canonicalBase = Astro.site ?? new URL(Astro.url.origin);
const canonical = new URL(Astro.url.pathname, canonicalBase).href;
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "website",
pageType: content ? "article" : "website",
title: fullTitle,
pageTitle: title,
description: pageDescription,
content,
siteName: siteTitle,
canonical,
breadcrumbs,
});
---
@@ -36,10 +42,10 @@ const pageCtx = createPublicPageContext({
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<meta name="description" content={pageDescription} />
<EmDashHead page={pageCtx} />
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<EmDashBodyStart page={pageCtx} />
<header class="site">
<div class="container row">
@@ -51,7 +57,7 @@ const pageCtx = createPublicPageContext({
</nav>
</div>
</header>
<main>
<main id="main">
<div class="container">
<slot />
</div>
+19
View File
@@ -0,0 +1,19 @@
export interface StatusDef {
value: string;
label: string;
desc: string;
}
export const STATUSES: StatusDef[] = [
{ value: "port", label: "Port", desc: "must be reimplemented on the target CMS" },
{ value: "built-in", label: "Built-in", desc: "covered by target CMS core / framework" },
{ value: "saas", label: "SaaS", desc: "replaced by an external service" },
{ value: "drop", label: "Drop", desc: "not needed on the target CMS" },
{ value: "gated", label: "Gated", desc: "fate depends on an unresolved decision" },
{ value: "done", label: "Done", desc: "ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "submitted for cataloging" },
];
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
STATUSES.map((s) => [s.value, s.label]),
);
+1
View File
@@ -1,5 +1,6 @@
---
import Base from "../layouts/Base.astro";
Astro.response.status = 404;
---
<Base title="Not found">
<h1>Not found</h1>
+14 -5
View File
@@ -6,15 +6,24 @@ import Base from "../layouts/Base.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry, cacheHint } = await getEmDashEntry("pages", slug);
if (!entry) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const { entry, cacheHint } = slug
? await getEmDashEntry("pages", slug)
: { entry: null, cacheHint: undefined };
if (!slug || !entry) {
Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
---
{!entry ? (
<Base title="Not found">
<h1>Not found</h1>
<p>The page you're looking for doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base title={entry.data.title} description={entry.data.excerpt ?? undefined} content={{ collection: "pages", id: entry.data.id, slug: entry.id }}>
<article class="detail">
<h1>{entry.data.title}</h1>
{entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>}
</article>
</Base>
)}
+35 -15
View File
@@ -6,31 +6,51 @@ import PluginCard from "../../components/PluginCard.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry: cms, cacheHint } = slug
? await getEmDashEntry("cmses", slug)
: { entry: null, cacheHint: undefined };
if (!slug || !cms) {
Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
const { entry: cms, cacheHint } = await getEmDashEntry("cmses", slug);
if (!cms) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const { entries: all } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" },
limit: 9999,
});
const cmsName = cms.data.title as string;
const plugins = all.filter((p) => p.data.source_cms === cmsName);
const PLUGIN_FETCH_CAP = 10000;
const { entries: all } = cms
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
: { 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);
---
<Base title={cmsName} description={cms.data.description ?? undefined}>
{!cms ? (
<Base title="Not found">
<h1>Not found</h1>
<p>That CMS doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base title={cmsName} description={cms.data.description ?? undefined} breadcrumbs={[{ name: "By CMS", url: "/cms" }, { name: cmsName, url: Astro.url.pathname }]}>
<p class="crumbs"><a href="/cms">By CMS</a></p>
<h1>{cmsName}</h1>
{cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>}
{cms.data.description && <p>{cms.data.description}</p>}
<h2 style="margin-top:2rem">Plugins ({plugins.length})</h2>
{plugins.length === 0 ? (
<h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
{fromHere.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p>
) : (
<ul class="plugin-grid">
{plugins.map((entry) => <PluginCard entry={entry} />)}
{fromHere.map((entry) => <PluginCard entry={entry} />)}
</ul>
)}
{targetingHere.length > 0 && (
<>
<h2 class="section-gap">Plugins targeting this CMS ({targetingHere.length})</h2>
<ul class="plugin-grid">
{targetingHere.map((entry) => <PluginCard entry={entry} />)}
</ul>
</>
)}
</Base>
)}
+7 -2
View File
@@ -9,11 +9,16 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
});
Astro.cache.set(cacheHint);
const { entries: plugins } = await getEmDashCollection("plugins", { limit: 9999 });
const PLUGIN_FETCH_CAP = 10000;
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");
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);
const t = p.data.target_cms;
if (t) countByTarget.set(t, (countByTarget.get(t) ?? 0) + 1);
}
---
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
@@ -22,7 +27,7 @@ for (const p of plugins) {
{cmses.map((c) => (
<li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} plugins</p>
<p class="meta">{countBySource.get(c.data.title) ?? 0} from · {countByTarget.get(c.data.title) ?? 0} targeting</p>
{c.data.description && <p>{c.data.description}</p>}
</li>
))}
+9 -7
View File
@@ -3,6 +3,7 @@ import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro";
import { STATUSES } from "../lib/statuses";
export const prerender = false;
@@ -17,7 +18,8 @@ const statusFilter = url.searchParams.get("status") ?? "";
const sourceFilter = url.searchParams.get("source") ?? "";
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
const statuses = ["port", "built-in", "saas", "drop", "gated", "done", "proposed"];
const present = new Set(plugins.map((p) => p.data.status).filter(Boolean));
const statuses = STATUSES.filter((s) => present.has(s.value));
const filtered = plugins.filter((p) => {
const d = p.data;
@@ -29,22 +31,22 @@ const filtered = plugins.filter((p) => {
---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
<h1>Plugins</h1>
<p>{plugins.length} entries cataloged. Filter by status, source CMS, or search by name.</p>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} entries cataloged. Filter by status, source CMS, or search by name.`}</p>
<StatusLegend />
<form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} />
<select name="source">
<input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" />
<select name="source" aria-label="Filter by source CMS" onchange="this.form.submit()">
<option value="">All source CMSes</option>
{sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option>
))}
</select>
<select name="status">
<select name="status" aria-label="Filter by status" onchange="this.form.submit()">
<option value="">All statuses</option>
{statuses.map((s) => (
<option value={s} selected={s === statusFilter}>{s}</option>
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
))}
</select>
<button type="submit">Apply</button>
@@ -52,7 +54,7 @@ const filtered = plugins.filter((p) => {
</form>
{filtered.length === 0 ? (
<p class="empty">No plugins match the current filters.</p>
<p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p>
) : (
<ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)}
+28 -10
View File
@@ -1,5 +1,5 @@
---
import { getEmDashEntry, decodeSlug } from "emdash";
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import StatusBadge from "../../components/StatusBadge.astro";
@@ -7,27 +7,44 @@ import StatusBadge from "../../components/StatusBadge.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry, cacheHint } = slug
? await getEmDashEntry("plugins", slug)
: { entry: null, cacheHint: undefined };
if (!slug || !entry) {
Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
const { entry, cacheHint } = await getEmDashEntry("plugins", slug);
if (!entry) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const d = entry?.data;
const d = entry.data;
const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] };
const cmsSlugByTitle = new Map(cmses.map((c) => [c.data.title as string, c.id]));
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefined;
---
<Base title={d.title} description={d.purpose ?? undefined} content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}>
{!entry || !d ? (
<Base title="Not found">
<h1>Not found</h1>
<p>That plugin doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base
title={d.title}
description={d.purpose ?? undefined}
content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(sourceCmsSlug ? [{ name: d.source_cms, url: `/cms/${sourceCmsSlug}` }] : []), { name: d.title, url: Astro.url.pathname }]}
>
<article class="detail">
<header>
<p class="crumbs">
<a href="/">Plugins</a>
{d.source_cms && (<> · <a href={`/cms/${d.source_cms.toLowerCase()}`}>{d.source_cms}</a></>)}
{d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)}
</p>
<h1>{d.title}</h1>
<p>
<StatusBadge status={d.status} />
{d.source_cms && <span style="margin-left:.75rem;color:var(--c-muted)">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
{d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p>
{d.purpose && <p style="font-size:1.05rem">{d.purpose}</p>}
{d.purpose && <p class="lead">{d.purpose}</p>}
</header>
<dl>
@@ -46,3 +63,4 @@ const d = entry.data;
)}
</article>
</Base>
)}
+26 -9
View File
@@ -10,9 +10,9 @@
/* status palette */
--c-status-port: #2563eb;
--c-status-builtin: #16a34a;
--c-status-drop: #6b7280;
--c-status-saas: #d97706;
--c-status-builtin: #15803d;
--c-status-drop: #4b5563;
--c-status-saas: #b45309;
--c-status-gated: #b91c1c;
--c-status-done: #047857;
--c-status-proposed: #7c3aed;
@@ -23,8 +23,10 @@
--fs-h2: clamp(1.4rem, 3vw, 1.9rem);
--fs-h3: 1.25rem;
--fs-h4: 1.1rem;
--fs-card-title: 1.05rem;
--radius: 6px;
color-scheme: light;
}
* { box-sizing: border-box; }
@@ -49,6 +51,13 @@ h4 { font-size: var(--fs-h4); }
a { color: var(--c-link); text-decoration: none; }
a:hover { text-decoration: underline; }
:where(a, button, input, select, [tabindex]):focus-visible { outline: 2px solid var(--c-link); outline-offset: 2px; }
.skip-link { position: absolute; left: -9999px; top: 0; background: var(--c-link); color: #fff; padding: 0.5rem 0.9rem; border-radius: var(--radius); z-index: 10; }
.skip-link:focus { left: 0.5rem; top: 0.5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.container {
max-width: 1100px;
margin: 0 auto;
@@ -110,12 +119,15 @@ footer.site {
border-radius: var(--radius);
background: white;
}
.toolbar input[type="search"] { min-width: 240px; }
.toolbar input[type="search"] { flex: 1 1 240px; min-width: 0; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button:hover { filter: brightness(1.08); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; border-radius: var(--radius); }
.plugin-grid {
list-style: none; padding: 0; margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
gap: 1rem;
}
.plugin-card {
@@ -124,18 +136,23 @@ footer.site {
padding: 1rem 1.1rem;
background: var(--c-bg);
display: flex; flex-direction: column; gap: 0.5rem;
transition: border-color .15s, box-shadow .15s;
}
.plugin-card h3 { margin: 0; font-size: 1.05rem; }
.plugin-card:hover { border-color: var(--c-link); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.plugin-card h3 { margin: 0; font-size: var(--fs-card-title); }
.plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: 0.78rem; color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); }
.plugin-card .meta { font-size: var(--fs-sm); color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
.detail .crumbs { color: var(--c-muted); font-size: var(--fs-sm); margin-bottom: 0.25rem; }
.detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0 0 1.5rem; }
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; }
.detail dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail .lead { font-size: 1.05rem; }
.detail .source-target { margin-left: .75rem; color: var(--c-muted); }
.section-gap { margin-top: 2rem; }
.detail .notes { line-height: 1.65; }
.detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/base",
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"types": ["node"]
},