stack-site-builder 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n/ui.ts CHANGED
@@ -59,6 +59,7 @@ export const ui = {
59
59
  'nav.browse': 'Browse',
60
60
  'nav.concepts': 'Concepts',
61
61
  'nav.courses': 'Courses',
62
+ 'nav.products': 'Products',
62
63
  'nav.blog': 'Writing',
63
64
  'nav.samples': 'Samples',
64
65
  'nav.slides': 'Slides',
@@ -127,6 +128,9 @@ export const ui = {
127
128
  'course.updated': 'Updated',
128
129
  'course.relatedCourses': 'Related courses',
129
130
  'course.slides': 'Slides',
131
+ 'products.title': 'Products',
132
+ 'products.tagline': 'What we build and offer.',
133
+ 'products.empty': 'No products yet.',
130
134
  'detail.usedInConcepts': 'Used in concepts',
131
135
  'sort.label': 'Sort',
132
136
  'sort.alpha': 'A–Z',
@@ -218,6 +222,7 @@ export const ui = {
218
222
  'nav.browse': '둘러보기',
219
223
  'nav.concepts': '개념',
220
224
  'nav.courses': '강의',
225
+ 'nav.products': '제품',
221
226
  'nav.blog': '글',
222
227
  'nav.samples': '샘플',
223
228
  'nav.slides': '슬라이드',
@@ -285,6 +290,9 @@ export const ui = {
285
290
  'course.updated': '업데이트',
286
291
  'course.relatedCourses': '관련 강의',
287
292
  'course.slides': '슬라이드',
293
+ 'products.title': '제품',
294
+ 'products.tagline': '우리가 만들고 제공하는 것.',
295
+ 'products.empty': '아직 제품이 없습니다.',
288
296
  'detail.usedInConcepts': '이 도구가 쓰이는 개념',
289
297
  'sort.label': '정렬',
290
298
  'sort.alpha': '이름순',
@@ -7,6 +7,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher.astro';
7
7
  import ThemeToggle from '../components/ThemeToggle.astro';
8
8
  import BackToTop from '../components/BackToTop.astro';
9
9
  import { getNavPages, pageSlugOf } from '../lib/pages';
10
+ import { getNavProducts, getTopProducts, productSlugOf } from '../lib/products';
11
+ import { homeTemplate } from '../lib/home';
10
12
  import { sectionEnabled, type SectionKey } from '../lib/sections';
11
13
 
12
14
  interface Props {
@@ -31,6 +33,21 @@ const container = wide ? 'max-w-[81rem]' : 'max-w-[75rem]';
31
33
  const home = getRelativeLocaleUrl(lang, '');
32
34
  const base = import.meta.env.BASE_URL;
33
35
 
36
+ // Site-configurable browser icons (`site.icons`). `favicon` may be any image
37
+ // the browser can render (SVG/PNG/ICO — public/ path); appleTouch/manifest
38
+ // links render only when declared. Defaults to the theme's /favicon.svg.
39
+ const icons =
40
+ (site as { icons?: { favicon?: string; appleTouch?: string; manifest?: string } }).icons ?? {};
41
+ const faviconHref = icons.favicon ?? '/favicon.svg';
42
+ const faviconType = faviconHref.endsWith('.svg')
43
+ ? 'image/svg+xml'
44
+ : faviconHref.endsWith('.png')
45
+ ? 'image/png'
46
+ : faviconHref.endsWith('.ico')
47
+ ? 'image/x-icon'
48
+ : undefined;
49
+ const baseNoSlash = base.replace(/\/$/, '');
50
+
34
51
  // Home URL per locale, for the language auto-detect redirect on the root home.
35
52
  const localeHomes = Object.fromEntries(
36
53
  (Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
@@ -41,6 +58,14 @@ const isDefaultHome = path === '' && lang === 'en';
41
58
  // e.g. an About/소개 page. Rendered as their own nav items alongside the
42
59
  // built-in sections; the accessible label comes from the page's own frontmatter.
43
60
  const navPages = await getNavPages(lang);
61
+ // Products that opt into their own header link (the collection's `nav`), plus
62
+ // the Products-index link, which appears once the locale has any product.
63
+ const navProducts = await getNavProducts(lang);
64
+ const hasProducts = (await getTopProducts(lang)).length > 0;
65
+ // The header's GitHub link — hidden for sites whose repo is private
66
+ // (`repoNav: false` in site.ts) or that declare no repoUrl at all.
67
+ const showRepoLink =
68
+ !!site.repoUrl && (site as { repoNav?: boolean }).repoNav !== false;
44
69
 
45
70
  // The header sections, as data — rendered twice from one source so the two
46
71
  // layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
@@ -54,12 +79,28 @@ const allNavItems: {
54
79
  keepFilters?: boolean;
55
80
  section?: SectionKey;
56
81
  }[] = [
57
- {
58
- href: `${home}#categories`,
59
- label: t('nav.browse'),
60
- keepFilters: true,
61
- svg: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
62
- },
82
+ // Browse anchors into the catalog home's category sections — it only makes
83
+ // sense when the site keeps the catalog home template.
84
+ ...(homeTemplate === 'catalog'
85
+ ? [
86
+ {
87
+ href: `${home}#categories`,
88
+ label: t('nav.browse'),
89
+ keepFilters: true,
90
+ svg: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
91
+ },
92
+ ]
93
+ : []),
94
+ ...(hasProducts
95
+ ? [
96
+ {
97
+ href: getRelativeLocaleUrl(lang, 'products/'),
98
+ label: t('nav.products'),
99
+ section: 'products' as SectionKey,
100
+ svg: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/>',
101
+ },
102
+ ]
103
+ : []),
63
104
  {
64
105
  href: getRelativeLocaleUrl(lang, 'course/'),
65
106
  label: t('nav.courses'),
@@ -96,18 +137,28 @@ const allNavItems: {
96
137
  section: 'glossary',
97
138
  svg: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
98
139
  },
140
+ ...navProducts.map((a) => ({
141
+ href: getRelativeLocaleUrl(lang, `products/${productSlugOf(a)}/`),
142
+ label: a.data.navLabel ?? a.data.title,
143
+ section: 'products' as SectionKey,
144
+ svg: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
145
+ })),
99
146
  ...navPages.map((p) => ({
100
147
  href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
101
148
  label: p.data.navLabel ?? p.data.title,
102
149
  section: 'pages' as SectionKey,
103
150
  svg: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
104
151
  })),
105
- {
106
- href: site.repoUrl,
107
- label: 'GitHub',
108
- external: true,
109
- svg: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C4 2 3 2 3 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 2 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
110
- },
152
+ ...(showRepoLink
153
+ ? [
154
+ {
155
+ href: site.repoUrl,
156
+ label: 'GitHub',
157
+ external: true,
158
+ svg: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C4 2 3 2 3 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 2 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
159
+ },
160
+ ]
161
+ : []),
111
162
  ];
112
163
  // Drop the nav links for sections the site turned off (their routes aren't built).
113
164
  const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(item.section));
@@ -122,7 +173,9 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
122
173
  <title>{title}</title>
123
174
  <meta name="description" content={description} />
124
175
  {noindex && <meta name="robots" content="noindex" />}
125
- <link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
176
+ <link rel="icon" {...(faviconType ? { type: faviconType } : {})} href={`${baseNoSlash}${faviconHref}`} />
177
+ {icons.appleTouch && <link rel="apple-touch-icon" href={`${baseNoSlash}${icons.appleTouch}`} />}
178
+ {icons.manifest && <link rel="manifest" href={`${baseNoSlash}${icons.manifest}`} />}
126
179
  {
127
180
  sectionEnabled('articles') && (
128
181
  <link
@@ -0,0 +1,63 @@
1
+ import { site } from '@aas-data/site';
2
+ import { defaultLang, type Lang } from '../i18n/ui';
3
+
4
+ /**
5
+ * The data-driven "cards" homepage. By default the theme home is the stack
6
+ * catalog; a site that isn't a catalog (a studio/product site) declares
7
+ * `home: { template: 'cards', … }` in `src/data/site.ts` and gets a
8
+ * hero + card grid + CTA home instead. The catalog routes still build (they
9
+ * are simply empty when the site has no stacks), and the header's Browse
10
+ * link — which anchors into the catalog home — hides itself.
11
+ *
12
+ * Strings are `Localized`: either one string for every locale, or a
13
+ * per-locale record (`{ ko: '…', en: '…' }`) that falls back to the default
14
+ * locale, matching how category labels work.
15
+ */
16
+ export type Localized = string | Record<string, string>;
17
+
18
+ export interface HomeCard {
19
+ /** Locale-less internal path ("/products/foo/", prefixed per locale at
20
+ * render) or an external URL (set `external`). */
21
+ href: string;
22
+ external?: boolean;
23
+ name: Localized;
24
+ description?: Localized;
25
+ icon?: string; // public/ path
26
+ rounded?: boolean; // app-icon style rounding for the icon
27
+ tags?: string[] | Record<string, string[]>;
28
+ }
29
+
30
+ export interface HomeConfig {
31
+ template: 'cards';
32
+ hero?: {
33
+ icon?: string; // public/ path, shown above the title
34
+ title?: Localized; // defaults to site.name
35
+ subtitle?: Localized; // defaults to the site.tagline UI string
36
+ };
37
+ cardsTitle?: Localized;
38
+ cards?: HomeCard[];
39
+ cta?: {
40
+ title?: Localized;
41
+ description?: Localized;
42
+ button?: { label: Localized; href: string; external?: boolean };
43
+ };
44
+ }
45
+
46
+ export const home: HomeConfig | undefined = (site as { home?: HomeConfig }).home;
47
+
48
+ /** Which homepage the site gets: the catalog (default) or the cards home. */
49
+ export const homeTemplate: 'catalog' | 'cards' = home?.template === 'cards' ? 'cards' : 'catalog';
50
+
51
+ /** Resolve a Localized string for `lang` (default locale, then any, as fallback). */
52
+ export function loc(v: Localized | undefined, lang: Lang): string | undefined {
53
+ if (v == null) return undefined;
54
+ if (typeof v === 'string') return v;
55
+ return v[lang] ?? v[defaultLang] ?? Object.values(v)[0];
56
+ }
57
+
58
+ /** Resolve a per-locale (or shared) string list for `lang`. */
59
+ export function locList(v: string[] | Record<string, string[]> | undefined, lang: Lang): string[] {
60
+ if (v == null) return [];
61
+ if (Array.isArray(v)) return v;
62
+ return v[lang] ?? v[defaultLang] ?? Object.values(v)[0] ?? [];
63
+ }
@@ -0,0 +1,31 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import type { Lang } from '../i18n/ui';
3
+
4
+ export type ProductEntry = CollectionEntry<'products'>;
5
+
6
+ /** The url slug of a product page — its id minus the `<lang>/` prefix. May be
7
+ * nested (`flowstate-ai/privacy`), which the catch-all route renders at the
8
+ * matching subpath. */
9
+ export function productSlugOf(entry: ProductEntry): string {
10
+ return entry.id.replace(/^[a-z]{2}\//, '');
11
+ }
12
+
13
+ /** Published product pages for one locale, in `order` (then title) order. */
14
+ export async function getProducts(lang: Lang): Promise<ProductEntry[]> {
15
+ const all = await getCollection('products');
16
+ return all
17
+ .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
18
+ .sort((a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title));
19
+ }
20
+
21
+ /** The subset of {@link getProducts} that opts into a header-nav link. */
22
+ export async function getNavProducts(lang: Lang): Promise<ProductEntry[]> {
23
+ return (await getProducts(lang)).filter((e) => e.data.nav);
24
+ }
25
+
26
+ /** Top-level product pages for one locale — what the products index lists
27
+ * (and what makes the header's Products link appear). Landing and plain
28
+ * pages both count; nested subpages (privacy/terms) are excluded. */
29
+ export async function getTopProducts(lang: Lang): Promise<ProductEntry[]> {
30
+ return (await getProducts(lang)).filter((e) => !productSlugOf(e).includes('/'));
31
+ }
@@ -20,6 +20,7 @@ export type SectionKey =
20
20
  | 'concepts'
21
21
  | 'articles'
22
22
  | 'courses'
23
+ | 'products'
23
24
  | 'samples'
24
25
  | 'slides'
25
26
  | 'glossary'
@@ -29,6 +30,7 @@ const DEFAULTS: Record<SectionKey, boolean> = {
29
30
  concepts: true,
30
31
  articles: true,
31
32
  courses: false, // opt-in: needs src/data/course-categories.ts on the site
33
+ products: false, // opt-in: needs src/data/product-categories.ts on the site
32
34
  samples: true,
33
35
  slides: true,
34
36
  glossary: true,
@@ -3,6 +3,8 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
5
  import Home from '../../components/Home.astro';
6
+ import CardsHome from '../../components/CardsHome.astro';
7
+ import { homeTemplate } from '../../lib/home';
6
8
  import { allLocales, langParam } from '../../lib/locales';
7
9
 
8
10
  export const getStaticPaths = (() =>
@@ -12,5 +14,5 @@ const { lang } = Astro.props;
12
14
  ---
13
15
 
14
16
  <BaseLayout title={site.name} lang={lang} path="">
15
- <Home lang={lang} />
17
+ {homeTemplate === 'cards' ? <CardsHome lang={lang} /> : <Home lang={lang} />}
16
18
  </BaseLayout>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import { render } from 'astro:content';
4
+ import type { GetStaticPaths } from 'astro';
5
+ import BaseLayout from '../../../layouts/BaseLayout.astro';
6
+ import ProductLanding from '../../../components/ProductLanding.astro';
7
+ import PrivateGate from '../../../components/PrivateGate.astro';
8
+ import TocRail from '../../../components/TocRail.astro';
9
+ import MermaidLoader from '../../../components/MermaidLoader.astro';
10
+ import { getProducts, productSlugOf } from '../../../lib/products';
11
+ import { allLocales, langParam } from '../../../lib/locales';
12
+ import { inlineMd } from '../../../lib/inline-md';
13
+
14
+ export const getStaticPaths = (async () => {
15
+ const paths: Awaited<ReturnType<GetStaticPaths>> = [];
16
+ for (const lang of allLocales) {
17
+ for (const entry of await getProducts(lang)) {
18
+ paths.push({ params: { lang: langParam(lang), id: productSlugOf(entry) }, props: { lang, entry } });
19
+ }
20
+ }
21
+ return paths;
22
+ }) satisfies GetStaticPaths;
23
+
24
+ const { lang, entry } = Astro.props;
25
+ const slug = productSlugOf(entry);
26
+ const priv = entry.data.private;
27
+ const isLanding = entry.data.template === 'landing';
28
+ // Plain 'page' template (privacy/terms subpages) renders the markdown body.
29
+ const { Content, headings } = await render(entry);
30
+ const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
31
+ const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
32
+ ---
33
+
34
+ <BaseLayout
35
+ title={`${entry.data.title} — ${site.name}`}
36
+ description={priv ? entry.data.teaser : (entry.data.description ?? entry.data.subtitle)}
37
+ lang={lang}
38
+ path={`products/${slug}/`}
39
+ noindex={priv}
40
+ >
41
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
42
+ {
43
+ isLanding ? (
44
+ <ProductLanding entry={entry} lang={lang} />
45
+ ) : (
46
+ <>
47
+ <header class="pb-6">
48
+ <h1 class="text-3xl font-bold tracking-tight">{entry.data.title}</h1>
49
+ {entry.data.description && (
50
+ <p
51
+ class="mt-2 aas-md text-lg whitespace-pre-line text-[var(--aas-muted)]"
52
+ set:html={inlineMd(entry.data.description)}
53
+ />
54
+ )}
55
+ </header>
56
+ <div class="mt-4 flex items-start gap-8">
57
+ <article class="prose max-w-none aas-reading min-w-0 flex-1">
58
+ <Content />
59
+ </article>
60
+ <TocRail lang={lang} items={tocItems} />
61
+ </div>
62
+ {bodyHasMermaid && <MermaidLoader />}
63
+ </>
64
+ )
65
+ }
66
+ </PrivateGate>
67
+ </BaseLayout>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../../layouts/BaseLayout.astro';
5
+ import ProductsIndex from '../../../components/ProductsIndex.astro';
6
+ import { useTranslations } from '../../../i18n/ui';
7
+ import { allLocales, langParam } from '../../../lib/locales';
8
+
9
+ export const getStaticPaths = (() =>
10
+ allLocales.map((lang) => ({ params: { lang: langParam(lang) }, props: { lang } }))) satisfies GetStaticPaths;
11
+
12
+ const { lang } = Astro.props;
13
+ const t = useTranslations(lang);
14
+ ---
15
+
16
+ <BaseLayout title={`${t('products.title')} — ${site.name}`} lang={lang} path="products/">
17
+ <ProductsIndex lang={lang} />
18
+ </BaseLayout>