feat(landing-page): point catalog links at /plugins, drop legacy /skills /systems routes

The 2026-05 plugins rebuild left three homepage/library surfaces still
pointing at the retired `/skills/`, `/systems/`, and `/craft/` route
trees, so visitors hit a 301 hop (or a stale facet) instead of landing
directly on the new `/plugins/*` pages. Repoint them at the canonical
destinations and align the homepage Labs pills with the real library.

- system-card: link straight to `/plugins/design-system-<slug>/` via a
  new `detailHrefForSystemSlug` resolver instead of hard-coding
  `/systems/<slug>/` and relying on the redirect. The ~8 systems that
  ship no manifest (hence no detail page) degrade to `/plugins/systems/`,
  the same destination the legacy 301 produced, minus the hop and with no
  risk of linking at a page that doesn't exist.
- homepage Labs pills: replace the hard-coded prototype/deck/mobile/office
  facets (mobile/office had drifted to stale or empty counts) with a live
  top-4 of `PLUGIN_CATEGORIES`, counted with the same `categorizePlugin`
  rule `/plugins/templates/` uses and labelled from `pcopy.category`, so
  the homepage stays in lockstep with the library and never shows a dead
  chip. Counts are surfaced through a new `CatalogCounts.templateCategories`.
- remove the Craft entry points from the homepage footer, sub-page footer,
  header Library dropdown, and the plugins hub tile grid. The `/craft/`
  pages stay live; they're just no longer surfaced in site chrome.

The legacy `/skills/`, `/systems/`, `/templates/` 301s added in the prior
PR stay in place for inbound links and search equity.
This commit is contained in:
Joey-nexu 2026-05-31 19:45:54 +08:00
parent 53fb175855
commit b0b2067e5c
7 changed files with 106 additions and 43 deletions

View file

