mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
The 2026-05 plugins library rebuild introduced /plugins/skills/, /plugins/systems/, /plugins/templates/ and a unified detail route /plugins/<manifest-slug>/, but the old /skills/, /systems/, /templates/ catalogs were left live in parallel. Two equivalent page trees split SEO equity, and the homepage, footer, quickstart, agents, official and blog pages all still linked to the old routes. Retire the legacy generators and 301 every old URL to its new plugins equivalent so inbound links and search equity are preserved: - Remove the /skills, /systems, /templates page generators (English + [locale] wrappers) and the now-orphaned skill-row component, and prune the skills/systems/templates branches from the [locale]/[...path] catch-all (it now renders only craft + blog). - Add the migration block to public/_redirects. Detail slugs differ from the old folder names (new slugs are manifest-name based, e.g. design-system-<x>, example-<x>), so systems/templates use a prefixed splat plus a short degrade list, and skills map the 27 with a template equivalent explicitly while the ~110 instruction-only skills and all mode/scenario/category facet pages degrade to the section landing. 'replicate' is forced to the section to avoid colliding with the design-system of the same name. Locale variants (zh, zh-tw, ja, ko) strip to the section. - Repoint in-site links to /plugins/* across page.tsx (footer, work, labs pills), info-page-i18n.ts (en + zh + sourceNames), official, quickstart, agents, blog and html-anything, and update the sitemap serialize priority list. The system-card keeps linking through /systems/<slug>/ so the 8 systems without a detail page ride the redirect's degrade rather than pointing at a missing page. Verified with a full astro build: old routes no longer emit any HTML, the new section pages exist, _redirects is copied verbatim, and no in-site link targets a removed route (the remaining /systems/<slug>/ hrefs are the system cards that 301 by design). astro check passes. Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
484 lines
14 KiB
Text
484 lines
14 KiB
Text
---
|
|
/*
|
|
* Blog post — `/blog/[slug]/`
|
|
*
|
|
* Static route generated from the `blog` Content Collection. Renders one
|
|
* post with a narrow editorial body, magazine-style masthead, and a back
|
|
* link to the index. Reuses landing CSS variables and the shared header.
|
|
*/
|
|
import { getCollection, render } from 'astro:content';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import GoogleAnalytics from '../../_components/google-analytics.astro';
|
|
import HeaderEnhancer from '../../_components/header-enhancer.astro';
|
|
import { Header, type HeaderProps } from '../../_components/header';
|
|
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
|
|
import SeoHead from '../../_components/seo-head.astro';
|
|
import SiteFooter from '../../_components/site-footer.astro';
|
|
import Topbar from '../../_components/topbar.astro';
|
|
import { getCatalogCounts } from '../../_lib/catalog';
|
|
import { getGithubRepoMeta } from '../../_lib/github';
|
|
import {
|
|
explicitLocalizedString,
|
|
localizeBlogPostText,
|
|
} from '../../content-i18n';
|
|
import {
|
|
getLandingUiCopy,
|
|
getLocaleDefinition,
|
|
localeFromPath,
|
|
localizedHref,
|
|
} from '../../i18n';
|
|
import '../../globals.css';
|
|
import '../../sub-pages.css';
|
|
|
|
export async function getStaticPaths() {
|
|
const posts = await getCollection('blog');
|
|
return posts.map((post) => ({
|
|
params: { slug: post.id },
|
|
props: { post },
|
|
}));
|
|
}
|
|
|
|
const { post } = Astro.props;
|
|
const { Content } = await render(post);
|
|
const locale = localeFromPath(Astro.url.pathname);
|
|
const localeDef = getLocaleDefinition(locale);
|
|
const ui = getLandingUiCopy(locale);
|
|
const href = (path: string) => localizedHref(path, locale);
|
|
const localizedPostI18n = (post.data as typeof post.data & {
|
|
i18n?: Record<string, Partial<Record<'title' | 'summary' | 'category' | 'bodyHtml', string>>>;
|
|
}).i18n?.[locale];
|
|
const fallbackPost = localizeBlogPostText({
|
|
id: post.id,
|
|
title: post.data.title,
|
|
summary: post.data.summary,
|
|
category: post.data.category,
|
|
locale,
|
|
});
|
|
const localizedPostField = (field: 'title' | 'summary' | 'category') => {
|
|
return explicitLocalizedString(
|
|
localizedPostI18n?.[field] as Parameters<typeof explicitLocalizedString>[0],
|
|
locale,
|
|
) ?? fallbackPost[field];
|
|
};
|
|
const localizedBodyHtml = explicitLocalizedString(
|
|
localizedPostI18n?.bodyHtml as Parameters<typeof explicitLocalizedString>[0],
|
|
locale,
|
|
) ?? fallbackPost.bodyHtml;
|
|
const DISCORD = 'https://discord.gg/9ptkbbqRu';
|
|
const sourceUrl = `https://github.com/nexu-io/open-design/blob/main/apps/landing-page/app/content/blog/${post.id}.md`;
|
|
const category = post.data.category.toLowerCase();
|
|
const bottomCta =
|
|
category.includes('announcement') || category.includes('product')
|
|
? {
|
|
title: ui.blog.cta.downloadTitle,
|
|
body: ui.blog.cta.downloadBody,
|
|
href: 'https://github.com/nexu-io/open-design/releases',
|
|
label: ui.blog.cta.downloadLabel,
|
|
external: true,
|
|
}
|
|
: category.includes('guide') || category.includes('use') || category.includes('case')
|
|
? {
|
|
title: ui.blog.cta.skillsTitle,
|
|
body: ui.blog.cta.skillsBody,
|
|
href: '/plugins/skills/',
|
|
label: ui.blog.cta.skillsLabel,
|
|
external: false,
|
|
}
|
|
: {
|
|
title: ui.blog.cta.repoTitle,
|
|
body: ui.blog.cta.repoBody,
|
|
href: 'https://github.com/nexu-io/open-design',
|
|
label: ui.blog.cta.repoLabel,
|
|
external: true,
|
|
};
|
|
|
|
const counts = await getCatalogCounts();
|
|
const github = await getGithubRepoMeta();
|
|
|
|
const origin = Astro.site?.toString() ?? 'https://open-design.ai/';
|
|
const postUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://open-design.ai/').toString();
|
|
|
|
// BreadcrumbList for the blog post. SeoHead already emits the Article
|
|
// JSON-LD via `kind='article'`; the breadcrumb here adds the trail
|
|
// (Open Design → Blog → <post title>) so Google can render the post
|
|
// with breadcrumb-style URLs in the SERP.
|
|
const breadcrumbJsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: origin },
|
|
{ '@type': 'ListItem', position: 2, name: 'Blog', item: new URL('/blog/', origin).toString() },
|
|
{ '@type': 'ListItem', position: 3, name: localizedPostField('title'), item: postUrl },
|
|
],
|
|
};
|
|
const headerHtml = renderToStaticMarkup(
|
|
createElement<HeaderProps>(Header, {
|
|
counts,
|
|
github,
|
|
brandHref: '/',
|
|
active: 'blog',
|
|
locale,
|
|
}),
|
|
);
|
|
|
|
const fmtDate = (d: Date) =>
|
|
d.toLocaleDateString(localeDef.htmlLang, {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
---
|
|
|
|
<!doctype html>
|
|
<html lang={localeDef.htmlLang} dir={localeDef.dir}>
|
|
<head>
|
|
<meta charset='utf-8' />
|
|
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
|
<SeoHead
|
|
kind='article'
|
|
title={localizedPostField('title')}
|
|
description={localizedPostField('summary')}
|
|
pathname={Astro.url.pathname}
|
|
datePublished={post.data.date}
|
|
category={post.data.category}
|
|
/>
|
|
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)} />
|
|
<GoogleAnalytics />
|
|
</head>
|
|
<body>
|
|
<div class='shell'>
|
|
<div class='site-chrome' data-chrome-headroom>
|
|
<Topbar github={github} locale={locale} />
|
|
<Fragment set:html={headerHtml} />
|
|
</div>
|
|
|
|
<main class='post-shell'>
|
|
<article class='post'>
|
|
<div class='container'>
|
|
<a class='post-back' href={href('/blog/')}>{ui.blog.backToBlog}</a>
|
|
|
|
<h1 class='post-title'>{localizedPostField('title')}</h1>
|
|
|
|
<p class='post-summary'>{localizedPostField('summary')}</p>
|
|
|
|
<div class='post-meta'>
|
|
<span>{fmtDate(post.data.date)}</span>
|
|
<span>{post.data.readingTime} {ui.blog.minRead}</span>
|
|
<span>{localizedPostField('category')}</span>
|
|
</div>
|
|
|
|
<hr class='post-rule' />
|
|
|
|
<div class='post-body'>
|
|
{localizedBodyHtml ? (
|
|
<Fragment set:html={localizedBodyHtml} />
|
|
) : (
|
|
<Content />
|
|
)}
|
|
</div>
|
|
|
|
<aside class='post-conversion' aria-labelledby='post-conversion-title'>
|
|
<div>
|
|
<span class='label'>{ui.blog.nextStep}</span>
|
|
<h2 id='post-conversion-title'>{bottomCta.title}</h2>
|
|
<p>{bottomCta.body}</p>
|
|
</div>
|
|
<div class='post-conversion-actions'>
|
|
<a
|
|
class='btn btn-primary'
|
|
href={bottomCta.external === false ? href(bottomCta.href) : bottomCta.href}
|
|
target={bottomCta.external === false ? undefined : '_blank'}
|
|
rel={bottomCta.external === false ? undefined : 'noreferrer noopener'}
|
|
>
|
|
{bottomCta.label}
|
|
</a>
|
|
<a class='btn btn-ghost' href={DISCORD} target='_blank' rel='noreferrer noopener'>
|
|
{ui.blog.joinDiscord}
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<hr class='post-rule' />
|
|
|
|
<div class='post-foot'>
|
|
<a class='post-back' href={href('/blog/')}>{ui.blog.backToBlog}</a>
|
|
<a
|
|
class='post-github'
|
|
href={sourceUrl}
|
|
target='_blank'
|
|
rel='noreferrer noopener'
|
|
>
|
|
{ui.blog.viewSource}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</main>
|
|
<SiteFooter counts={counts} locale={locale} />
|
|
</div>
|
|
|
|
<HeaderEnhancer />
|
|
<LocaleSwitcherScript />
|
|
|
|
<style>
|
|
.post-shell {
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
.post-shell + .sub-footer {
|
|
margin-top: 0;
|
|
}
|
|
.post {
|
|
padding: 88px 0 96px;
|
|
}
|
|
.post .container {
|
|
max-width: 860px;
|
|
}
|
|
.post-back {
|
|
display: inline-block;
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
color: var(--ink-mute);
|
|
text-decoration: none;
|
|
margin-bottom: 36px;
|
|
transition: color 160ms ease;
|
|
}
|
|
.post-back:hover {
|
|
color: var(--coral);
|
|
}
|
|
.post-title {
|
|
font-family: var(--serif);
|
|
font-weight: 500;
|
|
font-size: clamp(40px, 5vw, 64px);
|
|
line-height: 1.08;
|
|
letter-spacing: -0.018em;
|
|
color: var(--ink);
|
|
margin: 18px 0 24px;
|
|
}
|
|
.post-summary {
|
|
font-family: var(--serif);
|
|
font-style: italic;
|
|
font-weight: 400;
|
|
font-size: clamp(19px, 1.8vw, 22px);
|
|
line-height: 1.45;
|
|
color: var(--ink-soft);
|
|
max-width: 56ch;
|
|
margin-bottom: 28px;
|
|
}
|
|
.post-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px 10px;
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
color: var(--ink-faint);
|
|
margin-bottom: 32px;
|
|
}
|
|
.post-meta span + span::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 14px;
|
|
height: 1px;
|
|
margin-right: 10px;
|
|
vertical-align: middle;
|
|
background: var(--line);
|
|
}
|
|
.post-rule {
|
|
border: 0;
|
|
border-top: 1px solid var(--line);
|
|
margin: 0 0 40px;
|
|
}
|
|
.post-body {
|
|
font-family: var(--body);
|
|
font-size: 17px;
|
|
line-height: 1.7;
|
|
color: var(--ink-soft);
|
|
}
|
|
.post-body :global(p) {
|
|
margin: 0 0 1.4em;
|
|
}
|
|
.post-body :global(h2) {
|
|
font-family: var(--serif);
|
|
font-weight: 500;
|
|
font-size: clamp(26px, 2.6vw, 32px);
|
|
line-height: 1.2;
|
|
letter-spacing: -0.012em;
|
|
color: var(--ink);
|
|
margin: 2.2em 0 0.8em;
|
|
}
|
|
.post-body :global(h3) {
|
|
font-family: var(--sans);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
color: var(--ink);
|
|
margin: 1.8em 0 0.6em;
|
|
}
|
|
.post-body :global(a) {
|
|
color: var(--coral);
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
text-decoration-thickness: 1px;
|
|
}
|
|
.post-body :global(strong) {
|
|
color: var(--ink);
|
|
font-weight: 600;
|
|
}
|
|
.post-body :global(em) {
|
|
font-family: var(--serif);
|
|
font-style: italic;
|
|
}
|
|
.post-body :global(ul),
|
|
.post-body :global(ol) {
|
|
padding-left: 1.4em;
|
|
margin: 0 0 1.4em;
|
|
}
|
|
.post-body :global(li) {
|
|
margin-bottom: 0.4em;
|
|
}
|
|
.post-body :global(blockquote) {
|
|
border-left: 2px solid var(--coral);
|
|
padding-left: 18px;
|
|
margin: 1.6em 0;
|
|
font-family: var(--serif);
|
|
font-style: italic;
|
|
color: var(--ink-soft);
|
|
}
|
|
.post-body :global(code) {
|
|
font-family: var(--mono);
|
|
font-size: 0.9em;
|
|
background: var(--paper-warm);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
color: var(--ink);
|
|
}
|
|
.post-body :global(pre) {
|
|
background: var(--bone);
|
|
border: 1px solid var(--line-soft);
|
|
padding: 20px 22px;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
margin: 1.4em 0;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
/*
|
|
* Astro/Shiki stamps an inline `background-color:#xxx; color:#xxx;`
|
|
* on `.astro-code` based on the configured theme. We supply a
|
|
* custom paper theme in `astro.config.ts`, but pin the background
|
|
* to `--bone` here as a belt-and-braces guard against a theme
|
|
* upgrade silently re-introducing a slate slab.
|
|
*/
|
|
.post-body :global(pre.astro-code),
|
|
.post-body :global(pre.shiki) {
|
|
background-color: var(--bone) !important;
|
|
color: var(--ink) !important;
|
|
}
|
|
.post-body :global(pre code) {
|
|
background: transparent;
|
|
padding: 0;
|
|
font-size: inherit;
|
|
}
|
|
.post-body :global(table) {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 1.4em 0;
|
|
font-family: var(--body);
|
|
font-size: 15px;
|
|
}
|
|
.post-body :global(th),
|
|
.post-body :global(td) {
|
|
border-bottom: 1px solid var(--line-soft);
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
.post-body :global(th) {
|
|
font-family: var(--sans);
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.post-body :global(hr) {
|
|
border: 0;
|
|
border-top: 1px solid var(--line-soft);
|
|
margin: 2em 0;
|
|
}
|
|
|
|
.post-conversion {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 24px;
|
|
align-items: center;
|
|
margin: 52px 0 40px;
|
|
padding: 28px;
|
|
border: 1px solid var(--line);
|
|
background: color-mix(in srgb, var(--paper-warm) 72%, white);
|
|
}
|
|
.post-conversion .label {
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--coral);
|
|
}
|
|
.post-conversion h2 {
|
|
margin: 10px 0 8px;
|
|
font-family: var(--serif);
|
|
font-size: clamp(28px, 3vw, 38px);
|
|
font-weight: 500;
|
|
color: var(--ink);
|
|
}
|
|
.post-conversion p {
|
|
margin: 0;
|
|
max-width: 52ch;
|
|
color: var(--ink-soft);
|
|
}
|
|
.post-conversion-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.post-foot {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
margin-top: 8px;
|
|
}
|
|
.post-foot .post-back {
|
|
margin-bottom: 0;
|
|
}
|
|
.post-github {
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
text-decoration: none;
|
|
transition: color 160ms ease;
|
|
}
|
|
.post-github:hover {
|
|
color: var(--coral);
|
|
}
|
|
@media (max-width: 760px) {
|
|
.post {
|
|
padding: 56px 0 72px;
|
|
}
|
|
.post-foot {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
.post-conversion {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.post-conversion-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|