mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(landing-page): rebuild plugins library to mirror in-app taxonomy (#2926)
* feat(landing-page): synthesize fallback preview cards for instruction skills
The skill catalog renders a diagonal-stripe placeholder for any skill
without a runnable example.html, which leaves ~70% of /skills/ as a
field of bare grey thumbs (instruction skills like copywriting,
creative-director, color-expert, brainstorming have no static demo
because their output depends on the agent's input).
Synthesize a typographic editorial card from each SKILL.md frontmatter
and screenshot it through the same Playwright pipeline that handles
real demos, so every catalog row carries a thumbnail. Cards include:
- OPEN DESIGN · SKILL top label + Nº NNN index (1..96 over the
instruction subset, sorted by od.featured then alphabetical)
- Big Playfair Display slug with a coral dot accent
- Italic serif description clamped to 3 lines
- mode/category chips + "Curated from <author>" attribution
- Warm-paper background with a subtle 135° stripe to thread the
landing's existing visual language
Bundle a few related improvements caught while building this:
- SkillRecord gains a `kind: 'instruction' | 'template'` field so
the detail page can render differently per kind (instruction
skills now render the SKILL.md body inline as "About this skill",
template skills keep the click-to-expand iframe demo).
- Catalog row thumbnails switch from the bespoke IntersectionObserver
pipeline to native `loading="lazy"` (with eager + fetchpriority=high
on the first 3). The observer's swap latency stranded mid-list
rows on the SVG placeholder during fast scrolls; native lazy uses
the browser's 1250-3000px lookahead so the placeholder flash is
gone.
- precise-lazyload rootMargin bumped to 1500px for any remaining
data-precise-src callers.
- CI cache key for generated previews now folds in
fallback-preview-card.ts so a template tweak invalidates the cache.
* feat(landing-page): rebuild plugins library to mirror in-app taxonomy
The marketing site's `/skills/`, `/templates/`, `/systems/`, `/craft/`
top-level entries were organized around author-supplied `od.mode` /
`od.scenario` taxonomies that visitors never see inside Open Design
itself. The in-app Plugins home (`apps/web/src/components/plugins-home/`)
groups every bundled plugin by the artifact it produces — Prototype,
Live Artifact, Slides, Image, Video, HyperFrames, Audio — and that's
the language users encounter the moment they open the product.
This PR rebuilds the public library around the same taxonomy and the
same data source so a visitor reading "Templates · 231" on the
marketing site sees the same 231 inside the app.
## What changes
- New top-level `/plugins/` hub: four tiles (Templates, Skills,
Systems, Craft) with live counts pulled straight from
`plugins/_official/<bucket>/<slug>/open-design.json` — the daemon's
bundled-plugin registry.
- `/plugins/templates/` lists every bundled plugin that lands in one
of the seven artifact kinds. Seven sub-routes
(`/plugins/templates/prototype/`, `/deck/`, `/image/`, `/video/`,
`/hyperframes/`, `/audio/`, `/live-artifact/`) carry the same chip
rail with an active state, so visitors can switch artifact kinds
with one click without losing the rail.
- Each artifact-kind sub-route shows a Scene chip rail when the kind
has scene buckets (Prototype / Slides / Image / Video each get
five-six). The Scene filter runs client-side via inline `style.display`
toggles; URLs stay one-per-kind so we don't multiply 25 × 18 locales
worth of static pages just for filter combinations.
- `/plugins/skills/` collects the instruction-only entries (mode
doesn't fit any of the seven kinds) — copywriting, color theory,
creative direction, brainstorming, etc.
- `/plugins/systems/` lists the 150 bundled design systems via the
legacy SystemCard renderer (palette swatches, tagline) so the
visual treatment matches the in-product library.
- `/plugins/craft/` keeps the existing craft principles list.
- `/plugins/<manifest-id>/` detail pages built from manifest metadata:
hero (poster image or playable Cloudflare Stream MP4 for video
templates), author / mode / scenario / tags, GitHub source link.
Author URLs pointing at the `nexu-io` org redirect to the
`nexu-io/open-design` repo so the attribution is actionable.
- Header dropdown labelled "Plugins" with the four sub-routes; footer
Library column updated to match.
- Old marketplace registry pages under `/plugins/` and
`/[locale]/plugins/` removed (they were a dormant placeholder UI;
the actual manifests it tried to load lived nowhere). The rest of
the legacy plugin-registry loader stays intact for any other
consumer.
## Preview generation
Bundled plugins ship `od.preview.poster` URLs on R2 for image and
video templates; those are used directly. The other 293 entries
(html-mode examples, design-systems, scenarios) had no poster, so
`generate-previews.ts` was extended to:
1. Screenshot a local `example.html` referenced by `od.preview.entry`
when present (134 examples).
2. Synthesize the same typographic editorial card the SKILL.md
fallback uses, sourced from manifest title / description / mode /
author (159 systems / scenarios / misc).
Output lands at `public/previews/plugins/<manifest-id>.png`. The
catalog loader checks for the local file when the manifest carries no
poster URL, so the row's `<img src>` always has something to point at.
Result: every catalog row and every detail page has a thumbnail;
visiting `/plugins/templates/video/` shows the same 48 entries the
in-app Plugins home shows, hyperframes the same 13, etc.
## Counts
- Templates: 231 (Prototype 59 + Slides 59 + Image 46 + Video 48 +
HyperFrames 13 + Audio 1 + Live Artifact 5)
- Skills: 15
- Systems: 150
- Craft: 11
Atoms (13 infrastructure plugins, `od.kind === 'atom'`) are filtered
to mirror the in-app behaviour.
* fix(landing-page): use Astro 6 render() helper for SKILL.md body
Astro 6 dropped `entry.render()` in favour of a top-level `render(entry)`
helper imported from `astro:content`. The instruction-kind skill detail
page was still using the legacy method, which compiled locally on Astro
6 only because tsx ignored the missing prototype method, but `astro
check` (run in CI) flagged it as ts(2551) and broke the workflow.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
This commit is contained in:
parent
5ff673f884
commit
40ae0836dd
27 changed files with 2695 additions and 1891 deletions
2
.github/workflows/landing-page-ci.yml
vendored
2
.github/workflows/landing-page-ci.yml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: apps/landing-page/public/previews
|
||||
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
|
||||
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
|
||||
restore-keys: |
|
||||
landing-page-previews-${{ runner.os }}-
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ export interface HeaderProps {
|
|||
| 'home'
|
||||
| 'product'
|
||||
| 'html-anything'
|
||||
| 'plugins'
|
||||
/*
|
||||
* `library` is kept as an alias for the dropdown trigger so older
|
||||
* pages that still pass `active="library"` keep working. New pages
|
||||
* should pass `active="plugins"`.
|
||||
*/
|
||||
| 'library'
|
||||
| 'skills'
|
||||
| 'systems'
|
||||
|
|
@ -177,8 +183,9 @@ export function Header({
|
|||
*/}
|
||||
<li className='has-dropdown'>
|
||||
<a
|
||||
href={href('/skills/')}
|
||||
href={href('/plugins/')}
|
||||
className={
|
||||
active === 'plugins' ||
|
||||
active === 'library' ||
|
||||
active === 'skills' ||
|
||||
active === 'systems' ||
|
||||
|
|
@ -190,52 +197,44 @@ export function Header({
|
|||
aria-haspopup='true'
|
||||
aria-expanded='false'
|
||||
>
|
||||
{headerCopy.nav.library}
|
||||
Plugins
|
||||
<span className='dropdown-caret' aria-hidden='true'>▾</span>
|
||||
</a>
|
||||
<ul className='nav-dropdown' role='menu'>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/skills/')}
|
||||
className={linkClass('skills')}
|
||||
>
|
||||
<span className='dropdown-name'>
|
||||
{headerCopy.nav.skills}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/systems/')}
|
||||
className={linkClass('systems')}
|
||||
>
|
||||
<span className='dropdown-name'>
|
||||
{headerCopy.nav.systems}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/templates/')}
|
||||
href={href('/plugins/templates/')}
|
||||
className={linkClass('templates')}
|
||||
>
|
||||
<span className='dropdown-name'>
|
||||
{headerCopy.nav.templates}
|
||||
</span>
|
||||
<span className='dropdown-name'>Templates</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/craft/')}
|
||||
href={href('/plugins/skills/')}
|
||||
className={linkClass('skills')}
|
||||
>
|
||||
<span className='dropdown-name'>Skills</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/plugins/systems/')}
|
||||
className={linkClass('systems')}
|
||||
>
|
||||
<span className='dropdown-name'>Systems</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role='none'>
|
||||
<a
|
||||
role='menuitem'
|
||||
href={href('/plugins/craft/')}
|
||||
className={linkClass('craft')}
|
||||
>
|
||||
<span className='dropdown-name'>
|
||||
{headerCopy.nav.craft}
|
||||
</span>
|
||||
<span className='dropdown-name'>Craft</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
110
apps/landing-page/app/_components/plugin-row.astro
Normal file
110
apps/landing-page/app/_components/plugin-row.astro
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
/*
|
||||
* Unified catalog row used across the new `/plugins/...` routes.
|
||||
*
|
||||
* Sits next to `skill-row.astro`, but accepts either a SkillRecord or
|
||||
* a TemplateRecord — the two share the same display surface (preview,
|
||||
* name, description, mode chip) but differ on the detail-page URL
|
||||
* (`/skills/<slug>/` vs `/templates/<slug>/`). One component avoids
|
||||
* forcing every callsite to write the same five-column markup twice.
|
||||
*/
|
||||
import type { SkillRecord, TemplateRecord } from '../_lib/catalog';
|
||||
import type { BundledPluginRecord } from '../_lib/bundled-plugins';
|
||||
import { localeFromPath, localizedHref } from '../i18n';
|
||||
|
||||
interface SkillItem {
|
||||
kind: 'skill';
|
||||
record: SkillRecord;
|
||||
}
|
||||
interface TemplateItem {
|
||||
kind: 'template';
|
||||
record: TemplateRecord;
|
||||
}
|
||||
interface BundledItem {
|
||||
kind: 'bundled';
|
||||
record: BundledPluginRecord;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
item: SkillItem | TemplateItem | BundledItem;
|
||||
index: number;
|
||||
/**
|
||||
* Optional value for `data-scene` on the rendered `<li>`. Used by
|
||||
* `/plugins/templates/<kind>/` so its client-side scene filter can
|
||||
* toggle visibility without wrapping the row in an extra (and
|
||||
* HTML-illegal) outer `<li>`.
|
||||
*/
|
||||
dataScene?: string;
|
||||
}
|
||||
|
||||
const { item, index, dataScene } = Astro.props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
// Catalog rows use native lazy-loading; the first three rows above the
|
||||
// fold get eager + high fetchpriority so first paint is unblocked.
|
||||
const eager = index < 3;
|
||||
|
||||
let detailHref: string;
|
||||
let name: string;
|
||||
let description: string;
|
||||
let previewUrl: string | null;
|
||||
let modeLabel: string | undefined;
|
||||
let scenarioLabel: string | undefined;
|
||||
|
||||
if (item.kind === 'skill') {
|
||||
detailHref = `/skills/${item.record.slug}/`;
|
||||
name = item.record.name;
|
||||
description = item.record.description;
|
||||
previewUrl = item.record.previewUrl;
|
||||
modeLabel = item.record.modeLabel;
|
||||
scenarioLabel = item.record.scenarioLabel;
|
||||
} else if (item.kind === 'template') {
|
||||
detailHref = item.record.detailHref;
|
||||
name = item.record.name;
|
||||
description = item.record.summary;
|
||||
previewUrl = item.record.previewUrl;
|
||||
modeLabel = item.record.modeLabel;
|
||||
scenarioLabel = item.record.scenarioLabel;
|
||||
} else {
|
||||
// Bundled-plugin manifest. Preview comes straight from the manifest's
|
||||
// `od.preview.poster` URL (already on R2), so we don't need a local
|
||||
// generated PNG. Fall back to null if the author didn't ship one —
|
||||
// the row gets the diagonal-stripe placeholder and styling stays
|
||||
// consistent with the rest of the catalog.
|
||||
detailHref = item.record.detailHref;
|
||||
name = item.record.title;
|
||||
description = item.record.description;
|
||||
previewUrl = item.record.previewPoster ?? null;
|
||||
modeLabel = item.record.mode;
|
||||
scenarioLabel = item.record.scenario;
|
||||
}
|
||||
---
|
||||
|
||||
<li class="catalog-row catalog-row-skill" data-scene={dataScene}>
|
||||
<a href={href(detailHref)}>
|
||||
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
|
||||
<span class="row-thumb">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchpriority={eager ? 'high' : 'auto'}
|
||||
/>
|
||||
) : (
|
||||
<span class="row-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span class="row-body">
|
||||
<span class="row-name">{name}</span>
|
||||
<span class="row-desc">{description}</span>
|
||||
</span>
|
||||
<span class="row-meta">
|
||||
{modeLabel && <span class="meta-tag">{modeLabel}</span>}
|
||||
{scenarioLabel && <span class="meta-tag muted">{scenarioLabel}</span>}
|
||||
</span>
|
||||
<span class="row-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -35,7 +35,13 @@
|
|||
(() => {
|
||||
const IMG_SELECTOR = 'img[data-precise-src]';
|
||||
const VIDEO_SELECTOR = 'video[data-precise-load]';
|
||||
const IMG_ROOT_MARGIN = '300px 0px';
|
||||
// 1500px is roughly two laptop viewports of pre-load: enough that
|
||||
// a fast scroll through long catalogs (e.g. /skills/instructions/
|
||||
// with 90+ rows of typographic fallback cards) keeps thumbnails
|
||||
// ready instead of holding placeholders until each row settles.
|
||||
// Trade-off: a slightly larger Cloudflare CDN burst on initial
|
||||
// load — acceptable because every preview is a small static PNG.
|
||||
const IMG_ROOT_MARGIN = '1500px 0px';
|
||||
const VIDEO_ROOT_MARGIN = '600px 0px';
|
||||
|
||||
const swapImage = (img) => {
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ const DISCORD = 'https://discord.gg/9ptkbbqRu';
|
|||
</p>
|
||||
</div>
|
||||
<div class='sub-footer-col'>
|
||||
<h5>{ui.footer.catalog}</h5>
|
||||
<h5>Plugins</h5>
|
||||
<ul>
|
||||
<li><a href={href('/skills/')}>{counts.skills} {copy.nav.skills}</a></li>
|
||||
<li><a href={href('/systems/')}>{counts.systems} {copy.nav.systems}</a></li>
|
||||
<li><a href={href('/templates/')}>{counts.templates} {copy.nav.templates}</a></li>
|
||||
<li><a href={href('/craft/')}>{counts.craft} {copy.nav.craft}</a></li>
|
||||
<li><a href={href('/plugins/templates/')}>Templates</a></li>
|
||||
<li><a href={href('/plugins/skills/')}>Skills</a></li>
|
||||
<li><a href={href('/plugins/systems/')}>{counts.systems} Systems</a></li>
|
||||
<li><a href={href('/plugins/craft/')}>{counts.craft} Craft</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='sub-footer-col'>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
*/
|
||||
import type { SkillRecord } from '../_lib/catalog';
|
||||
import { localeFromPath, localizedHref } from '../i18n';
|
||||
import LazyImg from './lazy-img.astro';
|
||||
|
||||
export interface Props {
|
||||
skill: SkillRecord;
|
||||
|
|
@ -21,11 +20,16 @@ const { skill, index } = Astro.props;
|
|||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
// The first ~4 rows are visible above the fold on a typical laptop and
|
||||
// always render at the top of the first catalog list a visitor sees.
|
||||
// Skip the IntersectionObserver round-trip for them; everything below
|
||||
// uses precise lazy loading (rootMargin 300px) via the global script.
|
||||
const aboveFold = index < 4;
|
||||
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
|
||||
// so we deliberately bypass the precise IntersectionObserver pipeline.
|
||||
// On long lists like /skills/instructions/ (96 rows) the observer's
|
||||
// swap latency stranded mid-page rows on the SVG placeholder during
|
||||
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
|
||||
// lookahead) keeps the upcoming rows pre-fetched without the
|
||||
// observer round-trip; only the first three rows go eager so they
|
||||
// paint immediately on first paint instead of waiting for the
|
||||
// browser's lazy queue.
|
||||
const eager = index < 3;
|
||||
---
|
||||
|
||||
<li class="catalog-row catalog-row-skill">
|
||||
|
|
@ -33,10 +37,12 @@ const aboveFold = index < 4;
|
|||
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
|
||||
<span class="row-thumb">
|
||||
{skill.previewUrl ? (
|
||||
<LazyImg
|
||||
<img
|
||||
src={skill.previewUrl}
|
||||
alt=""
|
||||
loading={aboveFold ? 'eager' : 'precise'}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchpriority={eager ? 'high' : 'auto'}
|
||||
/>
|
||||
) : (
|
||||
<span class="row-thumb-empty" aria-hidden="true" />
|
||||
|
|
|
|||
254
apps/landing-page/app/_lib/bundled-plugins.ts
Normal file
254
apps/landing-page/app/_lib/bundled-plugins.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// Loader for `plugins/_official/<bucket>/<slug>/open-design.json` —
|
||||
// the bundled-plugin catalogue the daemon registers on startup and
|
||||
// the in-app Plugins home displays. Authoritative source of truth for
|
||||
// the marketing site's `/plugins/...` routes; mirroring it keeps the
|
||||
// landing-page counts in lockstep with what visitors see when they
|
||||
// open Open Design.
|
||||
//
|
||||
// Why a parallel loader instead of extending `catalog.ts`:
|
||||
// - Catalog reads SKILL.md frontmatter through Astro Content
|
||||
// Collections; bundled plugins ship `open-design.json` (a
|
||||
// manifest, not Markdown), so the data shape is different and
|
||||
// forcing one loader to handle both invites schema confusion.
|
||||
// - The manifest's `od.preview.poster` is already a CDN URL — no
|
||||
// Playwright pass required, screenshots are skipped entirely for
|
||||
// this dataset.
|
||||
// - Atoms (utility plugins like `code-import`, `patch-edit`) share
|
||||
// the same manifest format but are filtered out of the public
|
||||
// library. Centralising the filter here keeps every catalog
|
||||
// route in sync.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_ROOTS = [
|
||||
// Build run from monorepo root.
|
||||
path.resolve(process.cwd(), 'plugins/_official'),
|
||||
// Build run from `apps/landing-page/`.
|
||||
path.resolve(process.cwd(), '../../plugins/_official'),
|
||||
// Source-relative fallback (matches the convention in `catalog.ts`).
|
||||
path.resolve(fileURLToPath(new URL('../../../../plugins/_official', import.meta.url))),
|
||||
] as const;
|
||||
|
||||
function pluginsRoot(): string | null {
|
||||
return SOURCE_ROOTS.find((dir) => existsSync(dir)) ?? null;
|
||||
}
|
||||
|
||||
/** Buckets we walk under `plugins/_official/`. Order = display order. */
|
||||
export const BUNDLED_BUCKETS = [
|
||||
'examples',
|
||||
'image-templates',
|
||||
'video-templates',
|
||||
'scenarios',
|
||||
'design-systems',
|
||||
'atoms',
|
||||
] as const;
|
||||
|
||||
export type BundledBucket = (typeof BUNDLED_BUCKETS)[number];
|
||||
|
||||
export interface BundledPluginRecord {
|
||||
/** Folder name, e.g. `3d-stone-staircase-evolution-infographic`. */
|
||||
slug: string;
|
||||
/** Manifest `name`, e.g. `image-template-3d-stone-staircase-evolution-infographic`. */
|
||||
manifestId: string;
|
||||
/** Source bucket. */
|
||||
bucket: BundledBucket;
|
||||
/** Manifest `title`. */
|
||||
title: string;
|
||||
/** Manifest `description`. */
|
||||
description: string;
|
||||
/** Manifest `tags`. */
|
||||
tags: ReadonlyArray<string>;
|
||||
/** Manifest `author.name`. */
|
||||
authorName?: string;
|
||||
/** Manifest `author.url`. */
|
||||
authorUrl?: string;
|
||||
/** Manifest `homepage`. */
|
||||
homepage?: string;
|
||||
/** od.mode (e.g. `prototype`, `image`, `video`). */
|
||||
mode?: string;
|
||||
/** od.scenario. */
|
||||
scenario?: string;
|
||||
/** od.platform. */
|
||||
platform?: string;
|
||||
/** od.surface. */
|
||||
surface?: string;
|
||||
/** od.kind (e.g. `scenario`, `atom`, `system`). Atoms get filtered. */
|
||||
kind?: string;
|
||||
/** Preview poster URL (already on R2 / CDN). */
|
||||
previewPoster?: string;
|
||||
/** Preview type — `image`, `video`, `html`, etc. */
|
||||
previewType?: string;
|
||||
/** Preview video URL when `previewType === 'video'` (Cloudflare Stream MP4). */
|
||||
previewVideo?: string;
|
||||
/** Detail page URL on this site (`/plugins/<manifest-id>/`). */
|
||||
detailHref: string;
|
||||
/** GitHub source folder URL. */
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
interface BundledManifestRaw {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
tags?: unknown;
|
||||
author?: { name?: unknown; url?: unknown };
|
||||
homepage?: unknown;
|
||||
od?: {
|
||||
kind?: unknown;
|
||||
mode?: unknown;
|
||||
scenario?: unknown;
|
||||
platform?: unknown;
|
||||
surface?: unknown;
|
||||
preview?: {
|
||||
type?: unknown;
|
||||
poster?: unknown;
|
||||
entry?: unknown;
|
||||
video?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): ReadonlyArray<string> {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string');
|
||||
}
|
||||
|
||||
function REPO_FOR_BUCKET(bucket: BundledBucket): string {
|
||||
return `https://github.com/nexu-io/open-design/tree/main/plugins/_official/${bucket}`;
|
||||
}
|
||||
|
||||
const PREVIEW_OUT_CANDIDATES = [
|
||||
path.resolve(process.cwd(), 'apps/landing-page/public/previews/plugins'),
|
||||
path.resolve(process.cwd(), 'public/previews/plugins'),
|
||||
path.resolve(fileURLToPath(new URL('../../public/previews/plugins', import.meta.url))),
|
||||
] as const;
|
||||
|
||||
function localPreviewRoot(): string | null {
|
||||
return PREVIEW_OUT_CANDIDATES.find((d) => existsSync(d)) ?? null;
|
||||
}
|
||||
|
||||
let cachedLocalPreviewSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Quickly check whether `generate-previews.ts` produced a local PNG
|
||||
* for a given manifest id. Built once per build run, then reused for
|
||||
* every record so we don't fs-stat 400+ files in a tight loop.
|
||||
*/
|
||||
function hasLocalPreview(manifestId: string): boolean {
|
||||
if (cachedLocalPreviewSet) {
|
||||
return cachedLocalPreviewSet.has(`${manifestId}.png`);
|
||||
}
|
||||
const root = localPreviewRoot();
|
||||
if (!root) {
|
||||
cachedLocalPreviewSet = new Set();
|
||||
return false;
|
||||
}
|
||||
cachedLocalPreviewSet = new Set(readdirSync(root));
|
||||
return cachedLocalPreviewSet.has(`${manifestId}.png`);
|
||||
}
|
||||
|
||||
function loadOne(
|
||||
root: string,
|
||||
bucket: BundledBucket,
|
||||
slug: string,
|
||||
): BundledPluginRecord | null {
|
||||
const manifestPath = path.join(root, bucket, slug, 'open-design.json');
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
let raw: BundledManifestRaw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(manifestPath, 'utf8')) as BundledManifestRaw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestId = asString(raw.name) ?? slug;
|
||||
const title = asString(raw.title) ?? manifestId;
|
||||
const description = asString(raw.description) ?? '';
|
||||
|
||||
// Preference order:
|
||||
// 1. Manifest poster URL (R2/CDN, fastest, already bandwidth-paid).
|
||||
// 2. Local screenshot at /previews/plugins/<id>.png that
|
||||
// `generate-previews.ts` produced from the entry HTML.
|
||||
// 3. Local fallback typographic card at the same path.
|
||||
// Whichever exists first wins; the catalog row sees a single
|
||||
// `previewPoster` URL and doesn't have to know which path it came
|
||||
// from.
|
||||
const remotePoster = asString(raw.od?.preview?.poster);
|
||||
const previewPoster =
|
||||
remotePoster ??
|
||||
(hasLocalPreview(manifestId) ? `/previews/plugins/${manifestId}.png` : undefined);
|
||||
|
||||
return {
|
||||
slug,
|
||||
manifestId,
|
||||
bucket,
|
||||
title,
|
||||
description,
|
||||
tags: asStringArray(raw.tags),
|
||||
authorName: asString(raw.author?.name),
|
||||
authorUrl: asString(raw.author?.url),
|
||||
homepage: asString(raw.homepage),
|
||||
mode: asString(raw.od?.mode),
|
||||
scenario: asString(raw.od?.scenario),
|
||||
platform: asString(raw.od?.platform),
|
||||
surface: asString(raw.od?.surface),
|
||||
kind: asString(raw.od?.kind),
|
||||
previewPoster,
|
||||
previewType: asString(raw.od?.preview?.type),
|
||||
previewVideo: asString(raw.od?.preview?.video),
|
||||
detailHref: `/plugins/${manifestId}/`,
|
||||
sourceUrl: `${REPO_FOR_BUCKET(bucket)}/${slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedAll: ReadonlyArray<BundledPluginRecord> | null = null;
|
||||
|
||||
/**
|
||||
* Read every bundled plugin from `plugins/_official/`. Atoms
|
||||
* (`od.kind === 'atom'`) are dropped — they’re infrastructure,
|
||||
* not user-facing entries. Cached per build because the source
|
||||
* tree never changes during a single Astro build.
|
||||
*/
|
||||
export function getBundledPlugins(): ReadonlyArray<BundledPluginRecord> {
|
||||
if (cachedAll) return cachedAll;
|
||||
const root = pluginsRoot();
|
||||
if (!root) {
|
||||
cachedAll = [];
|
||||
return cachedAll;
|
||||
}
|
||||
|
||||
const out: BundledPluginRecord[] = [];
|
||||
for (const bucket of BUNDLED_BUCKETS) {
|
||||
const dir = path.join(root, bucket);
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.startsWith('_') || name.startsWith('.')) continue;
|
||||
const full = path.join(dir, name);
|
||||
if (!statSync(full).isDirectory()) continue;
|
||||
const record = loadOne(root, bucket, name);
|
||||
if (!record) continue;
|
||||
// Atoms are infrastructure plugins (`code-import`, `patch-edit`,
|
||||
// …) that the daemon needs but the in-app Plugins home filters
|
||||
// out. Mirror that filter here so our public-library counts
|
||||
// match what users see in the picker.
|
||||
if (record.kind === 'atom') continue;
|
||||
out.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.title.localeCompare(b.title));
|
||||
cachedAll = out;
|
||||
return cachedAll;
|
||||
}
|
||||
|
||||
export function getBundledPluginById(
|
||||
manifestId: string,
|
||||
): BundledPluginRecord | null {
|
||||
return getBundledPlugins().find((p) => p.manifestId === manifestId) ?? null;
|
||||
}
|
||||
|
|
@ -84,6 +84,37 @@ function previewUrlFor(
|
|||
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;
|
||||
|
|
@ -94,6 +125,26 @@ const SHOULD_CACHE_CATALOG = import.meta.env.PROD;
|
|||
|
||||
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;
|
||||
|
|
@ -112,6 +163,7 @@ export interface SkillRecord {
|
|||
examplePrompt?: string;
|
||||
source: string;
|
||||
body: string;
|
||||
kind: SkillKind;
|
||||
/** `/previews/skills/<slug>.png` if a generated preview exists, else null. */
|
||||
previewUrl: string | null;
|
||||
}
|
||||
|
|
@ -132,6 +184,7 @@ function firstParagraph(text: string | undefined, fallback = ''): string {
|
|||
export function shapeSkill(
|
||||
entry: SkillEntry,
|
||||
previews: Map<string, string>,
|
||||
examples: Set<string>,
|
||||
locale: LandingLocaleCode = DEFAULT_LOCALE,
|
||||
): SkillRecord {
|
||||
const slug = deriveSkillSlug(entry.id);
|
||||
|
|
@ -191,6 +244,7 @@ export function shapeSkill(
|
|||
examplePrompt,
|
||||
source: `${REPO_TREE}/skills/${slug}`,
|
||||
body: entry.body ?? '',
|
||||
kind: examples.has(slug) ? 'template' : 'instruction',
|
||||
previewUrl: previewUrlFor('skills', slug, previews),
|
||||
};
|
||||
}
|
||||
|
|
@ -200,8 +254,9 @@ export async function getSkillRecords(
|
|||
): 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, locale));
|
||||
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;
|
||||
|
|
@ -218,8 +273,9 @@ export async function getSkillRecords(
|
|||
|
||||
const promise = (async () => {
|
||||
const previews = listPreviews('skills');
|
||||
const examples = listSkillExamples();
|
||||
const entries = await getCollection('skills');
|
||||
const shaped = entries.map((entry) => shapeSkill(entry, previews, locale));
|
||||
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;
|
||||
|
|
@ -233,6 +289,19 @@ export async function getSkillRecords(
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
404
apps/landing-page/app/_lib/plugin-facets.ts
Normal file
404
apps/landing-page/app/_lib/plugin-facets.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
// Plugin-library facet derivation, ported from
|
||||
// `apps/web/src/components/plugins-home/facets.ts`.
|
||||
//
|
||||
// The web client organizes the Plugins home grid around the artifact a
|
||||
// user is about to make:
|
||||
//
|
||||
// Prototype · Live Artifact · Slides · Image · Video · HyperFrames · Audio
|
||||
//
|
||||
// Prototype, Slides, Image, and Video each expose a row of scene
|
||||
// children (Dashboards, Apps, Pitch decks, Storyboards, …); HyperFrames
|
||||
// and Audio stay flat. The marketing site has historically organized
|
||||
// the same content around author-supplied `od.mode` / `od.scenario`
|
||||
// taxonomies — confusing for visitors who have never opened the app.
|
||||
// Mirroring the client taxonomy here makes the catalogue read the same
|
||||
// on /plugins/ as it does inside the product.
|
||||
//
|
||||
// We adapt the client's `InstalledPluginRecord` test functions to the
|
||||
// landing-side `SkillRecord` / `TemplateRecord` shape: skill `name`,
|
||||
// `slug`, `mode`, `scenario`, `category` plus the design-template
|
||||
// origin slugs are the haystack we match against. The client's curated
|
||||
// `live-artifact` ID list maps to repo folder names because the daemon
|
||||
// plugin id is `example-<folder-name>` for design-template origins.
|
||||
|
||||
import type { SkillRecord, TemplateRecord } from './catalog';
|
||||
import type { BundledPluginRecord } from './bundled-plugins';
|
||||
|
||||
export type PluginCategorySlug =
|
||||
| 'prototype'
|
||||
| 'live-artifact'
|
||||
| 'deck'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'hyperframes'
|
||||
| 'audio';
|
||||
|
||||
export interface PluginCategoryDef {
|
||||
slug: PluginCategorySlug;
|
||||
label: string;
|
||||
/** One-sentence copy used on category headers and tile blurbs. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PluginSubcategoryDef {
|
||||
parent: PluginCategorySlug;
|
||||
slug: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PLUGIN_CATEGORIES: readonly PluginCategoryDef[] = [
|
||||
{
|
||||
slug: 'prototype',
|
||||
label: 'Prototype',
|
||||
description:
|
||||
'Interactive product mockups — dashboards, apps, landing pages, internal tools. Anything you’d hand a stakeholder and click through.',
|
||||
},
|
||||
{
|
||||
slug: 'live-artifact',
|
||||
label: 'Live Artifact',
|
||||
description:
|
||||
'Refreshable, data-aware artifacts that re-render whenever the underlying data changes. Live dashboards, monitoring boards, recurring trackers.',
|
||||
},
|
||||
{
|
||||
slug: 'deck',
|
||||
label: 'Slides',
|
||||
description:
|
||||
'Polished slide decks from a narrative brief — pitch decks, course modules, weekly reports, product launches.',
|
||||
},
|
||||
{
|
||||
slug: 'image',
|
||||
label: 'Image',
|
||||
description:
|
||||
'Image assets generated from structured creative direction — UI mockups, brand visuals, storyboards, social posts, illustrations.',
|
||||
},
|
||||
{
|
||||
slug: 'video',
|
||||
label: 'Video',
|
||||
description:
|
||||
'Video prompts, storyboards, and render-ready motion artifacts — short-form social, marketing cuts, motion graphics, cinematic stories.',
|
||||
},
|
||||
{
|
||||
slug: 'hyperframes',
|
||||
label: 'HyperFrames',
|
||||
description:
|
||||
'HyperFrames-ready motion compositions — agent-built video that blends template HTML with frame-level keyframes.',
|
||||
},
|
||||
{
|
||||
slug: 'audio',
|
||||
label: 'Audio',
|
||||
description:
|
||||
'Audio, voice, and sound-design assets generated from a brief — podcast intros, jingles, ambient beds.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PLUGIN_SUBCATEGORIES: readonly PluginSubcategoryDef[] = [
|
||||
// Prototype
|
||||
{ parent: 'prototype', slug: 'business-dashboards', label: 'Dashboards',
|
||||
description: 'Business systems, admin panels, analytics dashboards, ops control rooms.' },
|
||||
{ parent: 'prototype', slug: 'app-prototypes', label: 'Apps',
|
||||
description: 'Multi-screen mobile and web apps — onboarding, productivity, social.' },
|
||||
{ parent: 'prototype', slug: 'landing-marketing', label: 'Landing & marketing',
|
||||
description: 'Landing pages, marketing sites, pricing pages, waitlists, campaign pages.' },
|
||||
{ parent: 'prototype', slug: 'developer-tools', label: 'Developer tools',
|
||||
description: 'Engineering surfaces, dev workflows, technical docs, code-collab UIs.' },
|
||||
{ parent: 'prototype', slug: 'docs-reports', label: 'Docs & reports',
|
||||
description: 'Reports, case studies, specs, invoices, resumes, knowledge documents.' },
|
||||
{ parent: 'prototype', slug: 'brand-design', label: 'Brand & design',
|
||||
description: 'Brand pages, visual exploration, design reviews, mockups.' },
|
||||
|
||||
// Slides / Deck
|
||||
{ parent: 'deck', slug: 'pitch-business', label: 'Pitch & business',
|
||||
description: 'Fundraising decks, business plans, investor narratives, strategic memos.' },
|
||||
{ parent: 'deck', slug: 'course-training', label: 'Course & training',
|
||||
description: 'Course modules, workshops, lessons, classroom slides.' },
|
||||
{ parent: 'deck', slug: 'reports-briefings', label: 'Reports & briefings',
|
||||
description: 'Weekly reports, business reviews, white papers, briefings.' },
|
||||
{ parent: 'deck', slug: 'product-sales', label: 'Product & sales',
|
||||
description: 'Product launches, sales decks, feature reveals, customer pitches.' },
|
||||
{ parent: 'deck', slug: 'engineering-talks', label: 'Engineering talks',
|
||||
description: 'Tech sharing, architecture walkthroughs, dev workflow talks.' },
|
||||
{ parent: 'deck', slug: 'creative-decks', label: 'Creative decks',
|
||||
description: 'Editorial, brand, social, fashion, creator-portfolio decks.' },
|
||||
|
||||
// Image
|
||||
{ parent: 'image', slug: 'ui-product-mockups', label: 'UI & product mockups',
|
||||
description: 'Product UI mockups, game UI, interface showcases, app cards.' },
|
||||
{ parent: 'image', slug: 'brand-visuals', label: 'Brand & logo',
|
||||
description: 'Logos, brand visuals, typography-led posters, identity systems.' },
|
||||
{ parent: 'image', slug: 'storyboards-motion-refs', label: 'Storyboards',
|
||||
description: 'Storyboards, choreography breakdowns, pose references, motion sheets.' },
|
||||
{ parent: 'image', slug: 'social-content', label: 'Social & content',
|
||||
description: 'Social posts, infographics, explainers, content cards.' },
|
||||
{ parent: 'image', slug: 'avatar-portrait', label: 'Avatar & portrait',
|
||||
description: 'Avatars, portraits, identity photos, character headshots.' },
|
||||
{ parent: 'image', slug: 'illustration-style', label: 'Illustration & style',
|
||||
description: 'Illustrations, anime, fantasy scenes, 3D renders, style transfer.' },
|
||||
|
||||
// Video
|
||||
{ parent: 'video', slug: 'motion-effects', label: 'Motion & effects',
|
||||
description: 'Motion graphics, VFX, title frames, animation, logo outros.' },
|
||||
{ parent: 'video', slug: 'social-short-form', label: 'Social short form',
|
||||
description: 'Vertical social clips, TikTok-style captions, dance trends.' },
|
||||
{ parent: 'video', slug: 'marketing-product', label: 'Marketing & product',
|
||||
description: 'Product promos, advertising, brand sizzle reels, marketing cuts.' },
|
||||
{ parent: 'video', slug: 'data-explainers', label: 'Data & explainers',
|
||||
description: 'Data explainers, animated charts, maps, diagrams, flow walkthroughs.' },
|
||||
{ parent: 'video', slug: 'cinematic-story', label: 'Cinematic story',
|
||||
description: 'Cinematic scenes, story sequences, anime/action shots, fantasy clips.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Curated live-artifact ids. Mirrors `CURATED_LIVE_ARTIFACT_PLUGIN_IDS`
|
||||
* in `apps/web/src/components/plugins-home/curatedPriority.ts`.
|
||||
*
|
||||
* We accept both forms — the manifest `name` (`example-live-dashboard`,
|
||||
* what bundled-plugin records carry) and the bare folder slug
|
||||
* (`live-dashboard`, what legacy SkillRecord/TemplateRecord carry) —
|
||||
* so a single set works for both data sources.
|
||||
*/
|
||||
const LIVE_ARTIFACT_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Bundled-plugin manifest ids.
|
||||
'example-live-dashboard',
|
||||
'example-live-artifact',
|
||||
'example-social-media-matrix-tracker-template',
|
||||
'example-trading-analysis-dashboard-template',
|
||||
'image-template-notion-team-dashboard-live-artifact',
|
||||
// Bare folder slugs (used by SkillRecord / TemplateRecord adapters).
|
||||
'live-dashboard',
|
||||
'live-artifact',
|
||||
'social-media-matrix-tracker-template',
|
||||
'trading-analysis-dashboard-template',
|
||||
]);
|
||||
|
||||
interface MatchableRecord {
|
||||
slug: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
scenario?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fuzzy haystack of slugs the client's `byAnySlug` checks
|
||||
* against — slug, name, mode, scenario, category, plus tokens of the
|
||||
* compound slug (so `social-media-dashboard` also matches against
|
||||
* `social`, `media`, `dashboard`).
|
||||
*/
|
||||
function recordSlugs(record: MatchableRecord): Set<string> {
|
||||
const tokens: string[] = [
|
||||
slugify(record.slug),
|
||||
slugify(record.name ?? ''),
|
||||
slugify(record.mode ?? ''),
|
||||
slugify(record.scenario ?? ''),
|
||||
slugify(record.category ?? ''),
|
||||
];
|
||||
for (const part of record.slug.split('-')) {
|
||||
tokens.push(slugify(part));
|
||||
}
|
||||
return new Set(tokens.filter(Boolean));
|
||||
}
|
||||
|
||||
function hasAnySlug(record: MatchableRecord, slugs: readonly string[]): boolean {
|
||||
const haystack = recordSlugs(record);
|
||||
return slugs.some((slug) => haystack.has(slug));
|
||||
}
|
||||
|
||||
function modeMatches(record: MatchableRecord, mode: string): boolean {
|
||||
return slugify(record.mode ?? '') === mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the artifact-kind category for a record, or `null` if it
|
||||
* doesn't fit any of the seven product types (instruction-only skills
|
||||
* like `copywriting` end up here).
|
||||
*
|
||||
* Mirrors the precedence order from `apps/web/.../facets.ts`:
|
||||
* Live Artifact wins over Prototype (when the slug is in the curated
|
||||
* list), HyperFrames wins over Video.
|
||||
*/
|
||||
export function categorizePlugin(record: MatchableRecord): PluginCategorySlug | null {
|
||||
if (LIVE_ARTIFACT_SLUGS.has(record.slug)) return 'live-artifact';
|
||||
|
||||
if (
|
||||
hasAnySlug(record, [
|
||||
'hyperframes',
|
||||
'html-video',
|
||||
'video-composition',
|
||||
'interactive-video',
|
||||
])
|
||||
) {
|
||||
return 'hyperframes';
|
||||
}
|
||||
|
||||
if (modeMatches(record, 'prototype')) return 'prototype';
|
||||
if (modeMatches(record, 'deck')) return 'deck';
|
||||
if (modeMatches(record, 'image')) return 'image';
|
||||
if (modeMatches(record, 'video')) return 'video';
|
||||
if (modeMatches(record, 'audio')) return 'audio';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scene subcategory under the record's primary category,
|
||||
* or `null` if the record doesn't fit any (e.g. the parent has no
|
||||
* subcategories or the record straddles two scene buckets — first match
|
||||
* wins, mirroring the client implementation).
|
||||
*/
|
||||
export function categorizeSubcategory(
|
||||
record: MatchableRecord,
|
||||
parent?: PluginCategorySlug | null,
|
||||
): string | null {
|
||||
const primary = parent ?? categorizePlugin(record);
|
||||
if (!primary) return null;
|
||||
|
||||
const candidates = PLUGIN_SUBCATEGORIES.filter((s) => s.parent === primary);
|
||||
for (const sub of candidates) {
|
||||
if (subcategoryTest(sub.slug)(record)) return sub.slug;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function subcategoryTest(slug: string): (record: MatchableRecord) => boolean {
|
||||
const slugs = SUBCATEGORY_SLUGS[slug];
|
||||
if (!slugs) return () => false;
|
||||
return (record) => hasAnySlug(record, slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-subcategory keyword bundles, transcribed from `byAnySlug(...)`
|
||||
* arguments in `apps/web/.../facets.ts`. When the client list grows,
|
||||
* mirror the new slugs here so the marketing taxonomy stays aligned.
|
||||
*/
|
||||
const SUBCATEGORY_SLUGS: Record<string, readonly string[]> = {
|
||||
'business-dashboards': [
|
||||
'dashboard', 'admin-panel', 'analytics', 'control-panel', 'team-dashboard',
|
||||
'live-dashboard', 'refreshable-dashboard', 'ops-dashboard', 'github-dashboard',
|
||||
'social-media-dashboard', 'data', 'chart',
|
||||
],
|
||||
'app-prototypes': [
|
||||
'mobile', 'app', 'mobile-app', 'ios-app', 'android-app', 'phone-screen',
|
||||
'app-ui', 'app-mockup', 'app-onboarding', 'onboarding', 'signup', 'task',
|
||||
'habit-tracker', 'dating-app',
|
||||
],
|
||||
'landing-marketing': [
|
||||
'landing', 'landing-page', 'saas-landing', 'marketing-page', 'product-landing',
|
||||
'pricing', 'pricing-page', 'waitlist-page', 'coming-soon-page', 'email-template',
|
||||
'newsletter', 'lead-magnet', 'e-guide', 'poster', 'social-carousel',
|
||||
],
|
||||
'developer-tools': [
|
||||
'engineering', 'docs', 'documentation', 'api-reference', 'runbook', 'ops-doc',
|
||||
'sre-doc', 'github', 'linear', 'issue',
|
||||
],
|
||||
'docs-reports': [
|
||||
'report', 'financial-report', 'finance-report', 'case-report', 'clinical-case',
|
||||
'case-study', 'guide', 'tutorial', 'pm-spec', 'prd', 'spec', 'invoice',
|
||||
'resume', 'cv',
|
||||
],
|
||||
'brand-design': [
|
||||
'design', 'design-review', 'design-audit', 'critique', 'mockup', 'wireframe',
|
||||
'visual', 'brand',
|
||||
],
|
||||
'pitch-business': [
|
||||
'pitch-deck', 'pitch', 'fundraising', 'seed-round', 'investor-deck', 'vc-deck',
|
||||
'business-plan', 'b2b-saas-pitch', 'founder-vision-deck',
|
||||
],
|
||||
'course-training': [
|
||||
'course-module', 'course-slides', 'training-deck', 'workshop', 'lesson',
|
||||
'education', 'classroom',
|
||||
],
|
||||
'reports-briefings': [
|
||||
'weekly-report', 'status-update', 'team-report', 'business-review', 'white-paper',
|
||||
'investment-thesis', 'consulting-deliverable', 'financial', 'data-viz-launch',
|
||||
],
|
||||
'product-sales': [
|
||||
'product-launch', 'launch-deck', 'feature-reveal', 'launch-slides', 'sales',
|
||||
'customer', 'product',
|
||||
],
|
||||
'engineering-talks': [
|
||||
'engineering', 'tech-sharing', 'tech-talk', 'technical-presentation',
|
||||
'system-design', 'architecture', 'developer-tutorial', 'dev-workflow', 'incident',
|
||||
'red-team', 'risk-review',
|
||||
],
|
||||
'creative-decks': [
|
||||
'marketing', 'editorial', 'zhangzara', 'creative-agency-pitch', 'brand-manifesto',
|
||||
'fashion-brand-deck', 'creator-portfolio', 'xhs', 'design-studio-deck',
|
||||
],
|
||||
'ui-product-mockups': [
|
||||
'app-web-design', 'game-ui', 'ui', 'hud', 'live-artifact', 'app-showcase',
|
||||
'product', 'mockup',
|
||||
],
|
||||
'brand-visuals': ['logo', 'brand', 'typography', 'poster', 'key-art', 'cover-art'],
|
||||
'storyboards-motion-refs': [
|
||||
'storyboard', 'dance', 'choreography', 'pose-reference', 'video-reference', 'sequence',
|
||||
],
|
||||
'social-content': ['social-media-post', 'infographic', 'explainer', 'social', 'collage'],
|
||||
'avatar-portrait': ['profile-avatar', 'portrait', 'selfie', 'identity'],
|
||||
'illustration-style': [
|
||||
'illustration', 'anime', 'fantasy', '3d-render', 'cinematic', 'crayon',
|
||||
'style-transfer', 'nature',
|
||||
],
|
||||
'motion-effects': [
|
||||
'motion-graphics', 'vfx', 'frame', 'kinetic-typography', 'logo', 'outro',
|
||||
'title', 'transition', 'animation',
|
||||
],
|
||||
'social-short-form': [
|
||||
'short-form', 'vertical', 'tiktok', 'social-meme', 'dance', 'k-pop', 'karaoke', 'captions',
|
||||
],
|
||||
'marketing-product': [
|
||||
'marketing', 'product', 'advertising', 'product-promo', 'saas',
|
||||
'website-to-video', 'brand',
|
||||
],
|
||||
'data-explainers': ['data', 'chart', 'flowchart', 'diagram', 'map', 'route', 'infographic'],
|
||||
'cinematic-story': [
|
||||
'cinematic', 'fantasy', 'action', 'anime', 'game-cinematic', 'cyberpunk',
|
||||
'nature', 'cinematic-romance', 'combat',
|
||||
],
|
||||
};
|
||||
|
||||
export function getSubcategoriesFor(parent: PluginCategorySlug): PluginSubcategoryDef[] {
|
||||
return PLUGIN_SUBCATEGORIES.filter((s) => s.parent === parent);
|
||||
}
|
||||
|
||||
/** Adapter so `categorizePlugin` works against either record shape. */
|
||||
export function pluginRecordOf(
|
||||
source: SkillRecord | TemplateRecord,
|
||||
): MatchableRecord {
|
||||
return {
|
||||
slug: source.slug,
|
||||
name: source.name,
|
||||
mode: source.mode,
|
||||
scenario: source.scenario,
|
||||
category: 'category' in source ? source.category : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for the bundled-plugin manifest format. The slug we hand to
|
||||
* `categorizePlugin` is the manifest id (`image-template-...`,
|
||||
* `video-template-...`) so the prefix-based heuristics in the
|
||||
* subcategory bundles still bite. Tags are folded into the name field
|
||||
* so `byAnySlug` can match against `infographic`, `mobile-app`, etc.
|
||||
* the same way it does for SkillRecord taxonomy values.
|
||||
*/
|
||||
export function bundledRecordOf(source: BundledPluginRecord): MatchableRecord {
|
||||
// Joining tags into `name` lets `recordSlugs` tokenize them along
|
||||
// with the slug so `byAnySlug(['mobile-app', ...])` matches.
|
||||
return {
|
||||
slug: source.manifestId,
|
||||
name: [source.title, source.tags.join(' ')].filter(Boolean).join(' '),
|
||||
mode: source.mode,
|
||||
scenario: source.scenario,
|
||||
category: source.surface,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import PluginPage, {
|
||||
getStaticPaths as getPluginStaticPaths,
|
||||
} from '../../plugins/[...slug].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
const basePaths = getPluginStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<PluginPage {...Astro.props} />
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
import PluginsPage from '../../plugins/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
||||
(locale) => ({ params: { locale: locale.code } }),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<PluginsPage />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import PluginPreviewPage, {
|
||||
getStaticPaths as getPluginPreviewStaticPaths,
|
||||
} from '../../../plugins/previews/[...slug].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
const basePaths = getPluginPreviewStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<PluginPreviewPage {...Astro.props} />
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getPublicPlugins } from '../../../plugin-registry';
|
||||
import { PREFIXED_LOCALES, isLocale, localePath } from '../../../_lib/i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return PREFIXED_LOCALES.map((locale) => ({
|
||||
params: { locale },
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const locale = isLocale(params.locale) ? params.locale : 'en';
|
||||
const plugins = getPublicPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
title: plugin.title,
|
||||
description: plugin.description,
|
||||
registryId: plugin.registryId,
|
||||
trust: plugin.trust,
|
||||
version: plugin.version,
|
||||
mode: plugin.mode,
|
||||
surface: plugin.surface,
|
||||
visualKind: plugin.visualKind,
|
||||
preview: plugin.preview
|
||||
? {
|
||||
type: plugin.preview.type,
|
||||
label: plugin.preview.label,
|
||||
poster: plugin.preview.poster,
|
||||
frameHref: plugin.preview.frameHref,
|
||||
}
|
||||
: undefined,
|
||||
tags: plugin.tags,
|
||||
capabilities: plugin.capabilities,
|
||||
href: localePath(plugin.detailHref, locale, { prefixDefault: true }),
|
||||
installCommand: plugin.installCommand,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), locale, plugins }, null, 2), {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
---
|
||||
import '../../globals.css';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import FaviconLinks from '../../_components/favicon-links.astro';
|
||||
import ResourceHints from '../../_components/resource-hints.astro';
|
||||
import GoogleAnalytics from '../../_components/google-analytics.astro';
|
||||
import { Header } from '../../_components/header';
|
||||
import HeaderEnhancer from '../../_components/header-enhancer.astro';
|
||||
import LazyImg from '../../_components/lazy-img.astro';
|
||||
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
|
||||
import PreciseLazyload from '../../_components/precise-lazyload.astro';
|
||||
import Topbar from '../../_components/topbar.astro';
|
||||
import { getCatalogCounts } from '../../_lib/catalog';
|
||||
import { getGithubRepoMeta } from '../../_lib/github';
|
||||
import { localizeContentTag } from '../../content-i18n';
|
||||
import {
|
||||
LANDING_LOCALES,
|
||||
alternateLinksForPath,
|
||||
getLandingUiCopy,
|
||||
getLocaleDefinition,
|
||||
localeFromPath,
|
||||
localizedHref,
|
||||
} from '../../i18n';
|
||||
import type { PublicPluginEntry } from '../../plugin-registry';
|
||||
import { getPublicPlugins } from '../../plugin-registry';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return getPublicPlugins().map((plugin) => ({
|
||||
params: { slug: plugin.slug },
|
||||
props: { plugin },
|
||||
}));
|
||||
}
|
||||
|
||||
const { plugin: routePlugin } = Astro.props as { plugin: PublicPluginEntry };
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const localeDef = getLocaleDefinition(locale);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const localePlugins = getPublicPlugins(locale);
|
||||
const plugin = localePlugins.find((item) => item.id === routePlugin.id) ?? routePlugin;
|
||||
const site = Astro.site ?? new URL('https://open-design.ai');
|
||||
const canonical = new URL(Astro.url.pathname, site).toString();
|
||||
const alternateLinks = alternateLinksForPath(Astro.url.pathname).map((entry) => ({
|
||||
...entry,
|
||||
href: new URL(entry.hrefPath, site).toString(),
|
||||
}));
|
||||
const xDefaultHref = new URL(alternateLinks[0]!.hrefPath, site).toString();
|
||||
const title = ui.plugins.detailTitle(plugin.title);
|
||||
const description = ui.plugins.detailDescription(
|
||||
plugin.description,
|
||||
plugin.installCommand,
|
||||
);
|
||||
const catalogCounts = await getCatalogCounts();
|
||||
const github = await getGithubRepoMeta();
|
||||
const headerHtml = renderToStaticMarkup(
|
||||
createElement(Header, { counts: catalogCounts, brandHref: '/', locale }),
|
||||
);
|
||||
const related = localePlugins
|
||||
.filter((item) => item.id !== plugin.id && item.registryId === plugin.registryId)
|
||||
.slice(0, 6);
|
||||
const previewLabel = plugin.preview?.frameHref
|
||||
? ui.plugins.interactivePreview
|
||||
: plugin.preview?.poster
|
||||
? plugin.preview.label
|
||||
: ui.plugins.preview;
|
||||
const previewFrameHref = plugin.preview?.frameHref
|
||||
? plugin.preview.frameHref.startsWith('/')
|
||||
? href(plugin.preview.frameHref)
|
||||
: plugin.preview.frameHref
|
||||
: undefined;
|
||||
const trustLabel = (entry: PublicPluginEntry) =>
|
||||
ui.plugins.trustLabels[entry.trust] ?? entry.trust;
|
||||
const detailLinks = [
|
||||
{
|
||||
href: plugin.registryUrl,
|
||||
label: ui.plugins.marketplaceJson,
|
||||
},
|
||||
plugin.sourceUrl
|
||||
? {
|
||||
href: plugin.sourceUrl,
|
||||
label: ui.plugins.sourceRepository,
|
||||
}
|
||||
: undefined,
|
||||
plugin.homepage && plugin.homepage !== plugin.sourceUrl
|
||||
? {
|
||||
href: plugin.homepage,
|
||||
label: ui.plugins.homepage,
|
||||
}
|
||||
: undefined,
|
||||
].filter((item): item is { href: string; label: string } => Boolean(item));
|
||||
const factRows = [
|
||||
[ui.plugins.facts.pluginId, plugin.id],
|
||||
[ui.plugins.facts.version, plugin.version],
|
||||
[ui.plugins.facts.registry, plugin.registryName],
|
||||
[ui.plugins.facts.mode, localizeContentTag(plugin.mode ?? plugin.surface ?? plugin.visualKind, locale) ?? plugin.mode ?? plugin.surface ?? plugin.visualKind],
|
||||
[ui.plugins.facts.license, plugin.license ?? ui.plugins.facts.notSpecified],
|
||||
plugin.publisher ? [ui.plugins.facts.publisher, plugin.publisher] : undefined,
|
||||
].filter((item): item is [string, string] => Boolean(item));
|
||||
const pluginJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: plugin.title,
|
||||
alternateName: plugin.id,
|
||||
description: plugin.description,
|
||||
applicationCategory: 'DesignApplication',
|
||||
operatingSystem: 'macOS, Windows, Linux',
|
||||
softwareVersion: plugin.version,
|
||||
inLanguage: localeDef.htmlLang,
|
||||
url: canonical,
|
||||
installUrl: plugin.sourceUrl,
|
||||
codeRepository: plugin.sourceUrl,
|
||||
license: plugin.license,
|
||||
author: plugin.publisher
|
||||
? {
|
||||
'@type': 'Organization',
|
||||
name: plugin.publisher,
|
||||
}
|
||||
: {
|
||||
'@type': 'Organization',
|
||||
name: 'Open Design',
|
||||
},
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Open Design',
|
||||
url: new URL('/', site).toString(),
|
||||
},
|
||||
};
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Open Design',
|
||||
item: new URL('/', site).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: ui.plugins.registry,
|
||||
item: new URL(href('/plugins/'), site).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: plugin.title,
|
||||
item: canonical,
|
||||
},
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={localeDef.htmlLang} dir={localeDef.dir}>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<title>{title}</title>
|
||||
<meta name='description' content={description} />
|
||||
<meta name='robots' content={plugin.yanked ? 'noindex,follow' : 'index,follow'} />
|
||||
<link rel='canonical' href={canonical} />
|
||||
{alternateLinks.map((entry) => (
|
||||
<link rel='alternate' hreflang={entry.hreflang} href={entry.href} />
|
||||
))}
|
||||
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
|
||||
<FaviconLinks />
|
||||
<ResourceHints />
|
||||
<meta property='og:type' content='article' />
|
||||
<meta property='og:site_name' content='Open Design' />
|
||||
<meta property='og:title' content={title} />
|
||||
<meta property='og:description' content={description} />
|
||||
<meta property='og:url' content={canonical} />
|
||||
<meta property='og:locale' content={localeDef.ogLocale} />
|
||||
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
|
||||
<meta property='og:locale:alternate' content={entry.ogLocale} />
|
||||
))}
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta name='twitter:title' content={title} />
|
||||
<meta name='twitter:description' content={description} />
|
||||
<script is:inline type='application/ld+json' set:html={JSON.stringify(pluginJsonLd)}></script>
|
||||
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
|
||||
<GoogleAnalytics />
|
||||
</head>
|
||||
<body>
|
||||
<div class='side-rail right' aria-hidden='true'>
|
||||
<span class='rail-text'>{ui.plugins.detailRailRight(plugin.id)}</span>
|
||||
</div>
|
||||
<div class='side-rail left' aria-hidden='true'>
|
||||
<span class='rail-text'>{plugin.registryName} · {trustLabel(plugin)}</span>
|
||||
</div>
|
||||
|
||||
<div class='shell plugin-shell'>
|
||||
<div class='site-chrome' data-chrome-headroom>
|
||||
<Topbar github={github} locale={locale} />
|
||||
<Fragment set:html={headerHtml} />
|
||||
</div>
|
||||
|
||||
<main id='top' class='plugin-detail'>
|
||||
<section class='plugin-detail-hero'>
|
||||
<div class='container plugin-detail-hero__grid'>
|
||||
<div>
|
||||
<a class='plugin-back-link' href={href('/plugins/')}>{ui.plugins.registry}</a>
|
||||
<div class='plugin-detail__badges'>
|
||||
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
|
||||
{plugin.registryName}
|
||||
</span>
|
||||
<span>{trustLabel(plugin)}</span>
|
||||
{plugin.deprecated && <span>{ui.plugins.deprecated}</span>}
|
||||
{plugin.yanked && <span>{ui.plugins.yanked}</span>}
|
||||
</div>
|
||||
<h1>{plugin.title}</h1>
|
||||
<p>{plugin.description}</p>
|
||||
<div class='plugin-detail__commands'>
|
||||
<div>
|
||||
<span>{ui.plugins.installFromRegistry}</span>
|
||||
<code>{plugin.installCommand}</code>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
data-copy-command={plugin.installCommand}
|
||||
data-copy-label={ui.plugins.copy}
|
||||
data-copied-label={ui.plugins.copied}
|
||||
data-select-label={ui.plugins.select}
|
||||
>
|
||||
{ui.plugins.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class='plugin-detail-side' aria-label={ui.plugins.previewAndFacts}>
|
||||
<div class={`plugin-detail-preview plugin-detail-preview--${plugin.visualKind}`}>
|
||||
<div class='plugin-detail-preview__head'>
|
||||
<span>{previewLabel}</span>
|
||||
<small>{plugin.preview?.type ?? plugin.visualKind}</small>
|
||||
</div>
|
||||
<div class='plugin-detail-preview__frame'>
|
||||
{previewFrameHref ? (
|
||||
<iframe
|
||||
src={previewFrameHref}
|
||||
title={`${plugin.title} preview`}
|
||||
loading='lazy'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
></iframe>
|
||||
) : plugin.preview?.poster ? (
|
||||
<LazyImg src={plugin.preview.poster} alt='' loading='priority' />
|
||||
) : (
|
||||
<div class='plugin-detail-preview__mock' aria-hidden='true'>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class='plugin-detail__facts'>
|
||||
<dl>
|
||||
{factRows.map(([label, value]) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class='plugin-detail-section'>
|
||||
<div class='container plugin-detail-grid'>
|
||||
<div class='plugin-detail-panel'>
|
||||
<span class='label'>{ui.plugins.howItResolves}</span>
|
||||
<h2>{ui.plugins.provenance}</h2>
|
||||
<p>
|
||||
{ui.plugins.provenanceBody}
|
||||
</p>
|
||||
<div class='plugin-source-list'>
|
||||
{detailLinks.map((link) => (
|
||||
<a href={link.href} target='_blank' rel='noreferrer noopener'>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='plugin-detail-panel'>
|
||||
<span class='label'>{ui.plugins.capabilities}</span>
|
||||
<h2>{ui.plugins.workflowSurface}</h2>
|
||||
<div class='plugin-tags plugin-tags--large'>
|
||||
{plugin.tags.slice(0, 8).map((tag) => <span>{localizeContentTag(tag, locale) ?? tag}</span>)}
|
||||
{plugin.capabilities.slice(0, 4).map((capability) => <span>{localizeContentTag(capability, locale) ?? capability}</span>)}
|
||||
{plugin.mode && <span>{localizeContentTag(plugin.mode, locale) ?? plugin.mode}</span>}
|
||||
{plugin.taskKind && <span>{localizeContentTag(plugin.taskKind, locale) ?? plugin.taskKind}</span>}
|
||||
</div>
|
||||
<div class='plugin-detail__commands compact'>
|
||||
<div>
|
||||
<span>{ui.plugins.directSourceFallback}</span>
|
||||
<code>{plugin.directInstallCommand}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plugin.exampleQuery && (
|
||||
<div class='plugin-detail-panel plugin-detail-panel--wide'>
|
||||
<span class='label'>{ui.plugins.examplePrompt}</span>
|
||||
<h2>{ui.plugins.howPeopleUseIt}</h2>
|
||||
<p>
|
||||
{ui.plugins.examplePromptBody}
|
||||
</p>
|
||||
<pre class='plugin-example-query'>{plugin.exampleQuery}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class='plugin-detail-section plugin-related'>
|
||||
<div class='container'>
|
||||
<div class='section-header'>
|
||||
<span class='label'>{ui.plugins.moreFrom(plugin.registryName)}</span>
|
||||
<h2>{ui.plugins.related}</h2>
|
||||
</div>
|
||||
<div class='plugin-card-grid compact'>
|
||||
{related.map((item) => (
|
||||
<article class='plugin-card'>
|
||||
<div class='plugin-card__meta'>
|
||||
<span class={`plugin-badge plugin-badge--${item.registryId}`}>
|
||||
{item.registryName}
|
||||
</span>
|
||||
<span>{item.version}</span>
|
||||
</div>
|
||||
<h3>
|
||||
<a href={href(item.detailHref)}>{item.title}</a>
|
||||
</h3>
|
||||
<code>{item.id}</code>
|
||||
<p>{item.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
document.querySelectorAll('[data-copy-command]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const command = button.getAttribute('data-copy-command') ?? '';
|
||||
const copyLabel = button.getAttribute('data-copy-label') ?? 'Copy';
|
||||
const copiedLabel = button.getAttribute('data-copied-label') ?? 'Copied';
|
||||
const selectLabel = button.getAttribute('data-select-label') ?? 'Select';
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
button.textContent = copiedLabel;
|
||||
window.setTimeout(() => {
|
||||
button.textContent = copyLabel;
|
||||
}, 1400);
|
||||
} catch {
|
||||
button.textContent = selectLabel;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fetch('https://api.github.com/repos/nexu-io/open-design')
|
||||
.then((response) => response.ok ? response.json() : undefined)
|
||||
.then((repo) => {
|
||||
const count = Number(repo?.stargazers_count);
|
||||
if (!Number.isFinite(count)) return;
|
||||
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
|
||||
document.querySelectorAll('[data-github-stars]').forEach((node) => {
|
||||
node.textContent = formatted;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
<HeaderEnhancer />
|
||||
<LocaleSwitcherScript />
|
||||
<PreciseLazyload />
|
||||
</body>
|
||||
</html>
|
||||
221
apps/landing-page/app/pages/plugins/[slug]/index.astro
Normal file
221
apps/landing-page/app/pages/plugins/[slug]/index.astro
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/<manifest-id>/ — bundled-plugin detail page.
|
||||
*
|
||||
* One static page per manifest under `plugins/_official/`, built from
|
||||
* `open-design.json`. The page is deliberately information-dense
|
||||
* rather than visual: bundled plugins ship a poster (already on R2)
|
||||
* for the hero, then a structured read-out of mode / scenario /
|
||||
* platform / tags / triggers / author / GitHub source.
|
||||
*
|
||||
* Note: the route's `slug` parameter is the manifest `name` field
|
||||
* (e.g. `image-template-3d-stone-staircase-evolution-infographic`),
|
||||
* not the folder name. Manifest ids are globally unique within the
|
||||
* registry so there's no risk of collision across buckets.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import {
|
||||
getBundledPlugins,
|
||||
type BundledPluginRecord,
|
||||
} from '../../../_lib/bundled-plugins';
|
||||
import { localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return getBundledPlugins().map((plugin) => ({
|
||||
params: { slug: plugin.manifestId },
|
||||
props: { plugin },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plugin: BundledPluginRecord;
|
||||
}
|
||||
|
||||
const { plugin } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
/*
|
||||
* Author normalisation. First-party manifests authored by Open Design
|
||||
* point at the org URL (`https://github.com/nexu-io`) — fine for the
|
||||
* daemon, but on the marketing site that lands the visitor on a bare
|
||||
* org page rather than the project repo. Map the org URL to the
|
||||
* concrete `nexu-io/open-design` repo so the "Open Design" attribution
|
||||
* is actionable.
|
||||
*/
|
||||
const ORG_TO_REPO: Record<string, string> = {
|
||||
'https://github.com/nexu-io': 'https://github.com/nexu-io/open-design',
|
||||
'https://github.com/nexu-io/': 'https://github.com/nexu-io/open-design',
|
||||
};
|
||||
const authorUrl = plugin.authorUrl
|
||||
? (ORG_TO_REPO[plugin.authorUrl.replace(/\/$/, '')] ?? plugin.authorUrl)
|
||||
: undefined;
|
||||
|
||||
const bucketLabel: Record<BundledPluginRecord['bucket'], string> = {
|
||||
examples: 'Example',
|
||||
'image-templates': 'Image template',
|
||||
'video-templates': 'Video template',
|
||||
scenarios: 'Scenario',
|
||||
'design-systems': 'Design system',
|
||||
atoms: 'Atom',
|
||||
};
|
||||
|
||||
const title = `${plugin.title} · Open Design plugin`;
|
||||
const description = plugin.description;
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Plugins', item: new URL('/plugins/', Astro.site).toString() },
|
||||
{ '@type': 'ListItem', position: 3, name: plugin.title, item: new URL(plugin.detailHref, Astro.site).toString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: plugin.title,
|
||||
description,
|
||||
codeRepository: plugin.sourceUrl,
|
||||
programmingLanguage: 'JSON',
|
||||
keywords: plugin.tags.join(', '),
|
||||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">{plugin.title}</span>
|
||||
</nav>
|
||||
|
||||
<article class="detail">
|
||||
<header class="detail-head">
|
||||
<span class="label">
|
||||
Plugin · {bucketLabel[plugin.bucket]}
|
||||
</span>
|
||||
<h1 class="display">{plugin.title}<span class="dot">.</span></h1>
|
||||
<p class="lead">{description}</p>
|
||||
<div class="detail-actions">
|
||||
<a class="btn btn-primary" href="https://github.com/nexu-io/open-design/releases" target="_blank" rel="noopener">
|
||||
Use this plugin →
|
||||
</a>
|
||||
<a class="btn btn-ghost" href={plugin.sourceUrl} target="_blank" rel="noopener">
|
||||
Find on GitHub →
|
||||
</a>
|
||||
{plugin.homepage && plugin.homepage !== plugin.sourceUrl && (
|
||||
<a class="btn btn-ghost" href={plugin.homepage} target="_blank" rel="noopener">
|
||||
Homepage ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{plugin.previewPoster && (
|
||||
<figure class="detail-preview">
|
||||
{plugin.previewType === 'video' && plugin.previewVideo ? (
|
||||
/*
|
||||
* Bundled video templates carry their poster + playable MP4
|
||||
* as separate URLs in the manifest. Use both directly —
|
||||
* earlier we tried to derive the MP4 URL from the poster
|
||||
* URL by string-replacing `thumbnail.jpg` → `default.mp4`,
|
||||
* which only worked for one Cloudflare Stream URL shape and
|
||||
* silently produced an unplayable `<source>` for everything
|
||||
* else.
|
||||
*/
|
||||
<video
|
||||
class="detail-preview-video"
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={plugin.previewPoster}
|
||||
>
|
||||
<source src={plugin.previewVideo} type="video/mp4" />
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
class="detail-preview-static"
|
||||
src={plugin.previewPoster}
|
||||
alt={`${plugin.title} preview`}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
)}
|
||||
<figcaption>Preview from the bundled-plugin manifest.</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<dl class="detail-meta">
|
||||
{plugin.mode && (
|
||||
<Fragment>
|
||||
<dt>Mode</dt>
|
||||
<dd>{plugin.mode}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{plugin.scenario && (
|
||||
<Fragment>
|
||||
<dt>Scenario</dt>
|
||||
<dd>{plugin.scenario}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{plugin.platform && (
|
||||
<Fragment>
|
||||
<dt>Platform</dt>
|
||||
<dd>{plugin.platform}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{plugin.surface && plugin.surface !== plugin.mode && (
|
||||
<Fragment>
|
||||
<dt>Surface</dt>
|
||||
<dd>{plugin.surface}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{plugin.authorName && (
|
||||
<Fragment>
|
||||
<dt>Author</dt>
|
||||
<dd>
|
||||
{authorUrl ? (
|
||||
<a href={authorUrl} target="_blank" rel="noopener">{plugin.authorName} ↗</a>
|
||||
) : plugin.authorName}
|
||||
</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
<Fragment>
|
||||
<dt>Manifest id</dt>
|
||||
<dd><code>{plugin.manifestId}</code></dd>
|
||||
</Fragment>
|
||||
</dl>
|
||||
|
||||
{plugin.tags.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>Tags</h2>
|
||||
<ul class="trigger-list">
|
||||
{plugin.tags.map((t) => <li><code>{t}</code></li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.detail-preview-static {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
.detail-preview-video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: 1px solid var(--line);
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
61
apps/landing-page/app/pages/plugins/craft/index.astro
Normal file
61
apps/landing-page/app/pages/plugins/craft/index.astro
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/craft/ — craft principles under the new Plugins hub.
|
||||
*
|
||||
* Same renderer as the legacy `/craft/` page, with Plugins-aware
|
||||
* breadcrumb and `active="plugins"` for the nav highlight.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import { getCraftRecords } from '../../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const craft = await getCraftRecords(locale);
|
||||
|
||||
const title = `Craft · ${craft.length} · Open Design`;
|
||||
const description = ui.catalog.craft.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/plugins/craft/', Astro.site).toString(),
|
||||
numberOfItems: craft.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">Craft</span>
|
||||
</nav>
|
||||
|
||||
<header class="catalog-head">
|
||||
<span class="label">Plugins · Craft</span>
|
||||
<h1 class="display">{ui.catalog.craft.heading(craft.length)}</h1>
|
||||
<p class="lead">{ui.catalog.craft.lead}</p>
|
||||
</header>
|
||||
|
||||
<section class="catalog-grid" aria-label={ui.catalog.craft.allAria}>
|
||||
<ol>
|
||||
{craft.map((c, idx) => (
|
||||
<li class="catalog-row">
|
||||
<a href={href(`/craft/${c.slug}/`)}>
|
||||
<span class="row-index">{String(idx + 1).padStart(2, '0')}</span>
|
||||
<span class="row-body">
|
||||
<span class="row-name">{c.name}</span>
|
||||
<span class="row-desc">{c.summary}</span>
|
||||
</span>
|
||||
<span class="row-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,361 +1,195 @@
|
|||
---
|
||||
import '../../globals.css';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import FaviconLinks from '../../_components/favicon-links.astro';
|
||||
import ResourceHints from '../../_components/resource-hints.astro';
|
||||
import GoogleAnalytics from '../../_components/google-analytics.astro';
|
||||
import { Header } from '../../_components/header';
|
||||
import HeaderEnhancer from '../../_components/header-enhancer.astro';
|
||||
import LazyImg from '../../_components/lazy-img.astro';
|
||||
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
|
||||
import PreciseLazyload from '../../_components/precise-lazyload.astro';
|
||||
import Topbar from '../../_components/topbar.astro';
|
||||
import { getCatalogCounts } from '../../_lib/catalog';
|
||||
import { getGithubRepoMeta } from '../../_lib/github';
|
||||
import { localizeContentTag } from '../../content-i18n';
|
||||
import {
|
||||
LANDING_LOCALES,
|
||||
alternateLinksForPath,
|
||||
getLandingUiCopy,
|
||||
getLocaleDefinition,
|
||||
localeFromPath,
|
||||
localizedHref,
|
||||
} from '../../i18n';
|
||||
import { getPublicPlugins, getRegistryCounts } from '../../plugin-registry';
|
||||
/*
|
||||
* /plugins/ — top-level plugin library hub.
|
||||
*
|
||||
* Replaces the prior `/skills/`, `/templates/`, `/systems/`, `/craft/`
|
||||
* top-level entries with a single library that mirrors how the in-app
|
||||
* Plugins home is organised: artifact-producing entries under
|
||||
* Templates, instruction-only skills under Skills, brand systems under
|
||||
* Systems, craft principles under Craft. Visitors who land here can
|
||||
* mentally map the catalogue to what they will see when they open
|
||||
* Open Design itself.
|
||||
*
|
||||
* Anyone who already knows the slug they want skips the hub entirely
|
||||
* via `/skills/<slug>/`, `/templates/<slug>/` etc. Detail-page URLs
|
||||
* stay where they are; the hub is purely a discovery surface.
|
||||
*/
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import { getCraftRecords, getSystemRecords } from '../../_lib/catalog';
|
||||
import { getBundledPlugins } from '../../_lib/bundled-plugins';
|
||||
import { categorizePlugin, bundledRecordOf } from '../../_lib/plugin-facets';
|
||||
import { localeFromPath, localizedHref } from '../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const localeDef = getLocaleDefinition(locale);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const plugins = getPublicPlugins(locale);
|
||||
const counts = getRegistryCounts(plugins);
|
||||
const catalogCounts = await getCatalogCounts();
|
||||
const github = await getGithubRepoMeta();
|
||||
const previewCount = plugins.filter((plugin) => plugin.preview).length;
|
||||
const surfaceCount = new Set(
|
||||
plugins.map((plugin) => plugin.mode ?? plugin.surface ?? plugin.visualKind),
|
||||
).size;
|
||||
const featuredPlugins = plugins
|
||||
.filter((plugin) =>
|
||||
Boolean(plugin.preview) ||
|
||||
plugin.visualKind === 'deck' ||
|
||||
plugin.visualKind === 'image' ||
|
||||
plugin.visualKind === 'video',
|
||||
)
|
||||
.slice(0, 3);
|
||||
const showcasePlugins =
|
||||
featuredPlugins.length >= 3 ? featuredPlugins : plugins.slice(0, 3);
|
||||
const site = Astro.site ?? new URL('https://open-design.ai');
|
||||
const canonical = new URL(Astro.url.pathname, site).toString();
|
||||
const alternateLinks = alternateLinksForPath(Astro.url.pathname).map((entry) => ({
|
||||
...entry,
|
||||
href: new URL(entry.hrefPath, site).toString(),
|
||||
}));
|
||||
const xDefaultHref = new URL(alternateLinks[0]!.hrefPath, site).toString();
|
||||
const title = ui.plugins.registryTitle;
|
||||
const description = ui.plugins.registryDescription(counts.all);
|
||||
const headerHtml = renderToStaticMarkup(
|
||||
createElement(Header, { counts: catalogCounts, brandHref: '/', locale }),
|
||||
|
||||
// Counts come from two sources because the underlying catalog pages
|
||||
// each use whichever shape carries the data they render. Templates +
|
||||
// Skills read `plugins/_official/` (the daemon's bundled-plugin
|
||||
// registry, which is what the in-app Plugins home shows). Systems
|
||||
// reads the legacy SystemRecord set so its detail rendering can keep
|
||||
// using palette swatches from DESIGN.md — the bundled-plugin manifest
|
||||
// doesn't carry palette data. The two design-systems data sources
|
||||
// overlap 1:1 so the count stays consistent.
|
||||
const [bundled, systems, craft] = await Promise.all([
|
||||
Promise.resolve(getBundledPlugins()),
|
||||
getSystemRecords(locale),
|
||||
getCraftRecords(locale),
|
||||
]);
|
||||
|
||||
const nonSystems = bundled.filter((p) => p.bucket !== 'design-systems');
|
||||
const templatesSet = nonSystems.filter(
|
||||
(p) => categorizePlugin(bundledRecordOf(p)) !== null,
|
||||
);
|
||||
const itemListJsonLd = {
|
||||
const skillsSet = nonSystems.filter(
|
||||
(p) => categorizePlugin(bundledRecordOf(p)) === null,
|
||||
);
|
||||
|
||||
const templatesCount = templatesSet.length;
|
||||
const skillsCount = skillsSet.length;
|
||||
const systemsCount = systems.length;
|
||||
const craftCount = craft.length;
|
||||
const totalCount = templatesCount + skillsCount + systemsCount + craftCount;
|
||||
|
||||
const title = 'Plugins · Open Design';
|
||||
const description =
|
||||
'The Open Design plugin library — runnable templates for prototypes, decks, image and video generators, instruction skills the agent loads mid-task, brand systems, and craft principles. Every entry is a flat file in the open-source repo.';
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Open Design Plugin Registry',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: canonical,
|
||||
numberOfItems: counts.all,
|
||||
itemListElement: plugins.slice(0, 120).map((plugin, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(href(plugin.detailHref), site).toString(),
|
||||
name: plugin.title,
|
||||
description: plugin.description,
|
||||
})),
|
||||
};
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Open Design',
|
||||
item: new URL('/', site).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: ui.plugins.registry,
|
||||
item: canonical,
|
||||
},
|
||||
],
|
||||
url: new URL('/plugins/', Astro.site).toString(),
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Open Design',
|
||||
url: Astro.site?.toString(),
|
||||
},
|
||||
numberOfItems: totalCount,
|
||||
};
|
||||
|
||||
const displayKind = (plugin: (typeof plugins)[number]) =>
|
||||
localizeContentTag(plugin.mode ?? plugin.surface ?? plugin.visualKind, locale) ??
|
||||
plugin.mode ??
|
||||
plugin.surface ??
|
||||
plugin.visualKind;
|
||||
|
||||
const primaryMeta = (plugin: (typeof plugins)[number]) =>
|
||||
[displayKind(plugin), plugin.preview?.label]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
const trustLabel = (plugin: (typeof plugins)[number]) =>
|
||||
ui.plugins.trustLabels[plugin.trust] ?? plugin.trust;
|
||||
const tiles = [
|
||||
{
|
||||
href: href('/plugins/templates/'),
|
||||
title: 'Templates',
|
||||
count: templatesCount,
|
||||
blurb:
|
||||
'Visual, runnable templates — prototypes, slides, image and video generators, motion compositions. Every entry ships an example.html so you can fork, swap data, and ship.',
|
||||
},
|
||||
{
|
||||
href: href('/plugins/skills/'),
|
||||
title: 'Skills',
|
||||
count: skillsCount,
|
||||
blurb:
|
||||
'Instruction skills the agent loads mid-task — copywriting, color theory, creative direction, brainstorming. Pure SKILL.md prose; the output depends on your input.',
|
||||
},
|
||||
{
|
||||
href: href('/plugins/systems/'),
|
||||
title: 'Systems',
|
||||
count: systemsCount,
|
||||
blurb:
|
||||
'Brand-anchored design systems — palette, typography, motion, voice. Snap a project to a system and every plugin output inherits the same identity.',
|
||||
},
|
||||
{
|
||||
href: href('/plugins/craft/'),
|
||||
title: 'Craft',
|
||||
count: craftCount,
|
||||
blurb:
|
||||
'Brand-agnostic craft rules — accessibility, RTL, motion easing, photography ethics. Skills opt in via `od.craft.requires` so a plugin inherits the right rigour automatically.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={localeDef.htmlLang} dir={localeDef.dir}>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<title>{title}</title>
|
||||
<meta name='description' content={description} />
|
||||
<meta name='robots' content='index,follow' />
|
||||
<link rel='canonical' href={canonical} />
|
||||
{alternateLinks.map((entry) => (
|
||||
<link rel='alternate' hreflang={entry.hreflang} href={entry.href} />
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<span class="label">Plugin library</span>
|
||||
<h1 class="display">
|
||||
{totalCount} composable pieces<span class="dot">.</span>
|
||||
</h1>
|
||||
<p class="lead">
|
||||
Open Design is built around four kinds of plugin. Templates and Skills are what your agent
|
||||
runs; Systems and Craft are how it stays on-brand and accessible. Pick a section to drill
|
||||
in, or jump straight to a slug if you already know which one you want.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="plugins-tile-grid" aria-label="Plugin library categories">
|
||||
{tiles.map((tile) => (
|
||||
<a class="plugins-tile" href={tile.href}>
|
||||
<div class="plugins-tile-head">
|
||||
<h2 class="plugins-tile-title">{tile.title}</h2>
|
||||
<span class="plugins-tile-count">{tile.count}</span>
|
||||
</div>
|
||||
<p class="plugins-tile-blurb">{tile.blurb}</p>
|
||||
<span class="plugins-tile-cta">Browse {tile.title.toLowerCase()} →</span>
|
||||
</a>
|
||||
))}
|
||||
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
|
||||
<FaviconLinks />
|
||||
<ResourceHints />
|
||||
<meta property='og:type' content='website' />
|
||||
<meta property='og:site_name' content='Open Design' />
|
||||
<meta property='og:title' content={title} />
|
||||
<meta property='og:description' content={description} />
|
||||
<meta property='og:url' content={canonical} />
|
||||
<meta property='og:locale' content={localeDef.ogLocale} />
|
||||
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
|
||||
<meta property='og:locale:alternate' content={entry.ogLocale} />
|
||||
))}
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta name='twitter:title' content={title} />
|
||||
<meta name='twitter:description' content={description} />
|
||||
<script is:inline type='application/ld+json' set:html={JSON.stringify(itemListJsonLd)}></script>
|
||||
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
|
||||
<GoogleAnalytics />
|
||||
</head>
|
||||
<body>
|
||||
<div class='side-rail right' aria-hidden='true'>
|
||||
<span class='rail-text'>{ui.plugins.directoryRailRight}</span>
|
||||
</div>
|
||||
<div class='side-rail left' aria-hidden='true'>
|
||||
<span class='rail-text'>{ui.plugins.directoryRailLeft}</span>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<div class='shell plugin-shell'>
|
||||
<div class='site-chrome' data-chrome-headroom>
|
||||
<Topbar github={github} locale={locale} />
|
||||
<Fragment set:html={headerHtml} />
|
||||
</div>
|
||||
|
||||
<main id='top' class='plugin-directory'>
|
||||
<section class='plugin-hero'>
|
||||
<div class='container plugin-hero__grid'>
|
||||
<div>
|
||||
<span class='label'>{ui.plugins.heroLabel}</span>
|
||||
<h1>{ui.plugins.heroTitle}</h1>
|
||||
<p>
|
||||
{ui.plugins.heroBody}
|
||||
</p>
|
||||
<div class='plugin-hero__actions'>
|
||||
<a class='btn btn-primary' href='#registry-results'>
|
||||
{ui.plugins.browseRegistry}
|
||||
</a>
|
||||
<a class='btn btn-ghost' href='https://raw.githubusercontent.com/nexu-io/open-design/main/plugins/registry/community/open-design-marketplace.json' target='_blank' rel='noreferrer noopener'>
|
||||
{ui.plugins.communityMarketplace}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class='plugin-hero__panel' aria-label={ui.plugins.preview}>
|
||||
<div class='plugin-showcase__head'>
|
||||
<span>{ui.plugins.preview}</span>
|
||||
<strong>{counts.all}</strong>
|
||||
<small>{ui.plugins.installableEntries}</small>
|
||||
</div>
|
||||
<div class='plugin-showcase__list'>
|
||||
{showcasePlugins.map((plugin, index) => (
|
||||
<a
|
||||
class={`plugin-showcase-item plugin-showcase-item--${plugin.visualKind}`}
|
||||
href={href(plugin.detailHref)}
|
||||
>
|
||||
<span class='plugin-showcase-item__visual' aria-hidden='true'>
|
||||
{plugin.preview?.poster ? (
|
||||
<LazyImg
|
||||
src={plugin.preview.poster}
|
||||
alt=''
|
||||
loading={index < 4 ? 'eager' : 'precise'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span class='plugin-showcase-item__copy'>
|
||||
<small>0{index + 1} · {displayKind(plugin)}</small>
|
||||
<strong>{plugin.title}</strong>
|
||||
<em>{plugin.id}</em>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<dl class='plugin-hero__stats'>
|
||||
<div>
|
||||
<dt>{counts.official}</dt>
|
||||
<dd>{ui.plugins.official.toLowerCase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{previewCount}</dt>
|
||||
<dd>{ui.plugins.withPreview}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{surfaceCount}</dt>
|
||||
<dd>{ui.plugins.surfaces}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class='plugin-registry-section' id='registry-results'>
|
||||
<div class='container'>
|
||||
<div class='plugin-toolbar' data-plugin-toolbar>
|
||||
<div>
|
||||
<span class='label'>{ui.plugins.availableFromSources}</span>
|
||||
<h2>{ui.plugins.registryEntries}</h2>
|
||||
</div>
|
||||
<label class='plugin-search'>
|
||||
<span class='sr-only'>{ui.plugins.searchPlugins}</span>
|
||||
<input data-plugin-search type='search' placeholder={ui.plugins.searchPlaceholder} autocomplete='off' />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class='plugin-filter-row' aria-label={ui.plugins.filtersLabel}>
|
||||
<button class='plugin-filter is-active' type='button' data-plugin-filter='all' aria-pressed='true'>
|
||||
{ui.plugins.all} <span>{counts.all}</span>
|
||||
</button>
|
||||
<button class='plugin-filter' type='button' data-plugin-filter='official' aria-pressed='false'>
|
||||
{ui.plugins.official} <span>{counts.official}</span>
|
||||
</button>
|
||||
<button class='plugin-filter' type='button' data-plugin-filter='community' aria-pressed='false'>
|
||||
{ui.plugins.community} <span>{counts.community}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class='plugin-result-count'>
|
||||
<span data-plugin-visible-count>{counts.all}</span> {ui.plugins.visiblePlugins}
|
||||
</p>
|
||||
|
||||
<div class='plugin-card-grid'>
|
||||
{plugins.map((plugin) => (
|
||||
<article
|
||||
class='plugin-card'
|
||||
data-plugin-card
|
||||
data-registry={plugin.registryId}
|
||||
data-search={plugin.searchText}
|
||||
>
|
||||
<a
|
||||
class={`plugin-card__preview plugin-card__preview--${plugin.visualKind}`}
|
||||
href={href(plugin.detailHref)}
|
||||
aria-label={ui.plugins.openDetails(plugin.title)}
|
||||
>
|
||||
{plugin.preview?.poster ? (
|
||||
<LazyImg src={plugin.preview.poster} alt='' loading='precise' />
|
||||
) : (
|
||||
<span class='plugin-card__mock' aria-hidden='true'>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
<span class='plugin-card__preview-label'>
|
||||
{primaryMeta(plugin)}
|
||||
</span>
|
||||
</a>
|
||||
<div class='plugin-card__meta'>
|
||||
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
|
||||
{plugin.registryName}
|
||||
</span>
|
||||
<span>{plugin.version}</span>
|
||||
</div>
|
||||
<h3>
|
||||
<a href={href(plugin.detailHref)}>{plugin.title}</a>
|
||||
</h3>
|
||||
<code>{plugin.id}</code>
|
||||
<p>{plugin.description}</p>
|
||||
<div class='plugin-tags'>
|
||||
{plugin.tags.slice(0, 5).map((tag) => <span>{tag}</span>)}
|
||||
{plugin.capabilities.slice(0, 3).map((capability) => (
|
||||
<span>{capability}</span>
|
||||
))}
|
||||
</div>
|
||||
<div class='plugin-card__footer'>
|
||||
<a href={href(plugin.detailHref)}>{ui.plugins.details}</a>
|
||||
<span>{trustLabel(plugin)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
const searchInput = document.querySelector('[data-plugin-search]');
|
||||
const cards = Array.from(document.querySelectorAll('[data-plugin-card]'));
|
||||
const filterButtons = Array.from(document.querySelectorAll('[data-plugin-filter]'));
|
||||
const visibleCount = document.querySelector('[data-plugin-visible-count]');
|
||||
let activeFilter = 'all';
|
||||
|
||||
const applyFilters = () => {
|
||||
const query = String(searchInput?.value ?? '').trim().toLowerCase();
|
||||
let total = 0;
|
||||
for (const card of cards) {
|
||||
const matchesFilter = activeFilter === 'all' || card.dataset.registry === activeFilter;
|
||||
const matchesSearch = !query || String(card.dataset.search ?? '').includes(query);
|
||||
const visible = matchesFilter && matchesSearch;
|
||||
card.hidden = !visible;
|
||||
if (visible) total += 1;
|
||||
}
|
||||
if (visibleCount) {
|
||||
visibleCount.textContent = String(total);
|
||||
}
|
||||
};
|
||||
|
||||
searchInput?.addEventListener('input', applyFilters);
|
||||
for (const button of filterButtons) {
|
||||
button.addEventListener('click', () => {
|
||||
activeFilter = button.dataset.pluginFilter ?? 'all';
|
||||
for (const item of filterButtons) {
|
||||
const selected = item === button;
|
||||
item.classList.toggle('is-active', selected);
|
||||
item.setAttribute('aria-pressed', String(selected));
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
fetch('https://api.github.com/repos/nexu-io/open-design')
|
||||
.then((response) => response.ok ? response.json() : undefined)
|
||||
.then((repo) => {
|
||||
const count = Number(repo?.stargazers_count);
|
||||
if (!Number.isFinite(count)) return;
|
||||
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
|
||||
document.querySelectorAll('[data-github-stars]').forEach((node) => {
|
||||
node.textContent = formatted;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
<HeaderEnhancer />
|
||||
<LocaleSwitcherScript />
|
||||
<PreciseLazyload />
|
||||
</body>
|
||||
</html>
|
||||
<style>
|
||||
.plugins-tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin: 32px 0 64px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.plugins-tile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.plugins-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 28px 28px 24px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-warm);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.16s ease, transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.plugins-tile:hover {
|
||||
border-color: var(--ink);
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.plugins-tile-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
}
|
||||
.plugins-tile-title {
|
||||
margin: 0;
|
||||
font-family: var(--serif);
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1;
|
||||
}
|
||||
.plugins-tile-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--ink-mute);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plugins-tile-blurb {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.plugins-tile-cta {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,967 +0,0 @@
|
|||
---
|
||||
import { getPluginPreviewHtml, getPublicPlugins } from '../../../plugin-registry';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
getLocaleDefinition,
|
||||
localeFromPath,
|
||||
type LandingLocaleCode,
|
||||
} from '../../../i18n';
|
||||
|
||||
type PreviewTextMap = Record<string, string>;
|
||||
type PreviewTerms = {
|
||||
howItWorks: string;
|
||||
viewOnGithub: string;
|
||||
quickstart: string;
|
||||
gettingStarted: string;
|
||||
docs: string;
|
||||
onThisPage: string;
|
||||
roadmap: string;
|
||||
showcase: string;
|
||||
search: string;
|
||||
concepts: string;
|
||||
authentication: string;
|
||||
syncEngine: string;
|
||||
blockLevelDeltas: string;
|
||||
conflictResolution: string;
|
||||
resumableUploads: string;
|
||||
install: string;
|
||||
configuration: string;
|
||||
subcommands: string;
|
||||
syncFirstFolder: string;
|
||||
installCli: string;
|
||||
cliDistributed: string;
|
||||
verifyInstall: string;
|
||||
authenticateStep: string;
|
||||
signIn: string;
|
||||
opensBrowser: string;
|
||||
loggedIn: string;
|
||||
note: string;
|
||||
noBrowser: string;
|
||||
syncFolder: string;
|
||||
pickLocal: string;
|
||||
excludingFiles: string;
|
||||
addIgnore: string;
|
||||
whereNext: string;
|
||||
readConflict: string;
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
|
||||
const PREVIEW_TERMS: Record<Exclude<LandingLocaleCode, 'en'>, PreviewTerms> = {
|
||||
zh: {
|
||||
howItWorks: '工作方式',
|
||||
viewOnGithub: '在 GitHub 查看',
|
||||
quickstart: '快速开始',
|
||||
gettingStarted: '入门',
|
||||
docs: '文档',
|
||||
onThisPage: '本页内容',
|
||||
roadmap: '路线图',
|
||||
showcase: '示例',
|
||||
search: '搜索',
|
||||
concepts: '概念',
|
||||
authentication: '身份验证',
|
||||
syncEngine: '同步引擎',
|
||||
blockLevelDeltas: '块级增量',
|
||||
conflictResolution: '冲突解决',
|
||||
resumableUploads: '可续传上传',
|
||||
install: '安装',
|
||||
configuration: '配置',
|
||||
subcommands: '子命令',
|
||||
syncFirstFolder: '五分钟内同步第一个文件夹',
|
||||
installCli: '安装 CLI',
|
||||
cliDistributed: 'CLI 以单一二进制文件发布,支持 macOS、Linux 和 Windows。',
|
||||
verifyInstall: '验证安装:',
|
||||
authenticateStep: '登录认证',
|
||||
signIn: '使用 Filebase 账号登录。令牌会存储在 <code>~/.config/filebase/credentials</code>。',
|
||||
opensBrowser: '# → 打开浏览器',
|
||||
loggedIn: '# ✓ 已以 you@example.com 登录',
|
||||
note: '说明',
|
||||
noBrowser: '在没有浏览器的服务器上,可以使用 <code>filebase auth login --device</code> 走设备码流程。',
|
||||
syncFolder: '同步文件夹',
|
||||
pickLocal: '选择本地目录并关联到远端根目录。Filebase 会监听变更,并在后台推送块级差异。',
|
||||
excludingFiles: '排除文件',
|
||||
addIgnore: '在同步文件夹根目录添加 <code>.filebaseignore</code>。语法与 <code>.gitignore</code> 相同:',
|
||||
whereNext: '下一步',
|
||||
readConflict: '阅读 <a href="#">冲突解决</a> 了解 Filebase 如何合并并发编辑,或直接查看 <a href="#">CLI 参考</a> 获取完整子命令列表。',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
'zh-tw': {
|
||||
howItWorks: '運作方式',
|
||||
viewOnGithub: '在 GitHub 查看',
|
||||
quickstart: '快速開始',
|
||||
gettingStarted: '入門',
|
||||
docs: '文件',
|
||||
onThisPage: '本頁內容',
|
||||
roadmap: '路線圖',
|
||||
showcase: '示例',
|
||||
search: '搜尋',
|
||||
concepts: '概念',
|
||||
authentication: '身份驗證',
|
||||
syncEngine: '同步引擎',
|
||||
blockLevelDeltas: '區塊級增量',
|
||||
conflictResolution: '衝突解決',
|
||||
resumableUploads: '可續傳上傳',
|
||||
install: '安裝',
|
||||
configuration: '設定',
|
||||
subcommands: '子命令',
|
||||
syncFirstFolder: '五分鐘內同步第一個資料夾',
|
||||
installCli: '安裝 CLI',
|
||||
cliDistributed: 'CLI 以單一二進位檔發布,支援 macOS、Linux 和 Windows。',
|
||||
verifyInstall: '驗證安裝:',
|
||||
authenticateStep: '登入認證',
|
||||
signIn: '使用 Filebase 帳號登入。權杖會儲存在 <code>~/.config/filebase/credentials</code>。',
|
||||
opensBrowser: '# → 開啟瀏覽器',
|
||||
loggedIn: '# ✓ 已以 you@example.com 登入',
|
||||
note: '說明',
|
||||
noBrowser: '在沒有瀏覽器的伺服器上,可以使用 <code>filebase auth login --device</code> 走裝置碼流程。',
|
||||
syncFolder: '同步資料夾',
|
||||
pickLocal: '選擇本地目錄並連結到遠端根目錄。Filebase 會監聽變更,並在背景推送區塊級差異。',
|
||||
excludingFiles: '排除檔案',
|
||||
addIgnore: '在同步資料夾根目錄新增 <code>.filebaseignore</code>。語法與 <code>.gitignore</code> 相同:',
|
||||
whereNext: '下一步',
|
||||
readConflict: '閱讀 <a href="#">衝突解決</a> 了解 Filebase 如何合併並行編輯,或直接查看 <a href="#">CLI 參考</a> 取得完整子命令列表。',
|
||||
previous: '上一頁',
|
||||
next: '下一頁',
|
||||
},
|
||||
ja: {
|
||||
howItWorks: '仕組み',
|
||||
viewOnGithub: 'GitHubで見る',
|
||||
quickstart: 'クイックスタート',
|
||||
gettingStarted: 'はじめに',
|
||||
docs: 'ドキュメント',
|
||||
onThisPage: 'このページ',
|
||||
roadmap: 'ロードマップ',
|
||||
showcase: 'ショーケース',
|
||||
search: '検索',
|
||||
concepts: 'コンセプト',
|
||||
authentication: '認証',
|
||||
syncEngine: '同期エンジン',
|
||||
blockLevelDeltas: 'ブロック単位の差分',
|
||||
conflictResolution: '競合解決',
|
||||
resumableUploads: '再開可能アップロード',
|
||||
install: 'インストール',
|
||||
configuration: '設定',
|
||||
subcommands: 'サブコマンド',
|
||||
syncFirstFolder: '最初のフォルダを5分以内に同期します',
|
||||
installCli: 'CLIをインストール',
|
||||
cliDistributed: 'CLIはmacOS、Linux、Windows向けの単一バイナリとして配布されます。',
|
||||
verifyInstall: 'インストールを確認:',
|
||||
authenticateStep: '認証',
|
||||
signIn: 'Filebaseアカウントでサインインします。トークンは<code>~/.config/filebase/credentials</code>に保存されます。',
|
||||
opensBrowser: '# → ブラウザを開きます',
|
||||
loggedIn: '# ✓ you@example.comとしてログインしました',
|
||||
note: '注記',
|
||||
noBrowser: 'ブラウザのないサーバーでは、<code>filebase auth login --device</code>でデバイスコードフローを使います。',
|
||||
syncFolder: 'フォルダを同期',
|
||||
pickLocal: 'ローカルディレクトリを選び、リモートルートにリンクします。Filebaseは変更を監視し、差分をバックグラウンドで送信します。',
|
||||
excludingFiles: 'ファイルを除外',
|
||||
addIgnore: '同期フォルダのルートに<code>.filebaseignore</code>を追加します。構文は<code>.gitignore</code>と同じです:',
|
||||
whereNext: '次のステップ',
|
||||
readConflict: '<a href="#">競合解決</a>を読んで同時編集のマージを理解するか、<a href="#">CLIリファレンス</a>で全サブコマンドを確認します。',
|
||||
previous: '前へ',
|
||||
next: '次へ',
|
||||
},
|
||||
ko: {
|
||||
howItWorks: '작동 방식',
|
||||
viewOnGithub: 'GitHub에서 보기',
|
||||
quickstart: '빠른 시작',
|
||||
gettingStarted: '시작하기',
|
||||
docs: '문서',
|
||||
onThisPage: '이 페이지',
|
||||
roadmap: '로드맵',
|
||||
showcase: '쇼케이스',
|
||||
search: '검색',
|
||||
concepts: '개념',
|
||||
authentication: '인증',
|
||||
syncEngine: '동기화 엔진',
|
||||
blockLevelDeltas: '블록 단위 변경',
|
||||
conflictResolution: '충돌 해결',
|
||||
resumableUploads: '이어 올리기',
|
||||
install: '설치',
|
||||
configuration: '설정',
|
||||
subcommands: '하위 명령',
|
||||
syncFirstFolder: '첫 폴더를 5분 안에 동기화합니다',
|
||||
installCli: 'CLI 설치',
|
||||
cliDistributed: 'CLI는 macOS, Linux, Windows용 단일 바이너리로 배포됩니다.',
|
||||
verifyInstall: '설치 확인:',
|
||||
authenticateStep: '인증',
|
||||
signIn: 'Filebase 계정으로 로그인합니다. 토큰은 <code>~/.config/filebase/credentials</code>에 저장됩니다.',
|
||||
opensBrowser: '# → 브라우저를 엽니다',
|
||||
loggedIn: '# ✓ you@example.com으로 로그인됨',
|
||||
note: '참고',
|
||||
noBrowser: '브라우저가 없는 서버에서는 <code>filebase auth login --device</code>로 디바이스 코드 흐름을 사용합니다.',
|
||||
syncFolder: '폴더 동기화',
|
||||
pickLocal: '로컬 디렉터리를 선택해 원격 루트에 연결합니다. Filebase는 변경을 감시하고 블록 단위 차이를 백그라운드에서 전송합니다.',
|
||||
excludingFiles: '파일 제외',
|
||||
addIgnore: '동기화 폴더 루트에 <code>.filebaseignore</code>를 추가합니다. 문법은 <code>.gitignore</code>와 같습니다:',
|
||||
whereNext: '다음 단계',
|
||||
readConflict: '<a href="#">충돌 해결</a>을 읽어 동시 편집 병합을 이해하거나 <a href="#">CLI 참조</a>에서 전체 하위 명령을 확인합니다.',
|
||||
previous: '이전',
|
||||
next: '다음',
|
||||
},
|
||||
de: {
|
||||
howItWorks: 'So funktioniert es',
|
||||
viewOnGithub: 'Auf GitHub ansehen',
|
||||
quickstart: 'Schnellstart',
|
||||
gettingStarted: 'Erste Schritte',
|
||||
docs: 'Dokumentation',
|
||||
onThisPage: 'Auf dieser Seite',
|
||||
roadmap: 'Fahrplan',
|
||||
showcase: 'Beispiele',
|
||||
search: 'Suche',
|
||||
concepts: 'Konzepte',
|
||||
authentication: 'Authentifizierung',
|
||||
syncEngine: 'Sync-Engine',
|
||||
blockLevelDeltas: 'Blockbasierte Deltas',
|
||||
conflictResolution: 'Konfliktlösung',
|
||||
resumableUploads: 'Fortsetzbare Uploads',
|
||||
install: 'Installation',
|
||||
configuration: 'Konfiguration',
|
||||
subcommands: 'Unterbefehle',
|
||||
syncFirstFolder: 'Synchronisiere den ersten Ordner in unter fünf Minuten',
|
||||
installCli: 'CLI installieren',
|
||||
cliDistributed: 'Die CLI wird als einzelne Binärdatei für macOS, Linux und Windows verteilt.',
|
||||
verifyInstall: 'Installation prüfen:',
|
||||
authenticateStep: 'Authentifizieren',
|
||||
signIn: 'Melde dich mit deinem Filebase-Konto an. Das Token wird unter <code>~/.config/filebase/credentials</code> gespeichert.',
|
||||
opensBrowser: '# → öffnet den Browser',
|
||||
loggedIn: '# ✓ Angemeldet als you@example.com',
|
||||
note: 'Hinweis',
|
||||
noBrowser: 'Auf Servern ohne Browser nutze <code>filebase auth login --device</code> für den Gerätecode-Flow.',
|
||||
syncFolder: 'Ordner synchronisieren',
|
||||
pickLocal: 'Wähle ein lokales Verzeichnis und verknüpfe es mit einem Remote-Root. Filebase beobachtet Änderungen und sendet blockbasierte Diffs im Hintergrund.',
|
||||
excludingFiles: 'Dateien ausschließen',
|
||||
addIgnore: 'Lege eine <code>.filebaseignore</code> im Root des Sync-Ordners an. Die Syntax entspricht <code>.gitignore</code>:',
|
||||
whereNext: 'Nächste Schritte',
|
||||
readConflict: 'Lies <a href="#">Konfliktlösung</a>, um parallele Bearbeitungen zu verstehen, oder gehe zur <a href="#">CLI-Referenz</a> mit allen Unterbefehlen.',
|
||||
previous: 'Zurück',
|
||||
next: 'Weiter',
|
||||
},
|
||||
fr: {
|
||||
howItWorks: 'Fonctionnement',
|
||||
viewOnGithub: 'Voir sur GitHub',
|
||||
quickstart: 'Démarrage rapide',
|
||||
gettingStarted: 'Premiers pas',
|
||||
docs: 'Documentation',
|
||||
onThisPage: 'Sur cette page',
|
||||
roadmap: 'Feuille de route',
|
||||
showcase: 'Exemples',
|
||||
search: 'Recherche',
|
||||
concepts: 'Concepts',
|
||||
authentication: 'Authentification',
|
||||
syncEngine: 'Moteur de synchronisation',
|
||||
blockLevelDeltas: 'Deltas par bloc',
|
||||
conflictResolution: 'Résolution des conflits',
|
||||
resumableUploads: 'Uploads reprenables',
|
||||
install: 'Installation',
|
||||
configuration: 'Configuration',
|
||||
subcommands: 'Sous-commandes',
|
||||
syncFirstFolder: 'Synchronisez votre premier dossier en moins de cinq minutes',
|
||||
installCli: 'Installer la CLI',
|
||||
cliDistributed: 'La CLI est distribuée comme un binaire unique pour macOS, Linux et Windows.',
|
||||
verifyInstall: "Vérifier l'installation :",
|
||||
authenticateStep: "S'authentifier",
|
||||
signIn: 'Connectez-vous avec votre compte Filebase. Le jeton est stocké dans <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → ouvre votre navigateur',
|
||||
loggedIn: '# ✓ Connecté en tant que you@example.com',
|
||||
note: 'Note',
|
||||
noBrowser: 'Sur un serveur sans navigateur, utilisez <code>filebase auth login --device</code> pour le flux par code appareil.',
|
||||
syncFolder: 'Synchroniser un dossier',
|
||||
pickLocal: 'Choisissez un dossier local et liez-le à une racine distante. Filebase surveille les changements et pousse les différences par bloc en arrière-plan.',
|
||||
excludingFiles: 'Exclure des fichiers',
|
||||
addIgnore: 'Ajoutez un <code>.filebaseignore</code> à la racine du dossier synchronisé. Même syntaxe que <code>.gitignore</code> :',
|
||||
whereNext: 'Étapes suivantes',
|
||||
readConflict: 'Lisez <a href="#">Résolution des conflits</a> ou ouvrez la <a href="#">référence CLI</a> pour la liste complète des sous-commandes.',
|
||||
previous: 'Précédent',
|
||||
next: 'Suivant',
|
||||
},
|
||||
ru: {
|
||||
howItWorks: 'Как это работает',
|
||||
viewOnGithub: 'Посмотреть на GitHub',
|
||||
quickstart: 'Быстрый старт',
|
||||
gettingStarted: 'Начало работы',
|
||||
docs: 'Документация',
|
||||
onThisPage: 'На этой странице',
|
||||
roadmap: 'Дорожная карта',
|
||||
showcase: 'Примеры',
|
||||
search: 'Поиск',
|
||||
concepts: 'Концепции',
|
||||
authentication: 'Аутентификация',
|
||||
syncEngine: 'Движок синхронизации',
|
||||
blockLevelDeltas: 'Блочные изменения',
|
||||
conflictResolution: 'Разрешение конфликтов',
|
||||
resumableUploads: 'Возобновляемые загрузки',
|
||||
install: 'Установка',
|
||||
configuration: 'Настройка',
|
||||
subcommands: 'Подкоманды',
|
||||
syncFirstFolder: 'Синхронизируйте первую папку менее чем за пять минут',
|
||||
installCli: 'Установите CLI',
|
||||
cliDistributed: 'CLI распространяется как один бинарный файл для macOS, Linux и Windows.',
|
||||
verifyInstall: 'Проверьте установку:',
|
||||
authenticateStep: 'Войдите',
|
||||
signIn: 'Войдите в аккаунт Filebase. Токен хранится в <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → открывает браузер',
|
||||
loggedIn: '# ✓ вход выполнен как you@example.com',
|
||||
note: 'Примечание',
|
||||
noBrowser: 'На серверах без браузера используйте <code>filebase auth login --device</code> для входа по коду устройства.',
|
||||
syncFolder: 'Синхронизируйте папку',
|
||||
pickLocal: 'Выберите локальный каталог и привяжите его к удаленному корню. Filebase отслеживает изменения и отправляет блочные различия в фоне.',
|
||||
excludingFiles: 'Исключение файлов',
|
||||
addIgnore: 'Добавьте <code>.filebaseignore</code> в корень синхронизируемой папки. Синтаксис такой же, как у <code>.gitignore</code>:',
|
||||
whereNext: 'Что дальше',
|
||||
readConflict: 'Прочитайте <a href="#">разрешение конфликтов</a> или перейдите к <a href="#">справке CLI</a> со всеми подкомандами.',
|
||||
previous: 'Назад',
|
||||
next: 'Далее',
|
||||
},
|
||||
es: {
|
||||
howItWorks: 'Cómo funciona',
|
||||
viewOnGithub: 'Ver en GitHub',
|
||||
quickstart: 'Inicio rápido',
|
||||
gettingStarted: 'Primeros pasos',
|
||||
docs: 'Documentación',
|
||||
onThisPage: 'En esta página',
|
||||
roadmap: 'Hoja de ruta',
|
||||
showcase: 'Ejemplos',
|
||||
search: 'Buscar',
|
||||
concepts: 'Conceptos',
|
||||
authentication: 'Autenticación',
|
||||
syncEngine: 'Motor de sincronización',
|
||||
blockLevelDeltas: 'Deltas por bloque',
|
||||
conflictResolution: 'Resolución de conflictos',
|
||||
resumableUploads: 'Subidas reanudables',
|
||||
install: 'Instalación',
|
||||
configuration: 'Configuración',
|
||||
subcommands: 'Subcomandos',
|
||||
syncFirstFolder: 'Sincroniza tu primera carpeta en menos de cinco minutos',
|
||||
installCli: 'Instala la CLI',
|
||||
cliDistributed: 'La CLI se distribuye como un único binario para macOS, Linux y Windows.',
|
||||
verifyInstall: 'Verifica la instalación:',
|
||||
authenticateStep: 'Autentícate',
|
||||
signIn: 'Inicia sesión con tu cuenta de Filebase. El token se guarda en <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → abre tu navegador',
|
||||
loggedIn: '# ✓ Sesión iniciada como you@example.com',
|
||||
note: 'Nota',
|
||||
noBrowser: 'En servidores sin navegador, usa <code>filebase auth login --device</code> para el flujo con código de dispositivo.',
|
||||
syncFolder: 'Sincroniza una carpeta',
|
||||
pickLocal: 'Elige un directorio local y enlázalo con una raíz remota. Filebase observa cambios y envía diferencias por bloque en segundo plano.',
|
||||
excludingFiles: 'Excluir archivos',
|
||||
addIgnore: 'Agrega <code>.filebaseignore</code> en la raíz de la carpeta sincronizada. Misma sintaxis que <code>.gitignore</code>:',
|
||||
whereNext: 'Siguiente paso',
|
||||
readConflict: 'Lee <a href="#">resolución de conflictos</a> o abre la <a href="#">referencia CLI</a> con todos los subcomandos.',
|
||||
previous: 'Anterior',
|
||||
next: 'Siguiente',
|
||||
},
|
||||
'pt-br': {
|
||||
howItWorks: 'Como funciona',
|
||||
viewOnGithub: 'Ver no GitHub',
|
||||
quickstart: 'Início rápido',
|
||||
gettingStarted: 'Primeiros passos',
|
||||
docs: 'Documentação',
|
||||
onThisPage: 'Nesta página',
|
||||
roadmap: 'Roteiro',
|
||||
showcase: 'Exemplos',
|
||||
search: 'Buscar',
|
||||
concepts: 'Conceitos',
|
||||
authentication: 'Autenticação',
|
||||
syncEngine: 'Motor de sincronização',
|
||||
blockLevelDeltas: 'Deltas por bloco',
|
||||
conflictResolution: 'Resolução de conflitos',
|
||||
resumableUploads: 'Uploads retomáveis',
|
||||
install: 'Instalação',
|
||||
configuration: 'Configuração',
|
||||
subcommands: 'Subcomandos',
|
||||
syncFirstFolder: 'Sincronize sua primeira pasta em menos de cinco minutos',
|
||||
installCli: 'Instale a CLI',
|
||||
cliDistributed: 'A CLI é distribuída como um binário único para macOS, Linux e Windows.',
|
||||
verifyInstall: 'Verifique a instalação:',
|
||||
authenticateStep: 'Autentique-se',
|
||||
signIn: 'Entre com sua conta Filebase. O token fica em <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → abre seu navegador',
|
||||
loggedIn: '# ✓ Login feito como you@example.com',
|
||||
note: 'Nota',
|
||||
noBrowser: 'Em servidores sem navegador, use <code>filebase auth login --device</code> para o fluxo por código de dispositivo.',
|
||||
syncFolder: 'Sincronize uma pasta',
|
||||
pickLocal: 'Escolha um diretório local e conecte-o a uma raiz remota. Filebase observa alterações e envia diffs por bloco em segundo plano.',
|
||||
excludingFiles: 'Excluir arquivos',
|
||||
addIgnore: 'Adicione <code>.filebaseignore</code> na raiz da pasta sincronizada. Mesma sintaxe de <code>.gitignore</code>:',
|
||||
whereNext: 'Próximo passo',
|
||||
readConflict: 'Leia <a href="#">resolução de conflitos</a> ou abra a <a href="#">referência da CLI</a> com todos os subcomandos.',
|
||||
previous: 'Anterior',
|
||||
next: 'Próximo',
|
||||
},
|
||||
it: {
|
||||
howItWorks: 'Come funziona',
|
||||
viewOnGithub: 'Vedi su GitHub',
|
||||
quickstart: 'Avvio rapido',
|
||||
gettingStarted: 'Primi passi',
|
||||
docs: 'Documentazione',
|
||||
onThisPage: 'In questa pagina',
|
||||
roadmap: 'Tabella di marcia',
|
||||
showcase: 'Esempi',
|
||||
search: 'Cerca',
|
||||
concepts: 'Concetti',
|
||||
authentication: 'Autenticazione',
|
||||
syncEngine: 'Motore di sincronizzazione',
|
||||
blockLevelDeltas: 'Delta per blocco',
|
||||
conflictResolution: 'Risoluzione dei conflitti',
|
||||
resumableUploads: 'Upload riprendibili',
|
||||
install: 'Installazione',
|
||||
configuration: 'Configurazione',
|
||||
subcommands: 'Sottocomandi',
|
||||
syncFirstFolder: 'Sincronizza la prima cartella in meno di cinque minuti',
|
||||
installCli: 'Installa la CLI',
|
||||
cliDistributed: 'La CLI è distribuita come binario unico per macOS, Linux e Windows.',
|
||||
verifyInstall: "Verifica l'installazione:",
|
||||
authenticateStep: 'Autenticati',
|
||||
signIn: "Accedi con il tuo account Filebase. Il token viene salvato in <code>~/.config/filebase/credentials</code>.",
|
||||
opensBrowser: '# → apre il browser',
|
||||
loggedIn: '# ✓ Accesso effettuato come you@example.com',
|
||||
note: 'Nota',
|
||||
noBrowser: 'Su server senza browser usa <code>filebase auth login --device</code> per il flusso con codice dispositivo.',
|
||||
syncFolder: 'Sincronizza una cartella',
|
||||
pickLocal: 'Scegli una directory locale e collegala a una radice remota. Filebase osserva le modifiche e invia differenze per blocco in background.',
|
||||
excludingFiles: 'Escludere file',
|
||||
addIgnore: 'Aggiungi <code>.filebaseignore</code> alla radice della cartella sincronizzata. Stessa sintassi di <code>.gitignore</code>:',
|
||||
whereNext: 'Prossimo passo',
|
||||
readConflict: 'Leggi <a href="#">risoluzione dei conflitti</a> o apri la <a href="#">referenza CLI</a> con tutti i sottocomandi.',
|
||||
previous: 'Precedente',
|
||||
next: 'Successivo',
|
||||
},
|
||||
vi: {
|
||||
howItWorks: 'Cách hoạt động',
|
||||
viewOnGithub: 'Xem trên GitHub',
|
||||
quickstart: 'Bắt đầu nhanh',
|
||||
gettingStarted: 'Bắt đầu',
|
||||
docs: 'Tài liệu',
|
||||
onThisPage: 'Trong trang này',
|
||||
roadmap: 'Lộ trình',
|
||||
showcase: 'Ví dụ',
|
||||
search: 'Tìm kiếm',
|
||||
concepts: 'Khái niệm',
|
||||
authentication: 'Xác thực',
|
||||
syncEngine: 'Bộ máy đồng bộ',
|
||||
blockLevelDeltas: 'Delta theo khối',
|
||||
conflictResolution: 'Giải quyết xung đột',
|
||||
resumableUploads: 'Tải lên có thể tiếp tục',
|
||||
install: 'Cài đặt',
|
||||
configuration: 'Cấu hình',
|
||||
subcommands: 'Lệnh con',
|
||||
syncFirstFolder: 'Đồng bộ thư mục đầu tiên trong chưa đầy năm phút',
|
||||
installCli: 'Cài đặt CLI',
|
||||
cliDistributed: 'CLI được phân phối dưới dạng một binary cho macOS, Linux và Windows.',
|
||||
verifyInstall: 'Kiểm tra cài đặt:',
|
||||
authenticateStep: 'Xác thực',
|
||||
signIn: 'Đăng nhập bằng tài khoản Filebase. Token được lưu tại <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → mở trình duyệt',
|
||||
loggedIn: '# ✓ Đã đăng nhập dưới tên you@example.com',
|
||||
note: 'Ghi chú',
|
||||
noBrowser: 'Trên máy chủ không có trình duyệt, dùng <code>filebase auth login --device</code> cho luồng mã thiết bị.',
|
||||
syncFolder: 'Đồng bộ thư mục',
|
||||
pickLocal: 'Chọn một thư mục cục bộ và liên kết với gốc từ xa. Filebase theo dõi thay đổi và đẩy diff theo khối ở nền.',
|
||||
excludingFiles: 'Loại trừ tệp',
|
||||
addIgnore: 'Thêm <code>.filebaseignore</code> ở gốc thư mục đồng bộ. Cú pháp giống <code>.gitignore</code>:',
|
||||
whereNext: 'Bước tiếp theo',
|
||||
readConflict: 'Đọc <a href="#">giải quyết xung đột</a> hoặc mở <a href="#">tham chiếu CLI</a> để xem toàn bộ lệnh con.',
|
||||
previous: 'Trước',
|
||||
next: 'Tiếp',
|
||||
},
|
||||
pl: {
|
||||
howItWorks: 'Jak to działa',
|
||||
viewOnGithub: 'Zobacz na GitHub',
|
||||
quickstart: 'Szybki start',
|
||||
gettingStarted: 'Pierwsze kroki',
|
||||
docs: 'Dokumentacja',
|
||||
onThisPage: 'Na tej stronie',
|
||||
roadmap: 'Plan',
|
||||
showcase: 'Przykłady',
|
||||
search: 'Szukaj',
|
||||
concepts: 'Koncepcje',
|
||||
authentication: 'Uwierzytelnianie',
|
||||
syncEngine: 'Silnik synchronizacji',
|
||||
blockLevelDeltas: 'Zmiany blokowe',
|
||||
conflictResolution: 'Rozwiązywanie konfliktów',
|
||||
resumableUploads: 'Wznawialne uploady',
|
||||
install: 'Instalacja',
|
||||
configuration: 'Konfiguracja',
|
||||
subcommands: 'Podkomendy',
|
||||
syncFirstFolder: 'Zsynchronizuj pierwszy folder w mniej niż pięć minut',
|
||||
installCli: 'Zainstaluj CLI',
|
||||
cliDistributed: 'CLI jest dystrybuowane jako pojedynczy plik binarny dla macOS, Linux i Windows.',
|
||||
verifyInstall: 'Sprawdź instalację:',
|
||||
authenticateStep: 'Uwierzytelnij się',
|
||||
signIn: 'Zaloguj się kontem Filebase. Token jest zapisany w <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → otwiera przeglądarkę',
|
||||
loggedIn: '# ✓ Zalogowano jako you@example.com',
|
||||
note: 'Uwaga',
|
||||
noBrowser: 'Na serwerach bez przeglądarki użyj <code>filebase auth login --device</code> dla przepływu z kodem urządzenia.',
|
||||
syncFolder: 'Synchronizuj folder',
|
||||
pickLocal: 'Wybierz lokalny katalog i połącz go ze zdalnym katalogiem głównym. Filebase obserwuje zmiany i wysyła różnice blokowe w tle.',
|
||||
excludingFiles: 'Wykluczanie plików',
|
||||
addIgnore: 'Dodaj <code>.filebaseignore</code> w katalogu głównym synchronizowanego folderu. Składnia jak w <code>.gitignore</code>:',
|
||||
whereNext: 'Co dalej',
|
||||
readConflict: 'Przeczytaj <a href="#">rozwiązywanie konfliktów</a> albo otwórz <a href="#">referencję CLI</a> z pełną listą podkomend.',
|
||||
previous: 'Poprzednia',
|
||||
next: 'Następna',
|
||||
},
|
||||
id: {
|
||||
howItWorks: 'Cara kerja',
|
||||
viewOnGithub: 'Lihat di GitHub',
|
||||
quickstart: 'Mulai cepat',
|
||||
gettingStarted: 'Mulai',
|
||||
docs: 'Dokumentasi',
|
||||
onThisPage: 'Di halaman ini',
|
||||
roadmap: 'Peta jalan',
|
||||
showcase: 'Contoh',
|
||||
search: 'Cari',
|
||||
concepts: 'Konsep',
|
||||
authentication: 'Autentikasi',
|
||||
syncEngine: 'Mesin sinkronisasi',
|
||||
blockLevelDeltas: 'Delta per blok',
|
||||
conflictResolution: 'Resolusi konflik',
|
||||
resumableUploads: 'Unggahan yang dapat dilanjutkan',
|
||||
install: 'Instalasi',
|
||||
configuration: 'Konfigurasi',
|
||||
subcommands: 'Subperintah',
|
||||
syncFirstFolder: 'Sinkronkan folder pertama dalam kurang dari lima menit',
|
||||
installCli: 'Instal CLI',
|
||||
cliDistributed: 'CLI didistribusikan sebagai satu binary untuk macOS, Linux, dan Windows.',
|
||||
verifyInstall: 'Verifikasi instalasi:',
|
||||
authenticateStep: 'Autentikasi',
|
||||
signIn: 'Masuk dengan akun Filebase. Token disimpan di <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → membuka browser',
|
||||
loggedIn: '# ✓ Masuk sebagai you@example.com',
|
||||
note: 'Catatan',
|
||||
noBrowser: 'Di server tanpa browser, gunakan <code>filebase auth login --device</code> untuk alur kode perangkat.',
|
||||
syncFolder: 'Sinkronkan folder',
|
||||
pickLocal: 'Pilih direktori lokal dan hubungkan ke root remote. Filebase memantau perubahan dan mengirim diff per blok di latar belakang.',
|
||||
excludingFiles: 'Mengecualikan file',
|
||||
addIgnore: 'Tambahkan <code>.filebaseignore</code> di root folder yang disinkronkan. Sintaksnya sama dengan <code>.gitignore</code>:',
|
||||
whereNext: 'Langkah berikutnya',
|
||||
readConflict: 'Baca <a href="#">resolusi konflik</a> atau buka <a href="#">referensi CLI</a> untuk daftar lengkap subperintah.',
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Berikutnya',
|
||||
},
|
||||
nl: {
|
||||
howItWorks: 'Hoe het werkt',
|
||||
viewOnGithub: 'Bekijk op GitHub',
|
||||
quickstart: 'Snelstart',
|
||||
gettingStarted: 'Aan de slag',
|
||||
docs: 'Documentatie',
|
||||
onThisPage: 'Op deze pagina',
|
||||
roadmap: 'Routekaart',
|
||||
showcase: 'Voorbeelden',
|
||||
search: 'Zoeken',
|
||||
concepts: 'Concepten',
|
||||
authentication: 'Authenticatie',
|
||||
syncEngine: 'Synchronisatie-engine',
|
||||
blockLevelDeltas: 'Blok-delta’s',
|
||||
conflictResolution: 'Conflictoplossing',
|
||||
resumableUploads: 'Hervatbare uploads',
|
||||
install: 'Installatie',
|
||||
configuration: 'Configuratie',
|
||||
subcommands: 'Subcommando’s',
|
||||
syncFirstFolder: 'Synchroniseer je eerste map binnen vijf minuten',
|
||||
installCli: 'Installeer de CLI',
|
||||
cliDistributed: 'De CLI wordt geleverd als één binary voor macOS, Linux en Windows.',
|
||||
verifyInstall: 'Controleer de installatie:',
|
||||
authenticateStep: 'Authenticeer',
|
||||
signIn: 'Log in met je Filebase-account. Het token wordt opgeslagen in <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → opent je browser',
|
||||
loggedIn: '# ✓ Ingelogd als you@example.com',
|
||||
note: 'Opmerking',
|
||||
noBrowser: 'Gebruik op servers zonder browser <code>filebase auth login --device</code> voor een apparaatcode-flow.',
|
||||
syncFolder: 'Synchroniseer een map',
|
||||
pickLocal: 'Kies een lokale map en koppel die aan een remote root. Filebase bewaakt wijzigingen en stuurt blok-diffs op de achtergrond.',
|
||||
excludingFiles: 'Bestanden uitsluiten',
|
||||
addIgnore: 'Voeg <code>.filebaseignore</code> toe aan de root van de gesynchroniseerde map. Zelfde syntaxis als <code>.gitignore</code>:',
|
||||
whereNext: 'Volgende stap',
|
||||
readConflict: 'Lees <a href="#">conflictoplossing</a> of open de <a href="#">CLI-referentie</a> voor alle subcommando’s.',
|
||||
previous: 'Vorige',
|
||||
next: 'Volgende',
|
||||
},
|
||||
ar: {
|
||||
howItWorks: 'كيف يعمل',
|
||||
viewOnGithub: 'عرض على GitHub',
|
||||
quickstart: 'بدء سريع',
|
||||
gettingStarted: 'البدء',
|
||||
docs: 'الوثائق',
|
||||
onThisPage: 'في هذه الصفحة',
|
||||
roadmap: 'خارطة الطريق',
|
||||
showcase: 'أمثلة',
|
||||
search: 'بحث',
|
||||
concepts: 'المفاهيم',
|
||||
authentication: 'المصادقة',
|
||||
syncEngine: 'محرك المزامنة',
|
||||
blockLevelDeltas: 'فروق على مستوى الكتل',
|
||||
conflictResolution: 'حل التعارضات',
|
||||
resumableUploads: 'رفع قابل للاستئناف',
|
||||
install: 'التثبيت',
|
||||
configuration: 'الإعداد',
|
||||
subcommands: 'الأوامر الفرعية',
|
||||
syncFirstFolder: 'زامن أول مجلد خلال أقل من خمس دقائق',
|
||||
installCli: 'ثبّت CLI',
|
||||
cliDistributed: 'يوزع CLI كملف تنفيذي واحد لنظام macOS وLinux وWindows.',
|
||||
verifyInstall: 'تحقق من التثبيت:',
|
||||
authenticateStep: 'المصادقة',
|
||||
signIn: 'سجّل الدخول بحساب Filebase. يتم حفظ الرمز في <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → يفتح المتصفح',
|
||||
loggedIn: '# ✓ تم تسجيل الدخول باسم you@example.com',
|
||||
note: 'ملاحظة',
|
||||
noBrowser: 'على الخوادم بلا متصفح، استخدم <code>filebase auth login --device</code> لتدفق رمز الجهاز.',
|
||||
syncFolder: 'زامن مجلدا',
|
||||
pickLocal: 'اختر دليلا محليا واربطه بجذر بعيد. يراقب Filebase التغييرات ويدفع فروق الكتل في الخلفية.',
|
||||
excludingFiles: 'استبعاد الملفات',
|
||||
addIgnore: 'أضف <code>.filebaseignore</code> في جذر المجلد المتزامن. نفس صياغة <code>.gitignore</code>:',
|
||||
whereNext: 'الخطوة التالية',
|
||||
readConflict: 'اقرأ <a href="#">حل التعارضات</a> أو انتقل إلى <a href="#">مرجع CLI</a> لقائمة الأوامر الفرعية.',
|
||||
previous: 'السابق',
|
||||
next: 'التالي',
|
||||
},
|
||||
tr: {
|
||||
howItWorks: 'Nasıl çalışır',
|
||||
viewOnGithub: 'GitHub’da görüntüle',
|
||||
quickstart: 'Hızlı başlangıç',
|
||||
gettingStarted: 'Başlarken',
|
||||
docs: 'Belgeler',
|
||||
onThisPage: 'Bu sayfada',
|
||||
roadmap: 'Yol haritası',
|
||||
showcase: 'Örnekler',
|
||||
search: 'Ara',
|
||||
concepts: 'Kavramlar',
|
||||
authentication: 'Kimlik doğrulama',
|
||||
syncEngine: 'Senkronizasyon motoru',
|
||||
blockLevelDeltas: 'Blok düzeyi deltalar',
|
||||
conflictResolution: 'Çakışma çözümü',
|
||||
resumableUploads: 'Sürdürülebilir yüklemeler',
|
||||
install: 'Kurulum',
|
||||
configuration: 'Yapılandırma',
|
||||
subcommands: 'Alt komutlar',
|
||||
syncFirstFolder: 'İlk klasörünüzü beş dakikadan kısa sürede eşitleyin',
|
||||
installCli: 'CLI kur',
|
||||
cliDistributed: 'CLI, macOS, Linux ve Windows için tek bir binary olarak dağıtılır.',
|
||||
verifyInstall: 'Kurulumu doğrula:',
|
||||
authenticateStep: 'Kimlik doğrula',
|
||||
signIn: 'Filebase hesabınızla giriş yapın. Token <code>~/.config/filebase/credentials</code> içinde saklanır.',
|
||||
opensBrowser: '# → tarayıcınızı açar',
|
||||
loggedIn: '# ✓ you@example.com olarak giriş yapıldı',
|
||||
note: 'Not',
|
||||
noBrowser: 'Tarayıcısı olmayan sunucularda cihaz kodu akışı için <code>filebase auth login --device</code> kullanın.',
|
||||
syncFolder: 'Klasör eşitle',
|
||||
pickLocal: 'Yerel bir dizin seçip uzak köke bağlayın. Filebase değişiklikleri izler ve blok farklarını arka planda gönderir.',
|
||||
excludingFiles: 'Dosya hariç tutma',
|
||||
addIgnore: 'Eşitlenen klasörün köküne <code>.filebaseignore</code> ekleyin. Söz dizimi <code>.gitignore</code> ile aynıdır:',
|
||||
whereNext: 'Sonraki adım',
|
||||
readConflict: '<a href="#">Çakışma çözümü</a> bölümünü okuyun veya tüm alt komutlar için <a href="#">CLI başvurusuna</a> geçin.',
|
||||
previous: 'Önceki',
|
||||
next: 'Sonraki',
|
||||
},
|
||||
uk: {
|
||||
howItWorks: 'Як це працює',
|
||||
viewOnGithub: 'Переглянути на GitHub',
|
||||
quickstart: 'Швидкий старт',
|
||||
gettingStarted: 'Початок роботи',
|
||||
docs: 'Документація',
|
||||
onThisPage: 'На цій сторінці',
|
||||
roadmap: 'Дорожня карта',
|
||||
showcase: 'Приклади',
|
||||
search: 'Пошук',
|
||||
concepts: 'Концепції',
|
||||
authentication: 'Автентифікація',
|
||||
syncEngine: 'Рушій синхронізації',
|
||||
blockLevelDeltas: 'Блокові зміни',
|
||||
conflictResolution: 'Вирішення конфліктів',
|
||||
resumableUploads: 'Відновлювані завантаження',
|
||||
install: 'Встановлення',
|
||||
configuration: 'Налаштування',
|
||||
subcommands: 'Підкоманди',
|
||||
syncFirstFolder: 'Синхронізуйте першу теку менш ніж за п’ять хвилин',
|
||||
installCli: 'Встановіть CLI',
|
||||
cliDistributed: 'CLI постачається як один виконуваний файл для macOS, Linux і Windows.',
|
||||
verifyInstall: 'Перевірте встановлення:',
|
||||
authenticateStep: 'Увійдіть',
|
||||
signIn: 'Увійдіть у свій обліковий запис Filebase. Токен зберігається в <code>~/.config/filebase/credentials</code>.',
|
||||
opensBrowser: '# → відкриває браузер',
|
||||
loggedIn: '# ✓ виконано вхід як you@example.com',
|
||||
note: 'Примітка',
|
||||
noBrowser: 'На серверах без браузера використовуйте <code>filebase auth login --device</code> для входу через код пристрою.',
|
||||
syncFolder: 'Синхронізуйте теку',
|
||||
pickLocal: 'Виберіть локальну директорію та прив’яжіть її до віддаленого кореня. Filebase стежить за змінами й у фоновому режимі надсилає блокові різниці.',
|
||||
excludingFiles: 'Виключення файлів',
|
||||
addIgnore: 'Додайте <code>.filebaseignore</code> у корінь синхронізованої теки. Синтаксис такий самий, як у <code>.gitignore</code>:',
|
||||
whereNext: 'Куди рухатися далі',
|
||||
readConflict: 'Прочитайте <a href="#">вирішення конфліктів</a>, щоб зрозуміти, як Filebase об’єднує паралельні зміни, або перейдіть до <a href="#">довідника CLI</a> з повним списком підкоманд.',
|
||||
previous: 'Попередня',
|
||||
next: 'Наступна',
|
||||
},
|
||||
};
|
||||
|
||||
const COMMON_PLUGIN_PREVIEW_COPY: Partial<Record<LandingLocaleCode, PreviewTextMap>> = {
|
||||
zh: {
|
||||
'How it works': '工作方式',
|
||||
'View on GitHub →': '在 GitHub 查看 →',
|
||||
'View on GitHub': '在 GitHub 查看',
|
||||
Quickstart: '快速开始',
|
||||
'Getting started': '入门',
|
||||
Docs: '文档',
|
||||
'On this page': '本页内容',
|
||||
},
|
||||
'zh-tw': {
|
||||
'How it works': '運作方式',
|
||||
'View on GitHub →': '在 GitHub 查看 →',
|
||||
'View on GitHub': '在 GitHub 查看',
|
||||
Quickstart: '快速開始',
|
||||
'Getting started': '入門',
|
||||
Docs: '文件',
|
||||
'On this page': '本頁內容',
|
||||
},
|
||||
uk: {
|
||||
'How it works': 'Як це працює',
|
||||
'View on GitHub →': 'Переглянути на GitHub →',
|
||||
'View on GitHub': 'Переглянути на GitHub',
|
||||
Quickstart: 'Швидкий старт',
|
||||
'Getting started': 'Початок роботи',
|
||||
Docs: 'Документація',
|
||||
'On this page': 'На цій сторінці',
|
||||
},
|
||||
};
|
||||
|
||||
const FILEBASE_DOCS_PREVIEW_COPY: Partial<Record<LandingLocaleCode, PreviewTextMap>> = {
|
||||
zh: {
|
||||
'Filebase docs — Quickstart': 'Filebase 文档 — 快速开始',
|
||||
'◰ Filebase docs': '◰ Filebase 文档',
|
||||
'Search · ⌘K': '搜索 · ⌘K',
|
||||
'Getting started': '入门',
|
||||
Quickstart: '快速开始',
|
||||
Concepts: '概念',
|
||||
Authentication: '身份验证',
|
||||
'Sync engine': '同步引擎',
|
||||
'Block-level deltas': '块级增量',
|
||||
'Conflict resolution': '冲突解决',
|
||||
'Resumable uploads': '可续传上传',
|
||||
Install: '安装',
|
||||
Configuration: '配置',
|
||||
Subcommands: '子命令',
|
||||
'Docs › Getting started › Quickstart': '文档 › 入门 › 快速开始',
|
||||
'Sync your first folder in under five minutes. The CLI is the fastest path; the desktop app and the API client all wrap the same engine.':
|
||||
'五分钟内同步第一个文件夹。CLI 是最快路径;桌面端和 API 客户端都封装同一套引擎。',
|
||||
'1. Install the CLI': '1. 安装 CLI',
|
||||
'The CLI is distributed as a single binary for macOS, Linux, and Windows.':
|
||||
'CLI 以单一二进制文件发布,支持 macOS、Linux 和 Windows。',
|
||||
'Verify the install:': '验证安装:',
|
||||
'2. Authenticate': '2. 登录认证',
|
||||
'Sign in with your Filebase account. The token is stored in <code>~/.config/filebase/credentials</code>.':
|
||||
'使用 Filebase 账号登录。令牌会存储在 <code>~/.config/filebase/credentials</code>。',
|
||||
'# → opens your browser': '# → 打开浏览器',
|
||||
'# ✓ Logged in as you@example.com': '# ✓ 已以 you@example.com 登录',
|
||||
Note: '说明',
|
||||
'On servers without a browser, use <code>filebase auth login --device</code> for a device-code flow.':
|
||||
'在没有浏览器的服务器上,可以使用 <code>filebase auth login --device</code> 走设备码流程。',
|
||||
'3. Sync a folder': '3. 同步文件夹',
|
||||
'Pick a local directory and link it to a remote root. Filebase watches it for changes and pushes block-level diffs in the background.':
|
||||
'选择一个本地目录并关联到远端根目录。Filebase 会监听变更,并在后台推送块级差异。',
|
||||
'Excluding files': '排除文件',
|
||||
'Add a <code>.filebaseignore</code> at the root of the synced folder. Same syntax as <code>.gitignore</code>:':
|
||||
'在同步文件夹根目录添加 <code>.filebaseignore</code>。语法与 <code>.gitignore</code> 相同:',
|
||||
'4. Where to go next': '4. 下一步',
|
||||
'Read <a href="#">Conflict resolution</a> to understand how Filebase merges concurrent edits, or skip to the <a href="#">CLI reference</a> for the full subcommand list.':
|
||||
'阅读 <a href="#">冲突解决</a> 了解 Filebase 如何合并并发编辑,或直接查看 <a href="#">CLI 参考</a> 获取完整子命令列表。',
|
||||
'← Previous': '← 上一页',
|
||||
'Next →': '下一页 →',
|
||||
'On this page': '本页内容',
|
||||
},
|
||||
'zh-tw': {
|
||||
'Filebase docs — Quickstart': 'Filebase 文件 — 快速開始',
|
||||
'◰ Filebase docs': '◰ Filebase 文件',
|
||||
'Search · ⌘K': '搜尋 · ⌘K',
|
||||
'Getting started': '入門',
|
||||
Quickstart: '快速開始',
|
||||
Concepts: '概念',
|
||||
Authentication: '身份驗證',
|
||||
'Sync engine': '同步引擎',
|
||||
'Block-level deltas': '區塊級增量',
|
||||
'Conflict resolution': '衝突解決',
|
||||
'Resumable uploads': '可續傳上傳',
|
||||
Install: '安裝',
|
||||
Configuration: '設定',
|
||||
Subcommands: '子命令',
|
||||
'Docs › Getting started › Quickstart': '文件 › 入門 › 快速開始',
|
||||
'Sync your first folder in under five minutes. The CLI is the fastest path; the desktop app and the API client all wrap the same engine.':
|
||||
'五分鐘內同步第一個資料夾。CLI 是最快路徑;桌面端和 API 客戶端都封裝同一套引擎。',
|
||||
'1. Install the CLI': '1. 安裝 CLI',
|
||||
'The CLI is distributed as a single binary for macOS, Linux, and Windows.':
|
||||
'CLI 以單一二進位檔發布,支援 macOS、Linux 和 Windows。',
|
||||
'Verify the install:': '驗證安裝:',
|
||||
'2. Authenticate': '2. 登入認證',
|
||||
'Sign in with your Filebase account. The token is stored in <code>~/.config/filebase/credentials</code>.':
|
||||
'使用 Filebase 帳號登入。權杖會儲存在 <code>~/.config/filebase/credentials</code>。',
|
||||
'# → opens your browser': '# → 開啟瀏覽器',
|
||||
'# ✓ Logged in as you@example.com': '# ✓ 已以 you@example.com 登入',
|
||||
Note: '說明',
|
||||
'On servers without a browser, use <code>filebase auth login --device</code> for a device-code flow.':
|
||||
'在沒有瀏覽器的伺服器上,可以使用 <code>filebase auth login --device</code> 走裝置碼流程。',
|
||||
'3. Sync a folder': '3. 同步資料夾',
|
||||
'Pick a local directory and link it to a remote root. Filebase watches it for changes and pushes block-level diffs in the background.':
|
||||
'選擇一個本地目錄並連結到遠端根目錄。Filebase 會監聽變更,並在背景推送區塊級差異。',
|
||||
'Excluding files': '排除檔案',
|
||||
'Add a <code>.filebaseignore</code> at the root of the synced folder. Same syntax as <code>.gitignore</code>:':
|
||||
'在同步資料夾根目錄新增 <code>.filebaseignore</code>。語法與 <code>.gitignore</code> 相同:',
|
||||
'4. Where to go next': '4. 下一步',
|
||||
'Read <a href="#">Conflict resolution</a> to understand how Filebase merges concurrent edits, or skip to the <a href="#">CLI reference</a> for the full subcommand list.':
|
||||
'閱讀 <a href="#">衝突解決</a> 了解 Filebase 如何合併並行編輯,或直接查看 <a href="#">CLI 參考</a> 取得完整子命令列表。',
|
||||
'← Previous': '← 上一頁',
|
||||
'Next →': '下一頁 →',
|
||||
'On this page': '本頁內容',
|
||||
},
|
||||
uk: {
|
||||
'Filebase docs — Quickstart': 'Документація Filebase — Швидкий старт',
|
||||
'◰ Filebase docs': '◰ Документація Filebase',
|
||||
'Search · ⌘K': 'Пошук · ⌘K',
|
||||
'Getting started': 'Початок роботи',
|
||||
Quickstart: 'Швидкий старт',
|
||||
Concepts: 'Концепції',
|
||||
Authentication: 'Автентифікація',
|
||||
'Sync engine': 'Рушій синхронізації',
|
||||
'Block-level deltas': 'Блокові зміни',
|
||||
'Conflict resolution': 'Вирішення конфліктів',
|
||||
'Resumable uploads': 'Відновлювані завантаження',
|
||||
Install: 'Встановлення',
|
||||
Configuration: 'Налаштування',
|
||||
Subcommands: 'Підкоманди',
|
||||
'Docs › Getting started › Quickstart': 'Документація › Початок роботи › Швидкий старт',
|
||||
'Sync your first folder in under five minutes. The CLI is the fastest path; the desktop app and the API client all wrap the same engine.':
|
||||
'Синхронізуйте першу теку менш ніж за п’ять хвилин. CLI — найшвидший шлях; десктопний застосунок і API-клієнт використовують той самий рушій.',
|
||||
'1. Install the CLI': '1. Встановіть CLI',
|
||||
'The CLI is distributed as a single binary for macOS, Linux, and Windows.':
|
||||
'CLI постачається як один виконуваний файл для macOS, Linux і Windows.',
|
||||
'Verify the install:': 'Перевірте встановлення:',
|
||||
'2. Authenticate': '2. Увійдіть',
|
||||
'Sign in with your Filebase account. The token is stored in <code>~/.config/filebase/credentials</code>.':
|
||||
'Увійдіть у свій обліковий запис Filebase. Токен зберігається в <code>~/.config/filebase/credentials</code>.',
|
||||
'# → opens your browser': '# → відкриває браузер',
|
||||
'# ✓ Logged in as you@example.com': '# ✓ виконано вхід як you@example.com',
|
||||
Note: 'Примітка',
|
||||
'On servers without a browser, use <code>filebase auth login --device</code> for a device-code flow.':
|
||||
'На серверах без браузера використовуйте <code>filebase auth login --device</code> для входу через код пристрою.',
|
||||
'3. Sync a folder': '3. Синхронізуйте теку',
|
||||
'Pick a local directory and link it to a remote root. Filebase watches it for changes and pushes block-level diffs in the background.':
|
||||
'Виберіть локальну директорію та прив’яжіть її до віддаленого кореня. Filebase стежить за змінами й у фоновому режимі надсилає блокові різниці.',
|
||||
'Excluding files': 'Виключення файлів',
|
||||
'Add a <code>.filebaseignore</code> at the root of the synced folder. Same syntax as <code>.gitignore</code>:':
|
||||
'Додайте <code>.filebaseignore</code> у корінь синхронізованої теки. Синтаксис такий самий, як у <code>.gitignore</code>:',
|
||||
'4. Where to go next': '4. Куди рухатися далі',
|
||||
'Read <a href="#">Conflict resolution</a> to understand how Filebase merges concurrent edits, or skip to the <a href="#">CLI reference</a> for the full subcommand list.':
|
||||
'Прочитайте <a href="#">вирішення конфліктів</a>, щоб зрозуміти, як Filebase об’єднує паралельні зміни, або перейдіть до <a href="#">довідника CLI</a> з повним списком підкоманд.',
|
||||
'← Previous': '← Попередня',
|
||||
'Next →': 'Наступна →',
|
||||
'On this page': 'На цій сторінці',
|
||||
},
|
||||
};
|
||||
|
||||
const generatedCommonPreviewCopy = (locale: LandingLocaleCode): PreviewTextMap => {
|
||||
if (locale === DEFAULT_LOCALE) return {};
|
||||
const terms = PREVIEW_TERMS[locale as Exclude<LandingLocaleCode, 'en'>];
|
||||
if (!terms) return {};
|
||||
return {
|
||||
'How it works': terms.howItWorks,
|
||||
'View on GitHub →': `${terms.viewOnGithub} →`,
|
||||
'View on GitHub': terms.viewOnGithub,
|
||||
Quickstart: terms.quickstart,
|
||||
'Getting started': terms.gettingStarted,
|
||||
Docs: terms.docs,
|
||||
'On this page': terms.onThisPage,
|
||||
Roadmap: terms.roadmap,
|
||||
Showcase: terms.showcase,
|
||||
};
|
||||
};
|
||||
|
||||
const generatedFilebaseDocsPreviewCopy = (locale: LandingLocaleCode): PreviewTextMap => {
|
||||
if (locale === DEFAULT_LOCALE) return {};
|
||||
const terms = PREVIEW_TERMS[locale as Exclude<LandingLocaleCode, 'en'>];
|
||||
if (!terms) return {};
|
||||
return {
|
||||
'Filebase docs — Quickstart': `Filebase ${terms.docs} — ${terms.quickstart}`,
|
||||
'◰ Filebase docs': `◰ Filebase ${terms.docs}`,
|
||||
'Search · ⌘K': `${terms.search} · ⌘K`,
|
||||
'Getting started': terms.gettingStarted,
|
||||
Quickstart: terms.quickstart,
|
||||
Concepts: terms.concepts,
|
||||
Authentication: terms.authentication,
|
||||
'Sync engine': terms.syncEngine,
|
||||
'Block-level deltas': terms.blockLevelDeltas,
|
||||
'Conflict resolution': terms.conflictResolution,
|
||||
'Resumable uploads': terms.resumableUploads,
|
||||
Install: terms.install,
|
||||
Configuration: terms.configuration,
|
||||
Subcommands: terms.subcommands,
|
||||
'Docs › Getting started › Quickstart':
|
||||
`${terms.docs} › ${terms.gettingStarted} › ${terms.quickstart}`,
|
||||
'Sync your first folder in under five minutes. The CLI is the fastest path; the desktop app and the API client all wrap the same engine.':
|
||||
`${terms.syncFirstFolder}. Filebase CLI / desktop / API.`,
|
||||
'1. Install the CLI': `1. ${terms.installCli}`,
|
||||
'The CLI is distributed as a single binary for macOS, Linux, and Windows.':
|
||||
terms.cliDistributed,
|
||||
'Verify the install:': terms.verifyInstall,
|
||||
'2. Authenticate': `2. ${terms.authenticateStep}`,
|
||||
'Sign in with your Filebase account. The token is stored in <code>~/.config/filebase/credentials</code>.':
|
||||
terms.signIn,
|
||||
'# → opens your browser': terms.opensBrowser,
|
||||
'# ✓ Logged in as you@example.com': terms.loggedIn,
|
||||
Note: terms.note,
|
||||
'On servers without a browser, use <code>filebase auth login --device</code> for a device-code flow.':
|
||||
terms.noBrowser,
|
||||
'3. Sync a folder': `3. ${terms.syncFolder}`,
|
||||
'Pick a local directory and link it to a remote root. Filebase watches it for changes and pushes block-level diffs in the background.':
|
||||
terms.pickLocal,
|
||||
'Excluding files': terms.excludingFiles,
|
||||
'Add a <code>.filebaseignore</code> at the root of the synced folder. Same syntax as <code>.gitignore</code>:':
|
||||
terms.addIgnore,
|
||||
'4. Where to go next': `4. ${terms.whereNext}`,
|
||||
'Read <a href="#">Conflict resolution</a> to understand how Filebase merges concurrent edits, or skip to the <a href="#">CLI reference</a> for the full subcommand list.':
|
||||
terms.readConflict,
|
||||
'← Previous': `← ${terms.previous}`,
|
||||
'Next →': `${terms.next} →`,
|
||||
'On this page': terms.onThisPage,
|
||||
};
|
||||
};
|
||||
|
||||
function localizePluginPreviewHtml(html: string, locale: LandingLocaleCode): string {
|
||||
if (locale === DEFAULT_LOCALE) return html;
|
||||
const copy = {
|
||||
...generatedCommonPreviewCopy(locale),
|
||||
...(COMMON_PLUGIN_PREVIEW_COPY[locale] ?? {}),
|
||||
...(html.includes('Filebase docs') ? generatedFilebaseDocsPreviewCopy(locale) : {}),
|
||||
...(html.includes('Filebase docs') ? FILEBASE_DOCS_PREVIEW_COPY[locale] ?? {} : {}),
|
||||
};
|
||||
if (Object.keys(copy).length === 0) return html;
|
||||
const localeDef = getLocaleDefinition(locale);
|
||||
let localized = html.replace(
|
||||
'<html lang="en">',
|
||||
`<html lang="${localeDef.htmlLang}" dir="${localeDef.dir}">`,
|
||||
);
|
||||
for (const [source, target] of Object.entries(copy).sort(
|
||||
([left], [right]) => right.length - left.length,
|
||||
)) {
|
||||
localized = localized.replaceAll(source, target);
|
||||
}
|
||||
return localized;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return getPublicPlugins()
|
||||
.filter((plugin) => plugin.preview?.type === 'html' && plugin.preview.frameHref)
|
||||
.map((plugin) => ({
|
||||
params: { slug: plugin.slug },
|
||||
props: {
|
||||
html: getPluginPreviewHtml(plugin),
|
||||
},
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.props.html));
|
||||
}
|
||||
|
||||
const { html: rawHtml } = Astro.props as { html: string };
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const html = localizePluginPreviewHtml(rawHtml, locale);
|
||||
---
|
||||
|
||||
<Fragment set:html={html} />
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getPublicPlugins } from '../../plugin-registry';
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const plugins = getPublicPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
title: plugin.title,
|
||||
description: plugin.description,
|
||||
registryId: plugin.registryId,
|
||||
trust: plugin.trust,
|
||||
version: plugin.version,
|
||||
mode: plugin.mode,
|
||||
surface: plugin.surface,
|
||||
visualKind: plugin.visualKind,
|
||||
preview: plugin.preview
|
||||
? {
|
||||
type: plugin.preview.type,
|
||||
label: plugin.preview.label,
|
||||
poster: plugin.preview.poster,
|
||||
frameHref: plugin.preview.frameHref,
|
||||
}
|
||||
: undefined,
|
||||
tags: plugin.tags,
|
||||
capabilities: plugin.capabilities,
|
||||
href: plugin.detailHref,
|
||||
installCommand: plugin.installCommand,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), plugins }, null, 2), {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
};
|
||||
64
apps/landing-page/app/pages/plugins/skills/index.astro
Normal file
64
apps/landing-page/app/pages/plugins/skills/index.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/skills/ — instruction skills.
|
||||
*
|
||||
* Reads `plugins/_official/`, filters to entries that don't fit any
|
||||
* of the seven artifact kinds (i.e. `categorizePlugin` returns null).
|
||||
* These are scenarios, atoms-style helpers, and miscellaneous mode
|
||||
* values that the agent loads mid-task — copywriting, color theory,
|
||||
* creative direction, brainstorming. The output depends on the
|
||||
* user's input, so there's no static preview to embed.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import PluginRow from '../../../_components/plugin-row.astro';
|
||||
import { getBundledPlugins } from '../../../_lib/bundled-plugins';
|
||||
import { bundledRecordOf, categorizePlugin } from '../../../_lib/plugin-facets';
|
||||
import { localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
const skills = getBundledPlugins()
|
||||
.filter((p) => p.bucket !== 'design-systems')
|
||||
.filter((p) => categorizePlugin(bundledRecordOf(p)) === null);
|
||||
|
||||
const title = `Skills · ${skills.length} · Open Design`;
|
||||
const description = `${skills.length} instruction skills the agent loads mid-task — copywriting, color theory, creative direction, brainstorming. Each detail page surfaces the manifest's full description.`;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/plugins/skills/', Astro.site).toString(),
|
||||
numberOfItems: skills.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">Skills</span>
|
||||
</nav>
|
||||
|
||||
<header class="catalog-head">
|
||||
<span class="label">Plugins · Skills</span>
|
||||
<h1 class="display">{skills.length} instruction skills<span class="dot">.</span></h1>
|
||||
<p class="lead">
|
||||
Skills the agent loads mid-task — copywriting, color theory, creative direction,
|
||||
brainstorming. There's no static demo because the outcome depends on your input, so each
|
||||
detail page reads like a brief: title, description, triggers, attribution.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label="All instruction skills">
|
||||
<ol>
|
||||
{skills.map((s, idx) => (
|
||||
<PluginRow item={{ kind: 'bundled', record: s }} index={idx} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
75
apps/landing-page/app/pages/plugins/systems/index.astro
Normal file
75
apps/landing-page/app/pages/plugins/systems/index.astro
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/systems/ — bundled design-system plugins.
|
||||
*
|
||||
* Renders through the legacy SystemCard component (palette swatches,
|
||||
* name, tagline) so the visual treatment matches the in-product
|
||||
* library. The bundled-plugin manifests under
|
||||
* `plugins/_official/design-systems/<slug>/` overlap 1:1 with the
|
||||
* SystemRecord set the old `/systems/` route reads via DESIGN.md, so
|
||||
* we keep using SystemRecord here — the manifest doesn't carry the
|
||||
* palette data the card needs to render its swatches.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import SystemCard from '../../../_components/system-card.astro';
|
||||
import {
|
||||
getSystemRecords,
|
||||
getSystemCategoryIndex,
|
||||
} from '../../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const systems = await getSystemRecords(locale);
|
||||
const categoryTags = await getSystemCategoryIndex(locale);
|
||||
|
||||
const title = `Systems · ${systems.length} · Open Design`;
|
||||
const description = ui.catalog.systems.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/plugins/systems/', Astro.site).toString(),
|
||||
numberOfItems: systems.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">Systems</span>
|
||||
</nav>
|
||||
|
||||
<header class="catalog-head">
|
||||
<span class="label">Plugins · Systems</span>
|
||||
<h1 class="display">{ui.catalog.systems.heading(systems.length)}</h1>
|
||||
<p class="lead">{ui.catalog.systems.lead}</p>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.systems.category}</span>
|
||||
<ul>
|
||||
{categoryTags.map((tag) => (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
|
||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
||||
<ul>
|
||||
{systems.map((s) => <SystemCard system={s} />)}
|
||||
</ul>
|
||||
</section>
|
||||
</Layout>
|
||||
241
apps/landing-page/app/pages/plugins/templates/[kind]/index.astro
Normal file
241
apps/landing-page/app/pages/plugins/templates/[kind]/index.astro
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/templates/<kind>/ — focused list for one artifact category.
|
||||
*
|
||||
* Reads from `plugins/_official/` (bundled-plugin registry). Same
|
||||
* filter the parent flat index applies, narrowed to one of the seven
|
||||
* artifact kinds. Subcategory chips are anchored on the page so a
|
||||
* single tap inside Prototype / Slides / Image / Video jumps to a
|
||||
* scene cluster without leaving the page.
|
||||
*/
|
||||
import Layout from '../../../../_components/sub-page-layout.astro';
|
||||
import PluginRow from '../../../../_components/plugin-row.astro';
|
||||
import {
|
||||
getBundledPlugins,
|
||||
type BundledPluginRecord,
|
||||
} from '../../../../_lib/bundled-plugins';
|
||||
import {
|
||||
PLUGIN_CATEGORIES,
|
||||
bundledRecordOf,
|
||||
categorizePlugin,
|
||||
categorizeSubcategory,
|
||||
getSubcategoriesFor,
|
||||
type PluginCategorySlug,
|
||||
} from '../../../../_lib/plugin-facets';
|
||||
import { localeFromPath, localizedHref } from '../../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return PLUGIN_CATEGORIES.map((cat) => ({
|
||||
params: { kind: cat.slug },
|
||||
props: { categorySlug: cat.slug },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categorySlug: PluginCategorySlug;
|
||||
}
|
||||
|
||||
const { categorySlug } = Astro.props as Props;
|
||||
const category = PLUGIN_CATEGORIES.find((c) => c.slug === categorySlug)!;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
const all = getBundledPlugins().filter((p) => p.bucket !== 'design-systems');
|
||||
|
||||
interface Item {
|
||||
record: BundledPluginRecord;
|
||||
subcategory: string | null;
|
||||
}
|
||||
|
||||
const items: Item[] = [];
|
||||
for (const record of all) {
|
||||
const matchable = bundledRecordOf(record);
|
||||
if (categorizePlugin(matchable) !== categorySlug) continue;
|
||||
items.push({
|
||||
record,
|
||||
subcategory: categorizeSubcategory(matchable, categorySlug),
|
||||
});
|
||||
}
|
||||
|
||||
const subcategories = getSubcategoriesFor(categorySlug);
|
||||
const subCounts = new Map<string, number>();
|
||||
for (const i of items) {
|
||||
if (i.subcategory) subCounts.set(i.subcategory, (subCounts.get(i.subcategory) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const totalCount = items.length;
|
||||
const title = `${category.label} templates · ${totalCount} · Open Design`;
|
||||
const description = category.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL(`/plugins/templates/${category.slug}/`, Astro.site).toString(),
|
||||
numberOfItems: totalCount,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/templates/')}>Templates</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">{category.label}</span>
|
||||
</nav>
|
||||
|
||||
<header class="catalog-head">
|
||||
<span class="label">Templates · {category.label}</span>
|
||||
<h1 class="display">{totalCount} {category.label.toLowerCase()} templates<span class="dot">.</span></h1>
|
||||
<p class="lead">{category.description}</p>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Render the artifact-kind chip rail on every category page so the
|
||||
other six kinds stay one click away. Each chip URL ends in
|
||||
`#filter-strip` so cross-page navigation keeps the chip rail in
|
||||
view instead of scrolling back to the header.
|
||||
*/}
|
||||
<section class="filter-strip" id="filter-strip" aria-label="Artifact kinds">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Artifact kind</span>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="chip chip-link" href={href('/plugins/templates/#filter-strip')}>All</a>
|
||||
</li>
|
||||
{PLUGIN_CATEGORIES.map((cat) => (
|
||||
<li>
|
||||
<a
|
||||
class={cat.slug === categorySlug ? 'chip chip-link is-active' : 'chip chip-link'}
|
||||
href={href(`/plugins/templates/${cat.slug}/#filter-strip`)}
|
||||
aria-current={cat.slug === categorySlug ? 'page' : undefined}
|
||||
>
|
||||
{cat.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{subcategories.length > 0 && (
|
||||
<section class="filter-strip" aria-label="Scenes" data-scene-strip>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Scene</span>
|
||||
<ul>
|
||||
<li>
|
||||
<button type="button" class="chip chip-link is-active" data-scene-pick="all">
|
||||
All<span class="chip-num">{totalCount}</span>
|
||||
</button>
|
||||
</li>
|
||||
{subcategories.map((sub) => {
|
||||
const count = subCounts.get(sub.slug) ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip-link"
|
||||
data-scene-pick={sub.slug}
|
||||
title={sub.description}
|
||||
>
|
||||
{sub.label}<span class="chip-num">{count}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label={`All ${category.label} templates`}>
|
||||
<ol data-scene-list>
|
||||
{items.map((item, idx) => (
|
||||
<PluginRow
|
||||
item={{ kind: 'bundled', record: item.record }}
|
||||
index={idx}
|
||||
dataScene={item.subcategory ?? 'none'}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* Client-side scene filter for /plugins/templates/<kind>/. Plain
|
||||
* `<script>` (no `is:inline`) so Astro processes it as a typed
|
||||
* client script and bundles it in the page bundle — the inline
|
||||
* variant was getting elided by the build for reasons we couldn't
|
||||
* trace from the rendered HTML.
|
||||
*
|
||||
* Rather than route to a separate URL per scene (would multiply
|
||||
* static pages by ~25 across 18 locales), we render every row once
|
||||
* and toggle visibility via a `data-scene-hidden` attribute the
|
||||
* CSS reacts to. Falls back gracefully when JS is off: every row
|
||||
* stays visible and the chip rail still reads naturally — the All
|
||||
* chip stays in its `is-active` state.
|
||||
*/}
|
||||
<script>
|
||||
(() => {
|
||||
const strip = document.querySelector('[data-scene-strip]');
|
||||
const list = document.querySelector('[data-scene-list]');
|
||||
if (!strip || !list) return;
|
||||
const buttons = strip.querySelectorAll('[data-scene-pick]');
|
||||
const items = list.querySelectorAll('[data-scene]');
|
||||
strip.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const pick = target.closest('[data-scene-pick]');
|
||||
if (!(pick instanceof HTMLElement)) return;
|
||||
const want = pick.getAttribute('data-scene-pick');
|
||||
buttons.forEach((b) => b.classList.toggle('is-active', b === pick));
|
||||
// Toggle inline `display` instead of relying on a scoped CSS
|
||||
// rule keyed off `[data-scene-hidden]`. Astro's scoper adds
|
||||
// `[data-astro-cid-...]` to the selector — but the `<li>` is
|
||||
// rendered by the PluginRow component, which carries its own
|
||||
// cid, so the page-scoped rule misses. Inline style avoids
|
||||
// the cross-component scoping mismatch entirely.
|
||||
items.forEach((li) => {
|
||||
const has = li.getAttribute('data-scene');
|
||||
const hide = want !== 'all' && has !== want;
|
||||
(li as HTMLElement).style.display = hide ? 'none' : '';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Scene-pick chips are rendered as <button> so we can attach a
|
||||
* single delegated click handler without worrying about anchor
|
||||
* default-href behaviour (which would otherwise try to scroll to
|
||||
* a non-existent #anchor when the JS no-ops).
|
||||
*
|
||||
* Resetting only the user-agent decorations (appearance, the bold
|
||||
* font shorthand defaults that override .chip's mono/11px, the
|
||||
* narrow button padding) — explicitly NOT inheriting font/color,
|
||||
* so .chip's `font-family: var(--mono)` and `font-size: 11px`
|
||||
* stay authoritative and the chip reads identical to the anchor
|
||||
* chips on the same rail.
|
||||
*/
|
||||
[data-scene-strip] .chip-link[data-scene-pick] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
/* Wipe button-default font shorthand explicitly. We can't
|
||||
`font: inherit` because that pulls in the outer 16px Inter; we
|
||||
want the .chip rule (mono / 11px / bold-ish) to win, which it
|
||||
does once the user-agent declaration is gone. */
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
122
apps/landing-page/app/pages/plugins/templates/index.astro
Normal file
122
apps/landing-page/app/pages/plugins/templates/index.astro
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
/*
|
||||
* /plugins/templates/ — visual templates landing page.
|
||||
*
|
||||
* Reads `plugins/_official/<bucket>/open-design.json` (the daemon's
|
||||
* bundled-plugin registry) and groups by the seven artifact kinds
|
||||
* the in-app Plugins home uses (Prototype / Live Artifact / Slides /
|
||||
* Image / Video / HyperFrames / Audio). Counts therefore match the
|
||||
* counts visitors see when they open Open Design itself.
|
||||
*
|
||||
* `design-systems` and `atoms` buckets are excluded — the former has
|
||||
* its own /plugins/systems/ tile, the latter is infrastructure the
|
||||
* client filters out of the public library.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import PluginRow from '../../../_components/plugin-row.astro';
|
||||
import { getBundledPlugins, type BundledPluginRecord } from '../../../_lib/bundled-plugins';
|
||||
import {
|
||||
PLUGIN_CATEGORIES,
|
||||
bundledRecordOf,
|
||||
categorizePlugin,
|
||||
type PluginCategorySlug,
|
||||
} from '../../../_lib/plugin-facets';
|
||||
import { localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
const all = getBundledPlugins().filter((p) => p.bucket !== 'design-systems');
|
||||
|
||||
interface ListItem {
|
||||
record: BundledPluginRecord;
|
||||
category: PluginCategorySlug | null;
|
||||
}
|
||||
|
||||
const items: ListItem[] = all.map((record) => ({
|
||||
record,
|
||||
category: categorizePlugin(bundledRecordOf(record)),
|
||||
}));
|
||||
|
||||
// Templates view = anything that lands in one of the seven artifact
|
||||
// kinds. The remainder (categorize === null) renders on /plugins/skills/.
|
||||
const templates = items.filter((i) => i.category !== null);
|
||||
|
||||
const categoryCounts = new Map<PluginCategorySlug, number>();
|
||||
for (const t of templates) {
|
||||
if (t.category) {
|
||||
categoryCounts.set(t.category, (categoryCounts.get(t.category) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = templates.length;
|
||||
const title = `Templates · ${totalCount} · Open Design`;
|
||||
const description = `${totalCount} runnable visual templates — prototypes, slides, image and video generators, motion compositions, audio kits. Mirrors the in-app Plugins home so the catalog reads identically here and inside Open Design.`;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/plugins/templates/', Astro.site).toString(),
|
||||
numberOfItems: totalCount,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="plugins" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/plugins/')}>Plugins</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">Templates</span>
|
||||
</nav>
|
||||
|
||||
<header class="catalog-head">
|
||||
<span class="label">Plugins · Templates</span>
|
||||
<h1 class="display">{totalCount} runnable templates<span class="dot">.</span></h1>
|
||||
<p class="lead">
|
||||
Every template ships a working preview — the catalog row’s thumbnail comes straight from
|
||||
the manifest poster the agent uses inside the product. Browse all of them below, or jump
|
||||
to one of the seven artifact kinds.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Same artifact-kind rail the category sub-pages render. On the
|
||||
flat /plugins/templates/ index the `All` chip is the active one,
|
||||
so visitors see at a glance that they're looking at every kind.
|
||||
Each chip URL ends in `#filter-strip` so cross-page navigation
|
||||
keeps the chip rail in view instead of scrolling back to the
|
||||
page header.
|
||||
*/}
|
||||
<section class="filter-strip" id="filter-strip" aria-label="Artifact kinds">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Artifact kind</span>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="chip chip-link is-active" href={href('/plugins/templates/#filter-strip')} aria-current="page">All<span class="chip-num">{totalCount}</span></a>
|
||||
</li>
|
||||
{PLUGIN_CATEGORIES.map((cat) => {
|
||||
const count = categoryCounts.get(cat.slug) ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/plugins/templates/${cat.slug}/#filter-strip`)}>
|
||||
{cat.label}<span class="chip-num">{count}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label="All templates">
|
||||
<ol>
|
||||
{templates.map((item, idx) => (
|
||||
<PluginRow item={{ kind: 'bundled', record: item.record }} index={idx} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -2,12 +2,16 @@
|
|||
/*
|
||||
* /skills/<slug>/ — a detail page per skill.
|
||||
*
|
||||
* Mostly a structured read-out of SKILL.md frontmatter (description,
|
||||
* triggers, mode/scenario/platform, featured rank) plus deep links
|
||||
* to the GitHub source. We deliberately don't render the full
|
||||
* SKILL.md body to avoid duplicating the README and to keep the page
|
||||
* scan-friendly for both humans and search engines.
|
||||
* Two flavours render slightly differently:
|
||||
* - `template` skills get a click-to-expand iframe of their
|
||||
* `example.html` demo and stay deliberately brief — the demo is the
|
||||
* content, the README is one click away on GitHub.
|
||||
* - `instruction` skills (no runnable demo) instead render the full
|
||||
* SKILL.md body inline, so the page reads like a brief: what the
|
||||
* skill does, when it triggers, how to use it. Otherwise the page
|
||||
* would be a one-line description and a row of CTAs.
|
||||
*/
|
||||
import { getEntry, render } from 'astro:content';
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import LazyImg from '../../../_components/lazy-img.astro';
|
||||
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
|
||||
|
|
@ -167,6 +171,22 @@ const related = all
|
|||
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
|
||||
.slice(0, 4);
|
||||
|
||||
/*
|
||||
* Instruction skills don't have a runnable demo to iframe — to avoid
|
||||
* a near-empty detail page, render the SKILL.md prose inline so the
|
||||
* page reads like a brief. Template skills keep the page deliberately
|
||||
* brief because their demo is the content; their full SKILL.md is one
|
||||
* "Find on GitHub" click away.
|
||||
*
|
||||
* Astro 6 exposes the markdown pipeline through a top-level
|
||||
* `render(entry)` helper rather than the legacy `entry.render()`
|
||||
* method. The output (heading anchors, smart-typography, GFM
|
||||
* tables) styles cleanly with the existing `.detail-md` rules.
|
||||
*/
|
||||
const skillEntry =
|
||||
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
|
||||
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
|
|
@ -251,16 +271,16 @@ const jsonLd = [
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{skill.previewUrl && (
|
||||
{skill.kind === 'template' && skill.previewUrl && (
|
||||
<figure class="detail-preview">
|
||||
{/*
|
||||
Click-to-expand interactive preview. The thumb itself is the
|
||||
summary of a `<details>` element — clicking the image opens
|
||||
Click-to-expand interactive preview. Only template-kind skills
|
||||
ship a runnable example.html, so this block is gated on kind
|
||||
rather than just `previewUrl` — instruction skills now have a
|
||||
synthesized cover thumbnail too, but no iframe target. The
|
||||
thumb is the summary of a `<details>` element: clicking opens
|
||||
the live iframe, replacing the thumb with the canonical
|
||||
`<slug>/example.html` rendered inside a sandboxed frame. A
|
||||
hover overlay on the thumb hints "Click for live preview ↗"
|
||||
so the affordance is discoverable. The iframe loads lazily,
|
||||
only fetching once the user actually expands.
|
||||
`<slug>/example.html` rendered inside a sandboxed frame.
|
||||
*/}
|
||||
<details class="detail-preview-live">
|
||||
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
|
||||
|
|
@ -422,6 +442,13 @@ const jsonLd = [
|
|||
</section>
|
||||
)}
|
||||
|
||||
{SkillBody && (
|
||||
<section class="detail-block detail-md">
|
||||
<h2>About this skill</h2>
|
||||
<SkillBody />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.skills.related}</h2>
|
||||
|
|
|
|||
|
|
@ -131,6 +131,15 @@ body.sub-page {
|
|||
padding: 24px 0 28px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
/*
|
||||
* When the user clicks an artifact-kind chip on a different
|
||||
* /plugins/templates/<kind>/ page, the anchor `#filter-strip`
|
||||
* lands here. The 96px margin keeps the sticky site nav from
|
||||
* covering the chip rail and gives a breath of paper above the
|
||||
* chips so the rail reads as a "still here" marker rather than
|
||||
* snapping flush to the viewport edge.
|
||||
*/
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
.filter-group {
|
||||
display: grid;
|
||||
|
|
@ -178,6 +187,27 @@ body.sub-page {
|
|||
.chip-link:hover {
|
||||
background: rgba(237, 111, 92, 0.06);
|
||||
}
|
||||
/*
|
||||
* Active state for filter chips on the /plugins/templates/ rails. The
|
||||
* chip pointing at the page the user is on swaps to a filled coral
|
||||
* background so the rail keeps reading as a "you are here" marker
|
||||
* rather than a row of identical buttons.
|
||||
*/
|
||||
.chip-link.is-active,
|
||||
.chip-link[aria-current='page'] {
|
||||
background: var(--coral);
|
||||
border-color: var(--coral);
|
||||
color: #fff;
|
||||
}
|
||||
.chip-link.is-active .chip-num,
|
||||
.chip-link[aria-current='page'] .chip-num {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.chip-link.is-active:hover,
|
||||
.chip-link[aria-current='page']:hover {
|
||||
background: var(--coral);
|
||||
color: #fff;
|
||||
}
|
||||
.chip-num {
|
||||
color: var(--ink-mute);
|
||||
font-size: 10px;
|
||||
|
|
@ -767,6 +797,93 @@ body.sub-page {
|
|||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Inline-rendered SKILL.md body for instruction-kind skills. We strip
|
||||
* the SKILL.md H1 (already on the page as the detail header) and the
|
||||
* `> Curated from @author.` blockquote (already exposed in attribution),
|
||||
* but otherwise let Astro's standard markdown pipeline take over so
|
||||
* tables, code blocks, lists, etc. all render with the existing site
|
||||
* typography.
|
||||
*/
|
||||
.detail-md > h1:first-child,
|
||||
.detail-md > blockquote:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
.detail-md p,
|
||||
.detail-md ul,
|
||||
.detail-md ol {
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: var(--ink);
|
||||
max-width: 720px;
|
||||
}
|
||||
.detail-md p,
|
||||
.detail-md ul,
|
||||
.detail-md ol,
|
||||
.detail-md pre,
|
||||
.detail-md table {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.detail-md h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin: 28px 0 8px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.detail-md h4 {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
margin: 24px 0 6px;
|
||||
}
|
||||
.detail-md ul,
|
||||
.detail-md ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.detail-md li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-md code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--paper-dark, var(--paper-warm));
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.detail-md pre {
|
||||
background: var(--paper-dark, var(--paper-warm));
|
||||
border: 1px solid var(--line-soft);
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-md pre code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.detail-md a {
|
||||
color: var(--ink);
|
||||
border-bottom: 1px solid var(--ink-mute);
|
||||
}
|
||||
.detail-md a:hover {
|
||||
color: var(--coral);
|
||||
border-bottom-color: var(--coral);
|
||||
}
|
||||
.detail-md blockquote {
|
||||
margin: 0 0 18px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--line);
|
||||
color: var(--ink-mute);
|
||||
font-style: italic;
|
||||
}
|
||||
.block-lead {
|
||||
color: var(--ink-soft);
|
||||
font-size: 15px;
|
||||
|
|
|
|||
429
apps/landing-page/scripts/fallback-preview-card.ts
Normal file
429
apps/landing-page/scripts/fallback-preview-card.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
/**
|
||||
* Fallback preview-card renderer for skills that ship a `SKILL.md`
|
||||
* but no runnable `example.html` (instruction-only skills like
|
||||
* `copywriting`, `creative-director`, `competitive-ads-extractor`).
|
||||
*
|
||||
* Without this, the catalog row falls back to a diagonal-stripe
|
||||
* placeholder for ~70% of skills — visually cheap and undifferentiated.
|
||||
* We synthesize a typographic editorial card (skill name, tagline,
|
||||
* mode/category chips, attribution) and let `generate-previews.ts`
|
||||
* screenshot it through the same pipeline that handles real demos.
|
||||
*
|
||||
* The card's visual language matches the landing-page hero: warm paper
|
||||
* background, Playfair display serif headline with red-dot accent,
|
||||
* JetBrains Mono labels — so the catalog reads as one cohesive
|
||||
* publication rather than "real screenshots + grey placeholder".
|
||||
*
|
||||
* Frontmatter parsing is hand-rolled rather than pulling in a YAML
|
||||
* dependency: the SKILL.md schema is small (we need name, description,
|
||||
* od.mode, od.category, od.featured, od.upstream, body H1 attribution
|
||||
* line) and a tiny scanner is more predictable than `yaml` quirks
|
||||
* across 132 author-edited files.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface SkillCardMeta {
|
||||
/** Folder name, e.g. `color-expert`. */
|
||||
slug: string;
|
||||
/** SKILL.md frontmatter `name` if set, else the slug. */
|
||||
displayName: string;
|
||||
/** SKILL.md frontmatter `description` (already trimmed/joined). */
|
||||
description: string;
|
||||
/** SKILL.md frontmatter `od.mode`, e.g. `design-system`. */
|
||||
mode?: string;
|
||||
/** SKILL.md frontmatter `od.category`, e.g. `marketing-creative`. */
|
||||
category?: string;
|
||||
/**
|
||||
* SKILL.md frontmatter `od.featured` — used for sort order so the
|
||||
* card's Nº matches the row's position in the catalog index.
|
||||
*/
|
||||
featured?: number;
|
||||
/**
|
||||
* Author/maintainer attribution. We pull this from the body's
|
||||
* `> Curated from <X>.` line if present (most skills have it),
|
||||
* otherwise we derive a short host string from `od.upstream`.
|
||||
*/
|
||||
attribution?: string;
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
|
||||
|
||||
/**
|
||||
* Tiny YAML scanner just for the keys we need. Handles:
|
||||
* - top-level scalars: `name: foo` / `name: "foo"` / `name: 'foo'`
|
||||
* - block scalars with `|` or `>` (folded for description)
|
||||
* - one-level nesting under `od:` (mode, category, featured, upstream)
|
||||
*
|
||||
* Anything fancier (anchors, multi-doc, sequences nested under
|
||||
* sequences) we don't need — SKILL.md frontmatter never goes there.
|
||||
*/
|
||||
function parseSimpleYaml(yaml: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const lines = yaml.split('\n');
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i] ?? '';
|
||||
const line = raw.replace(/\r$/, '');
|
||||
if (line.trim().length === 0 || line.trimStart().startsWith('#')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level only — match key followed by colon, no indent.
|
||||
const topMatch = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
||||
if (topMatch) {
|
||||
const key = topMatch[1] ?? '';
|
||||
const inlineValue = (topMatch[2] ?? '').trim();
|
||||
|
||||
// Block scalar: `key: |` or `key: >`.
|
||||
if (inlineValue === '|' || inlineValue === '>') {
|
||||
i++;
|
||||
const buf: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const next = lines[i] ?? '';
|
||||
if (next.length === 0) {
|
||||
buf.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!/^\s/.test(next)) break; // dedented = end of block
|
||||
buf.push(next.replace(/^\s{2}/, '').trimEnd());
|
||||
i++;
|
||||
}
|
||||
out[key] = buf.join(inlineValue === '>' ? ' ' : '\n').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object: `key:` with indented children below.
|
||||
if (inlineValue === '') {
|
||||
const child: Record<string, unknown> = {};
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const next = lines[i] ?? '';
|
||||
if (next.length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const indented = /^\s{2}([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(next);
|
||||
if (!indented) break;
|
||||
const ckey = indented[1] ?? '';
|
||||
const cval = (indented[2] ?? '').trim();
|
||||
child[ckey] = stripScalar(cval);
|
||||
i++;
|
||||
}
|
||||
out[key] = child;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline scalar.
|
||||
out[key] = stripScalar(inlineValue);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripScalar(value: string): string | number | boolean {
|
||||
// Comments mid-line are rare in SKILL.md frontmatter; keep simple.
|
||||
let v = value.trim();
|
||||
if (
|
||||
(v.startsWith('"') && v.endsWith('"')) ||
|
||||
(v.startsWith("'") && v.endsWith("'"))
|
||||
) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the author/maintainer name out of the body — most SKILL.md files
|
||||
* carry a `> Curated from @handle.` blockquote right under the H1, and
|
||||
* surfacing it on the card preserves attribution at a glance. Falls back
|
||||
* to deriving a short host or path segment from `od.upstream`.
|
||||
*/
|
||||
function deriveAttribution(body: string, upstream?: string): string | undefined {
|
||||
const blockquote = /^>\s*(?:Curated\s+from|From)\s+([^.]+?)\.?\s*$/im.exec(body);
|
||||
if (blockquote && blockquote[1]) {
|
||||
return blockquote[1].trim();
|
||||
}
|
||||
if (!upstream) return undefined;
|
||||
// GitHub URL → @org/repo (or @user)
|
||||
const gh = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)/i.exec(upstream);
|
||||
if (gh && gh[1]) return `@${gh[1]}`;
|
||||
return upstream.replace(/^https?:\/\/(?:www\.)?/, '').replace(/\/.*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `<root>/skills/<slug>/SKILL.md` and shape it into a SkillCardMeta.
|
||||
* Returns null when the file doesn't exist (caller decides whether
|
||||
* that's a "no fallback needed" or a hard error).
|
||||
*/
|
||||
export function loadSkillCardMeta(skillsRoot: string, slug: string): SkillCardMeta | null {
|
||||
const file = path.join(skillsRoot, slug, 'SKILL.md');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const fmMatch = FRONTMATTER_RE.exec(raw);
|
||||
const body = fmMatch ? raw.slice(fmMatch[0].length) : raw;
|
||||
const fm = fmMatch ? parseSimpleYaml(fmMatch[1] ?? '') : {};
|
||||
const od = (fm.od as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
return {
|
||||
slug,
|
||||
displayName: (typeof fm.name === 'string' && fm.name) || slug,
|
||||
description: typeof fm.description === 'string' ? fm.description.trim() : '',
|
||||
mode: typeof od.mode === 'string' ? od.mode : undefined,
|
||||
category: typeof od.category === 'string' ? od.category : undefined,
|
||||
featured: typeof od.featured === 'number' ? od.featured : undefined,
|
||||
attribution: deriveAttribution(body, typeof od.upstream === 'string' ? od.upstream : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const VIEWPORT = { width: 1440, height: 900 } as const;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug renders at 128px by default. Long compound slugs
|
||||
* (`competitive-ads-extractor`, `weread-year-in-review-video-template`)
|
||||
* wrap to 2-3 lines. Cap aggressively so the headline never blows past
|
||||
* the description band.
|
||||
*/
|
||||
function pickSlugFontSize(slug: string): number {
|
||||
const len = slug.length;
|
||||
if (len <= 14) return 128;
|
||||
if (len <= 22) return 104;
|
||||
if (len <= 30) return 88;
|
||||
return 72;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the fallback card as a self-contained HTML document. Keep all
|
||||
* fonts loaded over the wire (Playwright will await `document.fonts.ready`
|
||||
* before snapshotting) so the screenshot matches what a visitor sees on
|
||||
* the live site. No build-time font baking.
|
||||
*/
|
||||
export function renderFallbackCard(meta: SkillCardMeta, indexInCatalog: number): string {
|
||||
const indexStr = String(indexInCatalog).padStart(3, '0');
|
||||
const chips: string[] = [];
|
||||
if (meta.mode) chips.push(meta.mode);
|
||||
if (meta.category && meta.category !== meta.mode) chips.push(meta.category);
|
||||
|
||||
const slugFontSize = pickSlugFontSize(meta.slug);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(meta.slug)} preview card</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,500;0,700;1,500&family=Inter:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--paper-warm: #efe7d2;
|
||||
--paper-dark: #e6dcc1;
|
||||
--ink: #1a1817;
|
||||
--ink-mute: #5b554b;
|
||||
--line: #c9bd9f;
|
||||
--accent: #d44b1e;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--paper-warm);
|
||||
color: var(--ink);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
width: ${VIEWPORT.width}px;
|
||||
height: ${VIEWPORT.height}px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 80px 96px 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(var(--paper-warm), var(--paper-warm)),
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent 0,
|
||||
transparent 22px,
|
||||
rgba(180, 165, 130, 0.08) 22px,
|
||||
rgba(180, 165, 130, 0.08) 23px
|
||||
);
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.top-bar .label {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.top-bar .label::before {
|
||||
content: '◆ ';
|
||||
color: var(--accent);
|
||||
}
|
||||
.body {
|
||||
margin-top: 56px;
|
||||
}
|
||||
.slug {
|
||||
font-family: 'Playfair Display', 'Georgia', serif;
|
||||
font-weight: 700;
|
||||
font-size: ${slugFontSize}px;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.slug .dot { color: var(--accent); }
|
||||
.desc {
|
||||
margin-top: 40px;
|
||||
max-width: 920px;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-mute);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.attribution {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: var(--ink-mute);
|
||||
text-align: right;
|
||||
}
|
||||
.attribution .from {
|
||||
display: block;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 32px 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="top-bar">
|
||||
<span class="label">Open Design · Skill</span>
|
||||
<span class="index">Nº ${indexStr}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h1 class="slug">${escapeHtml(meta.slug)}<span class="dot">.</span></h1>
|
||||
${meta.description ? `<p class="desc">${escapeHtml(meta.description)}</p>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<hr class="rule" />
|
||||
<div class="meta">
|
||||
<div class="chips">
|
||||
${chips.map((c) => `<span class="chip">${escapeHtml(c)}</span>`).join('')}
|
||||
</div>
|
||||
${
|
||||
meta.attribution
|
||||
? `<div class="attribution"><span class="from">Curated from</span><span>${escapeHtml(meta.attribution)}</span></div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export const FALLBACK_CARD_VIEWPORT = VIEWPORT;
|
||||
|
||||
/**
|
||||
* Lightweight wrapper for non-SKILL.md sources (notably the bundled
|
||||
* plugin manifests under `plugins/_official/`). Skips the YAML
|
||||
* parsing path and feeds whatever metadata the caller already has
|
||||
* straight into the same card renderer.
|
||||
*
|
||||
* The card visual is identical to the SKILL.md path so the catalog
|
||||
* reads as one publication regardless of where each entry's data
|
||||
* came from.
|
||||
*/
|
||||
export interface ExternalCardMeta {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mode?: string;
|
||||
category?: string;
|
||||
attribution?: string;
|
||||
}
|
||||
|
||||
export function renderCardFromExternal(
|
||||
meta: ExternalCardMeta,
|
||||
indexInSection: number,
|
||||
): string {
|
||||
return renderFallbackCard(
|
||||
{
|
||||
slug: meta.slug,
|
||||
displayName: meta.title || meta.slug,
|
||||
description: meta.description,
|
||||
mode: meta.mode,
|
||||
category: meta.category,
|
||||
attribution: meta.attribution,
|
||||
},
|
||||
indexInSection,
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ import { mkdir, cp, readdir, readFile, stat, unlink, writeFile } from 'node:fs/p
|
|||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import {
|
||||
loadSkillCardMeta,
|
||||
renderFallbackCard,
|
||||
renderCardFromExternal,
|
||||
type SkillCardMeta,
|
||||
} from './fallback-preview-card.ts';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LANDING_ROOT = path.resolve(HERE, '..');
|
||||
|
|
@ -33,6 +39,16 @@ const REPO_ROOT = path.resolve(LANDING_ROOT, '../..');
|
|||
const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
|
||||
const DESIGN_TEMPLATES_DIR = path.join(REPO_ROOT, 'design-templates');
|
||||
const TEMPLATES_DIR = path.join(REPO_ROOT, 'templates/live-artifacts');
|
||||
const BUNDLED_PLUGINS_DIR = path.join(REPO_ROOT, 'plugins/_official');
|
||||
// Buckets we walk under `plugins/_official/`. Order = registry walk order;
|
||||
// per-bucket previews land at `out/plugins/<manifest-id>.png` regardless.
|
||||
const BUNDLED_BUCKETS = [
|
||||
'examples',
|
||||
'image-templates',
|
||||
'video-templates',
|
||||
'scenarios',
|
||||
'design-systems',
|
||||
] as const;
|
||||
const OUT_DIR = path.join(LANDING_ROOT, 'public/previews');
|
||||
const LANDING_PACKAGE_JSON = path.join(LANDING_ROOT, 'package.json');
|
||||
const MANIFEST_PATH = path.join(OUT_DIR, '.manifest.json');
|
||||
|
|
@ -44,12 +60,19 @@ const PREVIEW_GENERATOR_VERSION = '2026-05-22-incremental-1';
|
|||
const MANIFEST_VERSION = 1;
|
||||
|
||||
interface Job {
|
||||
bucket: 'skills' | 'templates';
|
||||
bucket: 'skills' | 'templates' | 'plugins';
|
||||
slug: string;
|
||||
htmlPath: string;
|
||||
sourceRoot: string;
|
||||
/** Optional ready-made preview to copy verbatim (skips browser). */
|
||||
reuseFrom?: string;
|
||||
/**
|
||||
* When set, the renderer screenshots this in-memory HTML instead of
|
||||
* navigating to `htmlPath`. Used by the fallback-card path for skills
|
||||
* that ship a SKILL.md but no runnable demo. `htmlPath` still points
|
||||
* at SKILL.md so the source-hash machinery picks up edits.
|
||||
*/
|
||||
htmlContent?: string;
|
||||
}
|
||||
|
||||
interface PreviewManifestEntry {
|
||||
|
|
@ -189,6 +212,11 @@ async function sourceHashForJob(job: Job, directoryHashes: Map<string, string>):
|
|||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
// Fallback-card jobs encode their input in `htmlContent` (template
|
||||
// output for SKILL.md frontmatter). Folding it into the source hash
|
||||
// means a template tweak invalidates only the 96 fallbacks, not the
|
||||
// expensive real-demo screenshots that don't depend on it.
|
||||
if (job.htmlContent) hash.update(job.htmlContent);
|
||||
hash.update(baseHash);
|
||||
hash.update(await hashExtraDependencyRoots(job, directoryHashes));
|
||||
return hash.digest('hex');
|
||||
|
|
@ -248,6 +276,196 @@ async function removeIfExists(filePath: string): Promise<void> {
|
|||
await unlink(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills without a runnable `example.html` — pure SKILL.md instruction
|
||||
* skills like `copywriting`, `creative-director`, `competitive-ads-extractor`.
|
||||
* We synthesize a typographic editorial card and screenshot it so the
|
||||
* catalog row stops falling back to a blank diagonal-stripe placeholder.
|
||||
*
|
||||
* The card's Nº matches the row's position WITHIN the instruction
|
||||
* section (1..N over instruction skills only, sorted by featured asc
|
||||
* then alphabetical). That keeps card numbering stable with what the
|
||||
* catalog renders on `/skills/instructions/` and the Instructions
|
||||
* section of `/skills/`.
|
||||
*/
|
||||
async function buildFallbackCardJobs(): Promise<Job[]> {
|
||||
const skillJobs = await buildFallbackCardJobsFor({
|
||||
sourceRoot: SKILLS_DIR,
|
||||
bucket: 'skills',
|
||||
hasRunnableDemo: (slug) => existsSync(path.join(SKILLS_DIR, slug, 'example.html')),
|
||||
});
|
||||
|
||||
// Some `design-templates/<slug>/` ship neither example.html nor a
|
||||
// ready-made preview.png — typically instruction-style design briefs
|
||||
// misfiled under `design-templates/` instead of `skills/`. Without
|
||||
// this, the catalog row falls back to the diagonal-stripe placeholder
|
||||
// on `/plugins/templates/`. Treat them like instruction skills and
|
||||
// synthesize the same editorial card.
|
||||
const designTemplateJobs = await buildFallbackCardJobsFor({
|
||||
sourceRoot: DESIGN_TEMPLATES_DIR,
|
||||
bucket: 'templates',
|
||||
hasRunnableDemo: (slug) =>
|
||||
existsSync(path.join(DESIGN_TEMPLATES_DIR, slug, 'example.html')) ||
|
||||
existsSync(path.join(DESIGN_TEMPLATES_DIR, slug, 'preview.png')),
|
||||
});
|
||||
|
||||
return [...skillJobs, ...designTemplateJobs];
|
||||
}
|
||||
|
||||
async function buildFallbackCardJobsFor(args: {
|
||||
sourceRoot: string;
|
||||
bucket: 'skills' | 'templates';
|
||||
hasRunnableDemo: (slug: string) => boolean;
|
||||
}): Promise<Job[]> {
|
||||
const allSlugs: string[] = [];
|
||||
for (const entry of await readdir(args.sourceRoot, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) allSlugs.push(entry.name);
|
||||
}
|
||||
|
||||
const fallbackSlugs = allSlugs.filter((slug) => !args.hasRunnableDemo(slug));
|
||||
|
||||
const metas = fallbackSlugs
|
||||
.map((slug) => loadSkillCardMeta(args.sourceRoot, slug))
|
||||
.filter((m): m is SkillCardMeta => m !== null);
|
||||
|
||||
// Match `_lib/catalog.ts` → `getSkillRecords` sort: featured (∞ if
|
||||
// unset) ascending, then alphabetical. Numbering is per-bucket so a
|
||||
// given source folder's fallback cards stay in sync with the route
|
||||
// that lists them (`/plugins/skills/` for skills, `/plugins/templates/`
|
||||
// for design-templates).
|
||||
metas.sort((a, b) => {
|
||||
const af = a.featured ?? Number.POSITIVE_INFINITY;
|
||||
const bf = b.featured ?? Number.POSITIVE_INFINITY;
|
||||
if (af !== bf) return af - bf;
|
||||
return a.slug.localeCompare(b.slug);
|
||||
});
|
||||
|
||||
const jobs: Job[] = [];
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const meta = metas[i]!;
|
||||
const slugDir = path.join(args.sourceRoot, meta.slug);
|
||||
jobs.push({
|
||||
bucket: args.bucket,
|
||||
slug: meta.slug,
|
||||
htmlPath: path.join(slugDir, 'SKILL.md'),
|
||||
sourceRoot: slugDir,
|
||||
htmlContent: renderFallbackCard(meta, i + 1),
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled plugins (`plugins/_official/<bucket>/<slug>/open-design.json`)
|
||||
* are the daemon's canonical plugin registry, and the in-app Plugins
|
||||
* home reads from here. The marketing site's `/plugins/...` routes
|
||||
* mirror the same data, so every bundled entry that doesn't ship a
|
||||
* remote `od.preview.poster` URL needs a locally generated thumb so
|
||||
* catalog rows never fall back to the diagonal-stripe placeholder.
|
||||
*
|
||||
* Three preview paths, in priority order:
|
||||
* 1. Manifest already carries `od.preview.poster` (R2/CDN URL) — we
|
||||
* don't generate anything; the route's <img src> points straight
|
||||
* at the remote URL. Caller is expected to skip these.
|
||||
* 2. Manifest carries `od.preview.entry` pointing at a local
|
||||
* `example.html` — Playwright screenshots that file in-place,
|
||||
* treating the slug folder as the asset root so relative
|
||||
* `./assets/...` resolves correctly.
|
||||
* 3. Neither — synthesize the typographic fallback card from the
|
||||
* manifest's `title`/`description`/`mode`/`scenario`/`tags`. Same
|
||||
* visual as the SKILL.md fallback, just sourced from JSON.
|
||||
*
|
||||
* Output filename is the manifest `name` (e.g.
|
||||
* `image-template-3d-stone-staircase-evolution-infographic.png`) so
|
||||
* `<img src="/previews/plugins/<manifest-id>.png">` resolves directly.
|
||||
*/
|
||||
async function buildBundledPluginJobs(): Promise<Job[]> {
|
||||
if (!existsSync(BUNDLED_PLUGINS_DIR)) return [];
|
||||
|
||||
const jobs: Job[] = [];
|
||||
// Track per-bucket index for the fallback card's Nº.
|
||||
const fallbackIndexByBucket = new Map<string, number>();
|
||||
|
||||
for (const bucket of BUNDLED_BUCKETS) {
|
||||
const bucketDir = path.join(BUNDLED_PLUGINS_DIR, bucket);
|
||||
if (!existsSync(bucketDir)) continue;
|
||||
const entries = await readdir(bucketDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
||||
|
||||
const slugDir = path.join(bucketDir, entry.name);
|
||||
const manifestPath = path.join(slugDir, 'open-design.json');
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter atoms (infrastructure) — they don't need a thumbnail.
|
||||
const od = (raw.od ?? {}) as Record<string, unknown>;
|
||||
if (od.kind === 'atom') continue;
|
||||
|
||||
const manifestId = typeof raw.name === 'string' ? raw.name : entry.name;
|
||||
|
||||
// Path 1: manifest ships a poster URL → no local generation.
|
||||
const preview = (od.preview ?? {}) as Record<string, unknown>;
|
||||
if (typeof preview.poster === 'string' && preview.poster.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path 2: manifest ships a local entry HTML.
|
||||
const entryRel = typeof preview.entry === 'string' ? preview.entry : null;
|
||||
if (entryRel) {
|
||||
const entryAbs = path.resolve(slugDir, entryRel);
|
||||
if (existsSync(entryAbs)) {
|
||||
jobs.push({
|
||||
bucket: 'plugins',
|
||||
slug: manifestId,
|
||||
htmlPath: entryAbs,
|
||||
sourceRoot: slugDir,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Path 3: synthesize a fallback card from manifest fields.
|
||||
const idx = (fallbackIndexByBucket.get(bucket) ?? 0) + 1;
|
||||
fallbackIndexByBucket.set(bucket, idx);
|
||||
jobs.push({
|
||||
bucket: 'plugins',
|
||||
slug: manifestId,
|
||||
htmlPath: manifestPath,
|
||||
sourceRoot: slugDir,
|
||||
htmlContent: renderCardFromExternal(
|
||||
{
|
||||
slug: manifestId,
|
||||
title: typeof raw.title === 'string' ? raw.title : manifestId,
|
||||
description:
|
||||
typeof raw.description === 'string' ? raw.description : '',
|
||||
mode: typeof od.mode === 'string' ? od.mode : undefined,
|
||||
category: typeof od.scenario === 'string' ? od.scenario : undefined,
|
||||
attribution:
|
||||
typeof (raw.author as Record<string, unknown> | undefined)?.name ===
|
||||
'string'
|
||||
? ((raw.author as Record<string, unknown>).name as string)
|
||||
: undefined,
|
||||
},
|
||||
idx,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
async function discoverJobs(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
|
||||
|
|
@ -265,6 +483,18 @@ async function discoverJobs(): Promise<Job[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Synthesize cards for every other SKILL.md so the catalog never
|
||||
// shows a bare diagonal-stripe placeholder when the agent has nothing
|
||||
// demo-able to render.
|
||||
jobs.push(...(await buildFallbackCardJobs()));
|
||||
|
||||
// Bundled-plugin manifests under `plugins/_official/`. Renders local
|
||||
// example.html where present, falls back to the same typographic
|
||||
// card the SKILL.md path uses otherwise. Manifests with a remote
|
||||
// `od.preview.poster` URL are skipped — the catalog page points
|
||||
// straight at the CDN URL.
|
||||
jobs.push(...(await buildBundledPluginJobs()));
|
||||
|
||||
if (existsSync(DESIGN_TEMPLATES_DIR)) {
|
||||
const designTemplateEntries = await readdir(DESIGN_TEMPLATES_DIR, { withFileTypes: true });
|
||||
for (const entry of designTemplateEntries) {
|
||||
|
|
@ -343,10 +573,22 @@ async function captureOne(browser: Browser, job: Job): Promise<{
|
|||
});
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(pathToFileURL(job.htmlPath).toString(), {
|
||||
waitUntil: 'load',
|
||||
timeout: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
if (job.htmlContent) {
|
||||
// In-memory render path (fallback cards). `setContent` resolves
|
||||
// before `<link rel="stylesheet">` finishes, so explicitly wait
|
||||
// for `load` and `document.fonts.ready` — without this the
|
||||
// screenshot captures a flash of unstyled serif glyphs.
|
||||
await page.setContent(job.htmlContent, {
|
||||
waitUntil: 'load',
|
||||
timeout: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
} else {
|
||||
await page.goto(pathToFileURL(job.htmlPath).toString(), {
|
||||
waitUntil: 'load',
|
||||
timeout: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
await page.screenshot({
|
||||
path: targetPng,
|
||||
|
|
|
|||
Loading…
Reference in a new issue