open-design/apps/landing-page/app/_lib/catalog.ts
Jane 3e0ff3d0fa
feat(landing-page): point catalog links at /plugins, drop legacy /skills /systems routes (#3386)
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.

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
2026-05-31 11:53:36 +00:00

1160 lines
38 KiB
TypeScript

// Catalog data layer — turns raw Markdown bundles loaded by Astro
// Content Collections (the `SKILL.md`, `DESIGN.md`, `*.md` craft files,
// and Live Artifact `README.md` bundles in the repo root) into the
// shaped records the index and detail pages render.
//
// Why this lives in `_lib/` and not in the page files: every page
// imports from one place, so the parsing rules (folder-name slug,
// description fallback, palette extraction, etc.) stay consistent.
import { getCollection, type CollectionEntry } from 'astro:content';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import {
DEFAULT_LOCALE,
type LandingLocaleCode,
type LocalizedStringValue,
} from '../i18n';
import {
explicitLocalizedString,
localizeCraftText,
localizeSkillDescription,
localizeSystemText,
localizeTaxonomyValue,
localizeTemplateText,
} from '../content-i18n';
import { getBundledPlugins } from './bundled-plugins';
import {
bundledRecordOf,
categorizePlugin,
PLUGIN_CATEGORIES,
type PluginCategorySlug,
} from './plugin-facets';
// ---------------------------------------------------------------------------
// Preview imagery lookup
//
// Previews are produced offline by `pnpm --filter @open-design/landing-page
// previews` and saved under `public/previews/<bucket>/<slug>.png`. We read
// the directory listing once at build time so each catalog record can carry
// a `previewUrl` (or `null` when the underlying skill has no `example.html`).
// ---------------------------------------------------------------------------
const PREVIEWS_ROOT_CANDIDATES = [
// `pnpm --filter @open-design/landing-page build` may keep cwd at the
// workspace root, while direct package scripts run from the app root.
path.resolve(process.cwd(), 'apps/landing-page/public/previews'),
path.resolve(process.cwd(), 'public/previews'),
// Keep the source-relative path as a final fallback for local dev.
path.resolve(fileURLToPath(new URL('../../public/previews', import.meta.url))),
] as const;
function previewRoot(): string | null {
return PREVIEWS_ROOT_CANDIDATES.find((dir) => existsSync(dir)) ?? null;
}
/**
* Map of `slug → filename`, e.g. `'kami-deck' → 'kami-deck.webp'`.
*
* We track the actual on-disk filename (with its real extension) so the
* generated `<img src>` URL never lies about the format. Earlier this
* was a `Set<string>` and `previewUrlFor()` always emitted `.png`,
* which 404'd whenever the previews step produced `.webp`/`.jpg`/`.jpeg`
* (e.g., after a future sharp post-processor or a manually committed
* template asset).
*/
function listPreviews(bucket: 'skills' | 'systems' | 'templates'): Map<string, string> {
const root = previewRoot();
if (!root) return new Map();
const dir = path.join(root, bucket);
if (!existsSync(dir)) return new Map();
const map = new Map<string, string>();
for (const file of readdirSync(dir)) {
const m = /^(.+)\.(png|webp|jpg|jpeg)$/i.exec(file);
if (m && m[1]) {
// First match wins. If two files exist with the same slug but
// different extensions, prefer the one that sorts earlier (PNG
// before WebP in alphabetical order) for deterministic output.
if (!map.has(m[1])) map.set(m[1], file);
}
}
return map;
}
function previewUrlFor(
bucket: 'skills' | 'systems' | 'templates',
slug: string,
available: Map<string, string>,
): string | null {
const filename = available.get(slug);
return filename ? `/previews/${bucket}/${filename}` : null;
}
const SKILLS_SRC_CANDIDATES = [
// Same dual-cwd story as PREVIEWS_ROOT_CANDIDATES.
path.resolve(process.cwd(), 'skills'),
path.resolve(process.cwd(), '../../skills'),
path.resolve(fileURLToPath(new URL('../../../../skills', import.meta.url))),
] as const;
function skillsSourceRoot(): string | null {
return SKILLS_SRC_CANDIDATES.find((dir) => existsSync(dir)) ?? null;
}
/**
* Slugs whose folder ships a runnable `example.html`. We treat that as
* the canonical signal that a skill is template-flavoured (a real
* static demo we can iframe / screenshot) rather than instruction-only
* (pure SKILL.md prose).
*
* Read once per build so the per-record `shapeSkill()` call stays O(1).
*/
function listSkillExamples(): Set<string> {
const root = skillsSourceRoot();
if (!root) return new Set();
const out = new Set<string>();
for (const name of readdirSync(root)) {
if (name.startsWith('_') || name.startsWith('.')) continue;
const example = path.join(root, name, 'example.html');
if (existsSync(example)) out.add(name);
}
return out;
}
const REPO_TREE = 'https://github.com/nexu-io/open-design/tree/main';
const REPO_BLOB = 'https://github.com/nexu-io/open-design/blob/main';
const SHOULD_CACHE_CATALOG = import.meta.env.PROD;
// ---------------------------------------------------------------------------
// Skills
// ---------------------------------------------------------------------------
export type SkillEntry = CollectionEntry<'skills'>;
/**
* Two flavours of skill share the same SKILL.md schema and the same
* /skills/<slug>/ detail route, but differ in how they're presented:
*
* - `template` — ships a runnable `example.html`. The detail page
* exposes a click-to-expand iframe of the demo, and the catalog
* row uses a real screenshot as its thumbnail.
*
* - `instruction` — pure SKILL.md (e.g. `copywriting`,
* `creative-director`). The "demo" depends on the agent's input,
* so there's nothing static to iframe. The detail page hides the
* preview block and surfaces the full SKILL.md body instead, and
* the catalog row uses a typographic fallback card as its thumb.
*
* Catalog routing splits on this field: `/skills/templates/` and
* `/skills/instructions/` filter to one kind each; `/skills/` itself
* shows both as separate sections.
*/
export type SkillKind = 'instruction' | 'template';
export interface SkillRecord {
slug: string;
name: string;
description: string;
triggers: ReadonlyArray<string>;
mode?: string;
modeLabel?: string;
platform?: string;
platformLabel?: string;
scenario?: string;
scenarioLabel?: string;
category?: string;
categoryLabel?: string;
featured?: number;
upstream?: string;
examplePrompt?: string;
source: string;
body: string;
kind: SkillKind;
/** `/previews/skills/<slug>.png` if a generated preview exists, else null. */
previewUrl: string | null;
}
const skillRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<SkillRecord>>>();
function deriveSkillSlug(id: string): string {
// `id` is `[folder]/SKILL` (no extension). We want the folder name.
const folder = id.split('/')[0] ?? id;
return folder;
}
function firstParagraph(text: string | undefined, fallback = ''): string {
if (!text) return fallback;
return text.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? fallback;
}
export function shapeSkill(
entry: SkillEntry,
previews: Map<string, string>,
examples: Set<string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): SkillRecord {
const slug = deriveSkillSlug(entry.id);
const data = entry.data as {
name?: LocalizedStringValue;
description?: LocalizedStringValue;
triggers?: string[];
i18n?: Record<string, {
name?: string;
description?: string;
triggers?: string[];
examplePrompt?: string;
example_prompt?: string;
}>;
od?: {
mode?: string;
platform?: string;
scenario?: string;
category?: string;
featured?: number;
upstream?: string;
example_prompt?: LocalizedStringValue;
};
};
const localized = data.i18n?.[locale];
const name = explicitLocalizedString(localized?.name ?? data.name, locale) ?? slug;
const rawDescription = explicitLocalizedString(data.description, DEFAULT_LOCALE) ?? '';
const description =
explicitLocalizedString(localized?.description ?? data.description, locale) ??
localizeSkillDescription({
name,
mode: data.od?.mode,
scenario: data.od?.scenario,
category: data.od?.category,
locale,
fallback: rawDescription,
});
const examplePrompt = explicitLocalizedString(
localized?.examplePrompt ?? localized?.example_prompt ?? data.od?.example_prompt,
locale,
) ?? '';
return {
slug,
name,
description,
triggers: localized?.triggers ?? (locale === DEFAULT_LOCALE ? data.triggers ?? [] : []),
mode: data.od?.mode,
modeLabel: localizeTaxonomyValue(data.od?.mode, locale),
platform: data.od?.platform,
platformLabel: localizeTaxonomyValue(data.od?.platform, locale),
scenario: data.od?.scenario,
scenarioLabel: localizeTaxonomyValue(data.od?.scenario, locale),
category: data.od?.category,
categoryLabel: localizeTaxonomyValue(data.od?.category, locale),
featured: data.od?.featured,
upstream: data.od?.upstream,
examplePrompt,
source: `${REPO_TREE}/skills/${slug}`,
body: entry.body ?? '',
kind: examples.has(slug) ? 'template' : 'instruction',
previewUrl: previewUrlFor('skills', slug, previews),
};
}
export async function getSkillRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<SkillRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const previews = listPreviews('skills');
const examples = listSkillExamples();
const entries = await getCollection('skills');
const shaped = entries.map((entry) => shapeSkill(entry, previews, examples, locale));
return shaped.sort((a, b) => {
// Featured (lower number = higher priority) first, then alphabetical.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
return a.name.localeCompare(b.name);
});
}
const cached = skillRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const previews = listPreviews('skills');
const examples = listSkillExamples();
const entries = await getCollection('skills');
const shaped = entries.map((entry) => shapeSkill(entry, previews, examples, locale));
return shaped.sort((a, b) => {
// Featured (lower number = higher priority) first, then alphabetical.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
return a.name.localeCompare(b.name);
});
})();
skillRecordsCache.set(locale, promise);
return promise;
}
/**
* Filter helper for kind-specific catalog routes (`/plugins/templates/`,
* `/plugins/skills/`). Caller gets the records already sorted by the
* standard catalog rules.
*/
export async function getSkillRecordsByKind(
kind: SkillKind,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<SkillRecord>> {
const all = await getSkillRecords(locale);
return all.filter((s) => s.kind === kind);
}
// ---------------------------------------------------------------------------
// Design Systems
// ---------------------------------------------------------------------------
export type SystemEntry = CollectionEntry<'systems'>;
export interface SystemRecord {
slug: string;
name: string;
category: string;
categoryLabel: string;
tagline: string;
atmosphere: string;
palette: ReadonlyArray<string>;
source: string;
body: string;
}
const systemRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<SystemRecord>>>();
function extractH1(body: string): string | undefined {
for (const line of body.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('# ')) return trimmed.slice(2).trim();
}
return undefined;
}
function extractCategoryBlock(body: string): { category: string; tagline: string } {
// Convention: a `> Category:` blockquote, optionally followed by extra
// tagline lines also prefixed with `>`.
const lines = body.split('\n');
let category = '';
const taglineLines: string[] = [];
let inBlock = false;
for (const raw of lines) {
const line = raw.trim();
if (!inBlock) {
const m = /^>\s*Category:\s*(.+)$/i.exec(line);
if (m && m[1]) {
category = m[1].trim();
inBlock = true;
continue;
}
} else {
if (line.startsWith('>')) {
const text = line.replace(/^>\s?/, '').trim();
if (text.length > 0) taglineLines.push(text);
} else if (line.length === 0 && taglineLines.length === 0) {
// tolerate a single blank line between Category and tagline
} else {
break;
}
}
}
return { category, tagline: taglineLines.join(' ').trim() };
}
function extractAtmosphere(body: string): string {
// Take the first paragraph of the first H2 section that looks like
// "Visual Theme & Atmosphere" (or any first paragraph after `## 1.`).
const lines = body.split('\n');
let inSection = false;
const buf: string[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!inSection) {
if (/^##\s+1\./.test(raw) || /^##\s+.*Atmosphere/i.test(raw)) {
inSection = true;
}
continue;
}
if (line.startsWith('##')) break;
if (line.length === 0 && buf.length > 0) break;
if (line.length === 0) continue;
buf.push(line);
}
return buf.join(' ').trim();
}
const HEX_RE = /#[0-9a-fA-F]{6}\b/g;
function extractPalette(body: string, limit = 5): ReadonlyArray<string> {
const seen = new Set<string>();
const matches = body.match(HEX_RE) ?? [];
for (const hex of matches) {
seen.add(hex.toLowerCase());
if (seen.size >= limit) break;
}
return Array.from(seen);
}
export function shapeSystem(
entry: SystemEntry,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): SystemRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
category?: string;
tagline?: string;
atmosphere?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body) ?? slug;
const { category, tagline } = extractCategoryBlock(body);
const atmosphere = extractAtmosphere(body);
const palette = extractPalette(body);
const name =
localized?.name ??
(h1.replace(/^Design System Inspired by\s+/i, '').trim() || slug);
const rawCategory = localized?.category ?? (category || 'Uncategorized');
const localizedText = localizeSystemText({
name,
category: rawCategory,
paletteCount: palette.length,
locale,
fallbackTagline: localized?.tagline ?? tagline,
fallbackAtmosphere: localized?.atmosphere ?? atmosphere,
});
return {
slug,
name,
category: rawCategory,
categoryLabel: localizedText.category,
tagline: localizedText.tagline,
atmosphere: localizedText.atmosphere,
palette,
source: `${REPO_TREE}/design-systems/${slug}`,
body,
};
}
export async function getSystemRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<SystemRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const entries = await getCollection('systems');
return entries
.map((entry) => shapeSystem(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
}
const cached = systemRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const entries = await getCollection('systems');
return entries
.map((entry) => shapeSystem(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
})();
systemRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Craft
// ---------------------------------------------------------------------------
export type CraftEntry = CollectionEntry<'craft'>;
export interface CraftRecord {
slug: string;
name: string;
summary: string;
source: string;
body: string;
}
const craftRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<CraftRecord>>>();
const CRAFT_NAME_OVERRIDES: Record<string, string> = {
'rtl-and-bidi': 'RTL & Bidi',
};
function titleizeSlug(slug: string): string {
const override = CRAFT_NAME_OVERRIDES[slug];
if (override) return override;
return slug
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
// ---------------------------------------------------------------------------
// Markdown → display-text helpers
//
// Live-artifact READMEs and craft `*.md` files mix prose with editorial
// metadata blocks (`> Category: …`, `> Family: …`) and decorative
// inline syntax (backticks around slugs in the H1, asterisks for
// emphasis). When we surface them as page titles, card descriptions,
// or `<meta name="description">`, we want clean text — never raw
// Markdown noise like `\`otd-operations-brief\` · live-artifact template`
// or a literal `>` as the entire summary.
// ---------------------------------------------------------------------------
/** Strip backticks, leading/trailing emphasis, link wrappers, soft breaks. */
function stripMarkdownInline(text: string): string {
return text
.replace(/`([^`]+)`/g, '$1') // `code` → code
.replace(/\*\*([^*]+)\*\*/g, '$1') // **bold** → bold
.replace(/\*([^*]+)\*/g, '$1') // *italic* → italic
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/\s+/g, ' ')
.trim();
}
/**
* First plain-prose paragraph after the H1, with all leading
* blockquote / list / fenced-code / horizontal-rule lines skipped.
*
* The "first paragraph" definition: a contiguous run of non-empty
* lines that aren't headings, blockquotes, list markers, table rows,
* code fences, or HR rules. Returns the empty string if no such
* paragraph exists, leaving the caller to apply its own fallback.
*/
function extractFirstProseParagraph(body: string): string {
const lines = body.split('\n');
let pastH1 = false;
let inFence = false;
const buf: string[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!pastH1) {
if (line.startsWith('# ')) pastH1 = true;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Section break.
if (line.startsWith('#')) break;
if (line.length === 0) {
if (buf.length > 0) break;
continue;
}
// Skip editorial metadata blocks until we find real prose. Authors
// commonly stack `> Category:`, `> Family:`, `> Style:` lines under
// the H1 — they're meaningful in the README but useless as a card
// summary or SEO snippet.
if (line.startsWith('>')) continue;
// Skip lists / table rows / horizontal rules with the same logic.
if (/^([-*+]\s|\d+\.\s|\||---+$|\*\*\*+$|___+$)/.test(line)) {
if (buf.length > 0) break;
continue;
}
buf.push(line);
}
return stripMarkdownInline(buf.join(' '));
}
export function shapeCraft(
entry: CraftEntry,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): CraftRecord {
const slug = entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
summary?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body);
const cleanH1 = h1 ? stripMarkdownInline(h1).replace(/\s+craft rules?$/i, '').trim() : '';
const fallbackName = localized?.name ?? (cleanH1 || titleizeSlug(slug));
const fallbackSummary = localized?.summary ?? extractFirstProseParagraph(body);
const localizedText = localizeCraftText({
slug,
name: fallbackName,
summary: fallbackSummary,
locale,
});
return {
slug,
name: localizedText.name,
summary: localizedText.summary,
source: `${REPO_BLOB}/craft/${slug}.md`,
body,
};
}
export async function getCraftRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<CraftRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const entries = await getCollection('craft');
// Astro normalizes the entry id from `craft/README.md` to `readme`
// (lowercase, extension stripped). Comparing the raw `'README'` string
// misses it on disk and used to ship `/craft/readme/` as a public
// craft principle and inflate the nav count by one. Compare
// case-insensitively so future README casings (`Readme.md`, etc.) are
// also filtered out.
return entries
.filter((e) => e.id.toLowerCase() !== 'readme')
.map((entry) => shapeCraft(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
}
const cached = craftRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const entries = await getCollection('craft');
// Astro normalizes the entry id from `craft/README.md` to `readme`
// (lowercase, extension stripped). Comparing the raw `'README'` string
// misses it on disk and used to ship `/craft/readme/` as a public
// craft principle and inflate the nav count by one. Compare
// case-insensitively so future README casings (`Readme.md`, etc.) are
// also filtered out.
return entries
.filter((e) => e.id.toLowerCase() !== 'readme')
.map((entry) => shapeCraft(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
})();
craftRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Templates — renderable design templates + legacy Live Artifacts
// ---------------------------------------------------------------------------
export interface TemplateRecord {
slug: string;
name: string;
summary: string;
origin: 'design-template' | 'live-artifact';
mode?: string;
modeLabel?: string;
platform?: string;
platformLabel?: string;
scenario?: string;
scenarioLabel?: string;
featured?: number;
source: string;
detailHref: string;
/** Skill body / template README body (Markdown). */
body: string;
previewUrl: string | null;
}
const templateRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<TemplateRecord>>>();
export type TemplateEntry = CollectionEntry<'templates'>;
export type DesignTemplateEntry = CollectionEntry<'designTemplates'>;
export function shapeDesignTemplate(
entry: DesignTemplateEntry,
previews: Map<string, string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): TemplateRecord {
const slug = deriveSkillSlug(entry.id);
const data = entry.data as {
name?: LocalizedStringValue;
description?: LocalizedStringValue;
i18n?: Record<string, {
name?: string;
description?: string;
summary?: string;
}>;
od?: {
mode?: string;
platform?: string;
scenario?: string;
featured?: number;
};
};
const body = entry.body ?? '';
const localized = data.i18n?.[locale];
const name =
explicitLocalizedString(localized?.name ?? data.name, locale) ?? titleizeSlug(slug);
const summary =
explicitLocalizedString(
localized?.summary ?? localized?.description ?? data.description,
locale,
) ||
firstParagraph(explicitLocalizedString(data.description, DEFAULT_LOCALE)) ||
extractFirstProseParagraph(body) ||
'Open Design renderable design template.';
const localizedText = localizeTemplateText({ name, summary, locale });
return {
slug,
name: localizedText.name,
summary: localizedText.summary,
origin: 'design-template',
mode: data.od?.mode,
modeLabel: localizeTaxonomyValue(data.od?.mode, locale),
platform: data.od?.platform,
platformLabel: localizeTaxonomyValue(data.od?.platform, locale),
scenario: data.od?.scenario,
scenarioLabel: localizeTaxonomyValue(data.od?.scenario, locale),
featured: data.od?.featured,
source: `${REPO_TREE}/design-templates/${slug}`,
detailHref: `/templates/${slug}/`,
body,
previewUrl: previewUrlFor('templates', slug, previews),
};
}
export function shapeLiveArtifactTemplate(
entry: TemplateEntry,
previews: Map<string, string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): TemplateRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
summary?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body);
// Some authors write `# \`otd-operations-brief\` · live-artifact template`
// — strip the inline backticks/asterisks and drop the trailing
// `· live-artifact template` boilerplate so card titles read like
// human prose ("otd-operations-brief") instead of raw Markdown.
let cleanH1 = h1 ? stripMarkdownInline(h1) : '';
cleanH1 = cleanH1
.replace(/\s*[·•]\s*live[\s-]artifact\s+template$/i, '')
.trim();
const summary = extractFirstProseParagraph(body) || 'Open Design Live Artifact template.';
const localizedText = localizeTemplateText({
name: localized?.name ?? (cleanH1 || titleizeSlug(slug)),
summary: localized?.summary ?? summary,
locale,
});
const liveSlug = `live-${slug}`;
return {
slug: liveSlug,
name: localizedText.name,
summary: localizedText.summary,
origin: 'live-artifact',
mode: 'template',
modeLabel: localizeTaxonomyValue('template', locale),
scenario: 'live-artifacts',
scenarioLabel: localizeTaxonomyValue('live-artifacts', locale),
source: `${REPO_TREE}/templates/live-artifacts/${slug}`,
detailHref: `/templates/${liveSlug}/`,
body,
previewUrl: previewUrlFor('templates', liveSlug, previews),
};
}
export async function getTemplateRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TemplateRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const previews = listPreviews('templates');
const designEntries = await getCollection('designTemplates');
const designRecords = designEntries.map((entry) =>
shapeDesignTemplate(entry, previews, locale),
);
const liveEntries = await getCollection('templates');
const liveRecords = liveEntries.map((entry) =>
shapeLiveArtifactTemplate(entry, previews, locale),
);
return [...designRecords, ...liveRecords].sort((a, b) => {
// Keep explicitly featured templates first, then group the canonical
// design-template catalogue ahead of legacy live-artifact shims.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
if (a.origin !== b.origin) return a.origin === 'design-template' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
const cached = templateRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const previews = listPreviews('templates');
const designEntries = await getCollection('designTemplates');
const designRecords = designEntries.map((entry) =>
shapeDesignTemplate(entry, previews, locale),
);
const liveEntries = await getCollection('templates');
const liveRecords = liveEntries.map((entry) =>
shapeLiveArtifactTemplate(entry, previews, locale),
);
return [...designRecords, ...liveRecords].sort((a, b) => {
// Keep explicitly featured templates first, then group the canonical
// design-template catalogue ahead of legacy live-artifact shims.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
if (a.origin !== b.origin) return a.origin === 'design-template' ? -1 : 1;
return a.name.localeCompare(b.name);
});
})();
templateRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Counts
//
// `getCatalogCounts()` is the canonical numbers source for the homepage
// (hero stat rings, hero lead, capabilities cards, footer Library) and
// the nav badges. Anything in `app/page.tsx` that talks about catalog
// size MUST read from here — never hardcode. The `byMode` and
// `byPlatform` breakdowns power the `Labs` filter pills so they stay
// in sync with `od.mode` / `od.platform` across the SKILL.md corpus.
// ---------------------------------------------------------------------------
export interface CatalogCounts {
skills: number;
systems: number;
templates: number;
craft: number;
/** SKILL.md `od.mode` → count. Lowercase keys (e.g. `deck`, `prototype`). */
byMode: Readonly<Record<string, number>>;
/** SKILL.md `od.platform` → count. Lowercase keys (e.g. `mobile`, `desktop`). */
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>>();
function tallyKey(values: Iterable<string | undefined>): Record<string, number> {
const out: Record<string, number> = {};
for (const v of values) {
if (!v) continue;
const k = v.toLowerCase();
out[k] = (out[k] ?? 0) + 1;
}
return out;
}
export async function getCatalogCounts(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<CatalogCounts> {
if (!SHOULD_CACHE_CATALOG) {
const [skills, systems, templates, craft] = await Promise.all([
getSkillRecords(locale),
getSystemRecords(locale),
getTemplateRecords(locale),
getCraftRecords(locale),
]);
return {
skills: skills.length,
systems: systems.length,
templates: templates.length,
craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)),
templateCategories: computeTemplateCategories(),
};
}
const cached = catalogCountsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const [skills, systems, templates, craft] = await Promise.all([
getSkillRecords(locale),
getSystemRecords(locale),
getTemplateRecords(locale),
getCraftRecords(locale),
]);
return {
skills: skills.length,
systems: systems.length,
templates: templates.length,
craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)),
templateCategories: computeTemplateCategories(),
};
})();
catalogCountsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function uniq<T>(values: ReadonlyArray<T>): ReadonlyArray<T> {
return Array.from(new Set(values));
}
export function tally<T extends string | number>(values: ReadonlyArray<T>): ReadonlyArray<readonly [T, number]> {
const map = new Map<T, number>();
for (const v of values) map.set(v, (map.get(v) ?? 0) + 1);
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
}
// ---------------------------------------------------------------------------
// Tag slugification (used for `/skills/mode/<slug>/`,
// `/skills/scenario/<slug>/`, `/systems/category/<slug>/` routes).
//
// Stable, lossless rules:
// "AI & LLM" → "ai-llm"
// "Productivity & SaaS" → "productivity-saas"
// "Editorial · Studio" → "editorial-studio"
// "Editorial / Print" → "editorial-print"
// "live-artifacts" → "live-artifacts" (already a slug)
// ---------------------------------------------------------------------------
export function slugifyTag(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ---------------------------------------------------------------------------
// Tag canonicalization — collapse near-duplicate authoring spellings into
// one route per concept. Without this, `od.scenario: operation` and
// `od.scenario: operations` would generate two separate `/skills/scenario/...`
// pages for what is plainly the same facet.
//
// Keep aliases conservative — only collapse values that mean exactly the
// same thing (singular/plural, hyphen/space variants). Add more entries as
// inconsistencies appear; the alias key is matched case-insensitively
// against the raw frontmatter value before slugification.
// ---------------------------------------------------------------------------
const SCENARIO_ALIASES: Readonly<Record<string, string>> = {
operation: 'operations',
live: 'live-artifacts',
};
const MODE_ALIASES: Readonly<Record<string, string>> = {
// No aliases needed today — modes are an enum maintained centrally.
};
const CATEGORY_ALIASES: Readonly<Record<string, string>> = {
// No aliases needed today — categories come from DESIGN.md headers
// and are reasonably consistent across the corpus.
};
function canonicalize(
raw: string | undefined,
aliases: Readonly<Record<string, string>>,
): string | undefined {
if (!raw) return raw;
const key = raw.trim().toLowerCase();
return aliases[key] ?? raw;
}
export function canonicalScenario(raw: string | undefined): string | undefined {
return canonicalize(raw, SCENARIO_ALIASES);
}
export function canonicalMode(raw: string | undefined): string | undefined {
return canonicalize(raw, MODE_ALIASES);
}
export function canonicalCategory(raw: string | undefined): string | undefined {
return canonicalize(raw, CATEGORY_ALIASES);
}
export interface TagDescriptor {
slug: string;
label: string;
count: number;
}
/** Build [slug, label, count] index over a list of (possibly undefined) values. */
export function tagIndex(values: ReadonlyArray<string | undefined>): ReadonlyArray<TagDescriptor> {
const counts = new Map<string, { label: string; count: number }>();
for (const v of values) {
if (!v) continue;
const slug = slugifyTag(v);
const existing = counts.get(slug);
if (existing) {
existing.count++;
} else {
counts.set(slug, { label: v, count: 1 });
}
}
return Array.from(counts.entries())
.map(([slug, { label, count }]) => ({ slug, label, count }))
.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug));
}
// ---------------------------------------------------------------------------
// Tag-page selectors (used by the `/skills/mode/<slug>/` etc. routes via
// getStaticPaths). Each returns the matching records plus the canonical
// human label (preserving the original `od.mode` casing for the heading).
// ---------------------------------------------------------------------------
export async function getSkillsForMode(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalMode(s.mode);
return canonical && slugifyTag(canonical) === slug;
});
return {
label:
localizeTaxonomyValue(canonicalMode(matches[0]?.mode), locale) ??
canonicalMode(matches[0]?.mode) ??
null,
records: matches,
};
}
export async function getSkillsForScenario(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalScenario(s.scenario);
return canonical && slugifyTag(canonical) === slug;
});
return {
label:
localizeTaxonomyValue(canonicalScenario(matches[0]?.scenario), locale) ??
canonicalScenario(matches[0]?.scenario) ??
null,
records: matches,
};
}
export async function getSystemsForCategory(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SystemRecord>;
}> {
const all = await getSystemRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalCategory(s.category);
return canonical !== undefined && slugifyTag(canonical) === slug;
});
return {
label:
localizeTaxonomyValue(canonicalCategory(matches[0]?.category), locale) ??
canonicalCategory(matches[0]?.category) ??
null,
records: matches,
};
}
export async function getSkillModeIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalMode(s.mode))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}
export async function getSkillScenarioIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalScenario(s.scenario))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}
export async function getSystemCategoryIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSystemRecords();
return tagIndex(all.map((s) => canonicalCategory(s.category))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}