mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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>
1160 lines
38 KiB
TypeScript
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,
|
|
}));
|
|
}
|