@ -229,15 +229,6 @@ export function Header({
<span className='dropdown-name'>{headerCopy.nav.systems}</span> <span className='dropdown-name'>{headerCopy.nav.systems}</span>
</a> </a>
</li> </li>
<li role='none'>
<a
role='menuitem'
href={href('/plugins/craft/')}
className={linkClass('craft')}
>
<span className='dropdown-name'>{headerCopy.nav.craft}</span>
</a>
</li>
</ul> </ul>
</li> </li>
<li> <li>

View file

@ -50,7 +50,6 @@ const X_TWITTER = 'https://x.com/nexudotio';
<li><a href={href('/plugins/templates/')}>{copy.nav.templates}</a></li> <li><a href={href('/plugins/templates/')}>{copy.nav.templates}</a></li>
<li><a href={href('/plugins/skills/')}>{copy.nav.skills}</a></li> <li><a href={href('/plugins/skills/')}>{copy.nav.skills}</a></li>
<li><a href={href('/plugins/systems/')}>{copy.nav.systems}</a></li> <li><a href={href('/plugins/systems/')}>{copy.nav.systems}</a></li>
<li><a href={href('/plugins/craft/')}>{copy.nav.craft}</a></li>
</ul> </ul>
</div> </div>
<div class='sub-footer-col'> <div class='sub-footer-col'>

View file

@ -3,14 +3,16 @@
* Shared system card used on `/plugins/systems/`. Displays palette * Shared system card used on `/plugins/systems/`. Displays palette
* swatches, name, category, and tagline as a clickable card. * swatches, name, category, and tagline as a clickable card.
* *
* The card links to `/systems/<slug>/`, which `public/_redirects` * The card links straight to the bundled-plugin detail page
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`) * (`/plugins/design-system-<slug>/`) for the 142 systems that ship a
* for the 142 systems that have one, and degrades the 8 without a * manifest, and degrades to the `/plugins/systems/` index for the ~8
* detail page to `/plugins/systems/`. Linking through the redirect * that don't. `detailHrefForSystemSlug` resolves that mapping against
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from * the real detail-page set so we never link at a page that doesn't
* pointing at a non-existent detail page. * exist — same destinations the legacy `/systems/<slug>/` 301s produced,
* minus the redirect hop.
*/ */
import type { SystemRecord } from '../_lib/catalog'; import type { SystemRecord } from '../_lib/catalog';
import { detailHrefForSystemSlug } from '../_lib/bundled-plugins';
import { localeFromPath, localizedHref } from '../i18n'; import { localeFromPath, localizedHref } from '../i18n';
export interface Props { export interface Props {
@ -20,10 +22,11 @@ export interface Props {
const { system } = Astro.props; const { system } = Astro.props;
const locale = localeFromPath(Astro.url.pathname); const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale); const href = (path: string) => localizedHref(path, locale);
const detailHref = detailHrefForSystemSlug(system.slug) ?? '/plugins/systems/';
--- ---
<li class="system-card"> <li class="system-card">
<a href={href(`/systems/${system.slug}/`)}> <a href={href(detailHref)}>
<div class="system-swatches" aria-hidden="true"> <div class="system-swatches" aria-hidden="true">
{system.palette.length > 0 ? ( {system.palette.length > 0 ? (
system.palette.slice(0, 4).map((hex) => ( system.palette.slice(0, 4).map((hex) => (

View file

@ -461,3 +461,18 @@ export function getBundledPluginById(
): BundledPluginRecord | null { ): BundledPluginRecord | null {
return getBundledPlugins().find((p) => p.manifestId === manifestId) ?? null; return getBundledPlugins().find((p) => p.manifestId === manifestId) ?? null;
} }
// SystemRecord (from the `design-systems/` content collection) and the
// design-system detail pages (from `plugins/_official/design-systems/`)
// overlap on the folder name but not on the URL: a system folder `stripe`
// becomes detail page `/plugins/design-system-stripe/`. ~8 of the ~150
// systems ship no manifest, so they have no detail page. Resolve the link
// here: callers link straight to the detail page when one exists, and
// degrade to the `/plugins/systems/` index otherwise — same outcome the
// `/systems/<slug>/` 301 redirects produce, but without the extra hop.
export function detailHrefForSystemSlug(folderSlug: string): string | null {
const match = getDetailPlugins().find(
(p) => p.bucket === 'design-systems' && p.slug === folderSlug,
);
return match?.detailHref ?? null;
}

View file

@ -24,6 +24,13 @@ import {
localizeTaxonomyValue, localizeTaxonomyValue,
localizeTemplateText, localizeTemplateText,
} from '../content-i18n'; } from '../content-i18n';
import { getBundledPlugins } from './bundled-plugins';
import {
bundledRecordOf,
categorizePlugin,
PLUGIN_CATEGORIES,
type PluginCategorySlug,
} from './plugin-facets';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Preview imagery lookup // Preview imagery lookup
@ -850,6 +857,41 @@ export interface CatalogCounts {
byMode: Readonly<Record<string, number>>; byMode: Readonly<Record<string, number>>;
/** SKILL.md `od.platform` → count. Lowercase keys (e.g. `mobile`, `desktop`). */ /** SKILL.md `od.platform` → count. Lowercase keys (e.g. `mobile`, `desktop`). */
byPlatform: Readonly<Record<string, number>>; byPlatform: Readonly<Record<string, number>>;
/**
* Live `PLUGIN_CATEGORIES` breakdown for the `/plugins/templates/`
* library, computed with the same `categorizePlugin` rule the
* templates page uses so the homepage Labs pills never drift from
* the real catalog. Ordered by count descending, zero-count
* categories dropped; `total` is the count of all categorized
* templates (the "All" pill).
*/
templateCategories: {
total: number;
byCategory: ReadonlyArray<{ slug: PluginCategorySlug; count: number }>;
};
}
// Templates view = bundled plugins that land in one of the
// PLUGIN_CATEGORIES artifact kinds (categorizePlugin !== null). Mirrors
// the count the `/plugins/templates/` page derives so the homepage Labs
// pills stay in lockstep with the library. Locale-independent (counts
// don't vary by language), so it ignores the locale arg.
function computeTemplateCategories(): CatalogCounts['templateCategories'] {
const counts = new Map<PluginCategorySlug, number>();
let total = 0;
for (const record of getBundledPlugins()) {
const category = categorizePlugin(bundledRecordOf(record));
if (!category) continue;
total += 1;
counts.set(category, (counts.get(category) ?? 0) + 1);
}
const byCategory = PLUGIN_CATEGORIES.map((cat) => ({
slug: cat.slug,
count: counts.get(cat.slug) ?? 0,
}))
.filter((c) => c.count > 0)
.sort((a, b) => b.count - a.count);
return { total, byCategory };
} }
const catalogCountsCache = new Map<LandingLocaleCode, Promise<CatalogCounts>>(); const catalogCountsCache = new Map<LandingLocaleCode, Promise<CatalogCounts>>();
@ -881,6 +923,7 @@ export async function getCatalogCounts(
craft: craft.length, craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)), byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)), byPlatform: tallyKey(skills.map((s) => s.platform)),
templateCategories: computeTemplateCategories(),
}; };
} }
@ -903,6 +946,7 @@ export async function getCatalogCounts(
craft: craft.length, craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)), byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)), byPlatform: tallyKey(skills.map((s) => s.platform)),
templateCategories: computeTemplateCategories(),
}; };
})(); })();

View file

@ -27,6 +27,7 @@ import {
imageAsset, imageAsset,
PRECISE_LAZY_PLACEHOLDER, PRECISE_LAZY_PLACEHOLDER,
} from './image-assets'; } from './image-assets';
import { getPluginsCopy } from './_lib/plugins-i18n';
/** /**
* `<img>` wrapper for non-hero homepage images. Outputs `data-precise-src` * `<img>` wrapper for non-hero homepage images. Outputs `data-precise-src`
@ -162,6 +163,14 @@ interface PageProps {
/** Optional richer breakdown used by the Labs filter pills. */ /** Optional richer breakdown used by the Labs filter pills. */
byMode?: Readonly<Record<string, number>>; byMode?: Readonly<Record<string, number>>;
byPlatform?: Readonly<Record<string, number>>; byPlatform?: Readonly<Record<string, number>>;
/**
* Live `/plugins/templates/` category breakdown driving the Labs
* pills. Ordered by count desc, zero-count categories dropped.
*/
templateCategories?: {
total: number;
byCategory: ReadonlyArray<{ slug: string; count: number }>;
};
}; };
github: { github: {
starsLabel: string; starsLabel: string;
@ -201,11 +210,21 @@ export default function Page({
}: PageProps) { }: PageProps) {
const skills = fmt(counts.skills); const skills = fmt(counts.skills);
const systems = fmt(counts.systems); const systems = fmt(counts.systems);
const deckCount = pad2(counts.byMode?.deck);
const prototypeCount = pad2(counts.byMode?.prototype);
const mobileCount = pad2(counts.byPlatform?.mobile);
const commonCopy = getCommonCopy(locale); const commonCopy = getCommonCopy(locale);
const home = getHomePageCopy(locale); const home = getHomePageCopy(locale);
const pcopy = getPluginsCopy(locale);
// Labs pills mirror the live `/plugins/templates/` category strip: an
// "All" chip plus the top categories by count, labelled and counted
// from the same source so the homepage never drifts from the library.
const templateCategories = counts.templateCategories;
const labsPills = templateCategories
? templateCategories.byCategory.slice(0, 4).map((c) => ({
slug: c.slug,
label:
pcopy.category[c.slug as keyof typeof pcopy.category]?.label ?? c.slug,
count: pad2(c.count),
}))
: [];
const localeDef = getLocaleDefinition(locale); const localeDef = getLocaleDefinition(locale);
const localeOptions = LANDING_LOCALES.map((entry) => ({ const localeOptions = LANDING_LOCALES.map((entry) => ({
...entry, ...entry,
@ -730,26 +749,22 @@ export default function Page({
</h2> </h2>
</div> </div>
<div className='pills' data-reveal='right'> <div className='pills' data-reveal='right'>
<a className='pill active' href={href('/plugins/skills/')}> <a className='pill active' href={href('/plugins/templates/')}>
{home.labs.pills.all} {pcopy.allChip}
<span className='count'>{skills}</span> <span className='count'>
</a> {templateCategories ? fmt(templateCategories.total) : skills}
<a className='pill' href={href('/plugins/templates/')}> </span>
{home.labs.pills.prototype}
<span className='count'>{prototypeCount}</span>
</a>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.deck}
<span className='count'>{deckCount}</span>
</a>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.mobile}
<span className='count'>{mobileCount}</span>
</a>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.office}
<span className='count'></span>
</a> </a>
{labsPills.map((pill) => (
<a
key={pill.slug}
className='pill'
href={href(`/plugins/templates/${pill.slug}/`)}
>
{pill.label}
<span className='count'>{pill.count}</span>
</a>
))}
</div> </div>
</div> </div>
<div className='labs-meta'> <div className='labs-meta'>
@ -1339,9 +1354,6 @@ export default function Page({
{home.footer.libraryLinks.templates} {home.footer.libraryLinks.templates}
</a> </a>
</li> </li>
<li>
<a href={href('/craft/')}>{home.footer.libraryLinks.craft}</a>
</li>
{/* {/*
* Sister product: HTML Anything is the agent-driven HTML * Sister product: HTML Anything is the agent-driven HTML
* editor from the same team. Listed here as a peer to the * editor from the same team. Listed here as a peer to the

View file

@ -74,7 +74,6 @@ const tiles = [
{ href: href('/plugins/templates/'), title: pcopy.tileTemplates, count: templatesCount, blurb: pcopy.tileTemplatesBlurb, cta: pcopy.browseTemplates }, { href: href('/plugins/templates/'), title: pcopy.tileTemplates, count: templatesCount, blurb: pcopy.tileTemplatesBlurb, cta: pcopy.browseTemplates },
{ href: href('/plugins/skills/'), title: pcopy.tileSkills, count: skillsCount, blurb: pcopy.tileSkillsBlurb, cta: pcopy.browseSkills }, { href: href('/plugins/skills/'), title: pcopy.tileSkills, count: skillsCount, blurb: pcopy.tileSkillsBlurb, cta: pcopy.browseSkills },
{ href: href('/plugins/systems/'), title: pcopy.tileSystems, count: systemsCount, blurb: pcopy.tileSystemsBlurb, cta: pcopy.browseSystems }, { href: href('/plugins/systems/'), title: pcopy.tileSystems, count: systemsCount, blurb: pcopy.tileSystemsBlurb, cta: pcopy.browseSystems },
{ href: href('/plugins/craft/'), title: pcopy.tileCraft, count: craftCount, blurb: pcopy.tileCraftBlurb, cta: pcopy.browseCraft },
]; ];
--- ---