stack-site-builder 1.16.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/CHANGELOG.md CHANGED
@@ -11,6 +11,45 @@ content schema, while a consuming site supplies only content, taxonomy data and
11
11
  config. Sites track the theme with `pnpm up stack-site-builder`, so each release
12
12
  here is a plain version bump they pull in.
13
13
 
14
+ ## [1.17.0] - 2026-07-22
15
+
16
+ ### Changed
17
+
18
+ - **`apps` → `products` (breaking for 1.16.0 `apps` users)** — the day-old
19
+ apps section is renamed and generalized before real adoption: "apps" was
20
+ too narrow an umbrella for what a studio offers, so the section is now
21
+ **products** with a site-side mini-taxonomy (apps, services, education, …).
22
+ Content moves to `src/content/products/`, routes to `/products/…`, the
23
+ section key becomes `products` and — because the index needs
24
+ `src/data/product-categories.ts` (exporting `productTree` /
25
+ `productCatOf`) — it is now opt-IN (`sections: { products: true }`).
26
+ Schemas gain an optional `category` (validated via the optional
27
+ `productCategoryMap` argument to `defineAasCollections`); the landing/page
28
+ templates, nested subpages and automatic header link carry over unchanged
29
+ as `ProductLanding` / `ProductsIndex`.
30
+
31
+ ### Added
32
+
33
+ - **Products index + header link** — `/products/` lists the top-level
34
+ products (icon, name, subtitle, description) grouped by the site's
35
+ category tree, and the header gains a Products link automatically once a
36
+ locale has at least one product. Per-product header links (`nav: true`)
37
+ still work for sites that prefer direct items.
38
+ - **Configurable browser icons** — `site.icons = { favicon, appleTouch,
39
+ manifest }` in site.ts; `favicon` may be SVG, PNG or ICO (type inferred
40
+ from the extension) so a site can keep its existing logo as the tab icon.
41
+ Defaults to the theme's `/favicon.svg`.
42
+ - **Hideable GitHub link** — `repoNav: false` in site.ts removes the
43
+ header's GitHub item (for private-repo sites); it also disappears when no
44
+ `repoUrl` is declared.
45
+
46
+ ### Fixed
47
+
48
+ - **Cards home links lost the locale** — card and CTA hrefs on the cards
49
+ home are written locale-less ("/apps/foo/") and now get the current
50
+ locale's prefix at render, so the `/en/` home links to `/en/…` pages
51
+ instead of the default locale's.
52
+
14
53
  ## [1.16.0] - 2026-07-22
15
54
 
16
55
  ### Added
@@ -186,6 +225,7 @@ catalog sites from a thin content-only repository.
186
225
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
187
226
  consuming site for developing and previewing the theme on its own.
188
227
 
228
+ [1.17.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.16.0...v1.17.0
189
229
  [1.16.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.15.0...v1.16.0
190
230
  [1.15.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.14.0...v1.15.0
191
231
  [1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
package/README.md CHANGED
@@ -36,11 +36,11 @@ export const collections = defineAasCollections({ categoryMap });
36
36
 
37
37
  | Where | What |
38
38
  | --- | --- |
39
- | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, optional `sections` toggles, per-locale UI string overrides |
39
+ | `src/data/site.ts` | Site identity: name, repo URL (`repoNav: false` hides the header's GitHub link), the `locales` it ships, optional `sections` toggles, browser icons (`icons: { favicon, appleTouch, manifest }`), the `home` template, per-locale UI string overrides |
40
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
41
- | `src/data/concept-categories.ts` · `article-categories.ts` · `course-categories.ts` (opt-in) | Taxonomies for concepts / articles / courses |
41
+ | `src/data/concept-categories.ts` · `article-categories.ts` · `course-categories.ts` · `product-categories.ts` (opt-in) | Taxonomies for concepts / articles / courses / products |
42
42
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets |
43
- | `src/content/{stacks,concepts,courses,articles,slides}/` | The content, one MDX file per locale |
43
+ | `src/content/{stacks,concepts,courses,products,articles,slides}/` | The content, one MDX file per locale |
44
44
  | `src/content/pages/` | Standalone top-level pages (e.g. an About/소개), rendered at `/<slug>/` and optionally linked in the header nav |
45
45
  | `public/` · `samples/` | Logos/favicons and runnable sample projects |
46
46
 
@@ -72,7 +72,7 @@ The rest are opt-out — **concepts, articles, samples, slides, glossary** and t
72
72
  standalone **pages** collection (About/소개, …) — so a site can ship only what it
73
73
  needs. Turning one off removes both its routes and its header-nav item. (`pages`
74
74
  also has finer control: each page's `nav` / `draft` frontmatter, or simply not
75
- authoring it.) **courses** is the one opt-IN section — see below.
75
+ authoring it.) **courses** and **products** are the opt-IN sections — see below.
76
76
 
77
77
  Declare the toggles once in `src/data/site.ts` and forward them to the theme in
78
78
  astro.config (which needs them to skip route injection). Import `SectionKey` from
@@ -125,17 +125,26 @@ teaser: A public one-liner for the login gate.
125
125
 
126
126
  Routes mirror concepts: `/course/`, `/course/<slug>/`, `/course/category/<id>/`.
127
127
 
128
- ## Apps (product landings)
129
-
130
- The `apps` collection renders Things-style marketing pages from frontmatter
131
- alone (`template: 'landing'`): hero with store buttons and an optional Product
132
- Hunt badge, alternating feature rows (device-frame screenshots, auto-rotating
133
- carousels), a video themes showcase, a highlights grid, pricing tiers, a
134
- closing CTA and legal links. Landing media are `public/` paths. Entries nest:
135
- `apps/<lang>/flowstate.mdx` `/apps/flowstate/`, and
136
- `apps/<lang>/flowstate/privacy.mdx` `/apps/flowstate/privacy/` (a plain
137
- prose page). `nav: true` adds a header link. See
138
- `playground/src/content/apps/` for a complete example.
128
+ ## Products (opt-in)
129
+
130
+ The `products` collection is the "what we offer" umbrella — entries grouped
131
+ by a site-side mini-taxonomy (apps, services, education, …) on the
132
+ `/products/` index. Each entry is either a Things-style marketing landing
133
+ (`template: 'landing'`: hero with store buttons and an optional Product Hunt
134
+ badge, alternating feature rows with device-frame screenshots and
135
+ auto-rotating carousels, a video themes showcase, a highlights grid, pricing
136
+ tiers, a closing CTA and legal links — media are `public/` paths) or a plain
137
+ prose page (a service pitch, a legal subpage). Entries nest:
138
+ `products/<lang>/flowstate.mdx` `/products/flowstate/`, and
139
+ `products/<lang>/flowstate/privacy.mdx` → `/products/flowstate/privacy/`.
140
+ The header gains a Products link automatically once a locale has a product;
141
+ `nav: true` additionally gives an entry its own header item.
142
+
143
+ Enable with `sections: { products: true }` plus
144
+ `src/data/product-categories.ts` (exporting `productTree` / `productCatOf` —
145
+ copy the playground's), and optionally pass `productCategoryMap` to
146
+ `defineAasCollections` for build-time category validation. See
147
+ `playground/src/content/products/` for a complete example.
139
148
 
140
149
  ## Homepage
141
150
 
@@ -147,7 +156,7 @@ home: {
147
156
  template: 'cards',
148
157
  hero: { icon: '/img/logo.png', subtitle: { ko: '…', en: '…' } },
149
158
  cardsTitle: { ko: '앱', en: 'Apps' },
150
- cards: [{ href: '/apps/flowstate/', name: 'FlowState', icon: '/img/icon.png',
159
+ cards: [{ href: '/products/flowstate/', name: 'FlowState', icon: '/img/icon.png',
151
160
  rounded: true, description: { ko: '…', en: '…' }, tags: ['iOS'] }],
152
161
  cta: { title: { … }, description: { … }, button: { label: { … }, href: '/course/' } },
153
162
  },
package/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type SectionKey =
7
7
  | 'concepts'
8
8
  | 'articles'
9
9
  | 'courses'
10
- | 'apps'
10
+ | 'products'
11
11
  | 'samples'
12
12
  | 'slides'
13
13
  | 'glossary'
package/index.mjs CHANGED
@@ -29,9 +29,11 @@ const PAGES = [
29
29
  // Standalone top-level pages (the `pages` collection), e.g. an About/소개
30
30
  // page. A single dynamic route per locale renders every entry at `/<slug>/`.
31
31
  '[page].astro',
32
- // App/product pages (the `apps` collection): marketing landings and their
33
- // nested subpages (privacy/terms) via one catch-all.
34
- 'apps/[...id].astro',
32
+ // Product pages (the `products` collection): a category-grouped index, plus
33
+ // the landings/pages and their nested subpages (privacy/terms) via one
34
+ // catch-all.
35
+ 'products/index.astro',
36
+ 'products/[...id].astro',
35
37
  // Per-locale RSS feed of the articles collection (an endpoint, not a page).
36
38
  'rss.xml.ts',
37
39
  'article/index.astro',
@@ -70,7 +72,7 @@ function sectionOf(file) {
70
72
  if (file.startsWith('concept/')) return 'concepts';
71
73
  if (file.startsWith('article/')) return 'articles';
72
74
  if (file.startsWith('course/')) return 'courses';
73
- if (file.startsWith('apps/')) return 'apps';
75
+ if (file.startsWith('products/')) return 'products';
74
76
  if (file.startsWith('sample/')) return 'samples';
75
77
  if (file.startsWith('slides/')) return 'slides';
76
78
  if (file === 'glossary.astro') return 'glossary';
@@ -80,9 +82,10 @@ function sectionOf(file) {
80
82
  }
81
83
 
82
84
  // Sections that are opt-IN rather than opt-out: their routes are injected only
83
- // when the site passes `{ <key>: true }`. `courses` needs site-side data
84
- // (src/data/course-categories.ts), so a theme upgrade alone must not enable it.
85
- const OPT_IN_SECTIONS = new Set(['courses']);
85
+ // when the site passes `{ <key>: true }`. Both need site-side data
86
+ // (src/data/{course,product}-categories.ts), so a theme upgrade alone must
87
+ // not enable them.
88
+ const OPT_IN_SECTIONS = new Set(['courses', 'products']);
86
89
 
87
90
  /**
88
91
  * @param {object} opts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.16.0",
4
+ "version": "1.17.0",
5
5
  "license": "MIT",
6
6
  "description": "The engine behind the awesome-*-stack catalog sites: an Astro theme with the catalog/concepts/articles/slides/samples routes, components, styles and markdown pipeline. Sites provide content, taxonomy data and config.",
7
7
  "repository": {
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  // The data-driven "cards" homepage (see src/lib/home.ts): hero with the site
3
- // icon/name, a stack of wide link cards (apps, sections, external links — the
3
+ // icon/name, a stack of wide link cards (products, sections, external links — the
4
4
  // Things-style studio home), and a closing CTA banner. Everything renders
5
5
  // from `site.home`; sections without data simply don't render.
6
6
  import { site } from '@aas-data/site';
7
+ import { getRelativeLocaleUrl } from 'astro:i18n';
7
8
  import { home, loc, locList } from '../lib/home';
8
9
  import { useTranslations, type Lang } from '../i18n/ui';
9
10
 
@@ -21,6 +22,10 @@ const heroTitle = loc(h?.hero?.title, lang) ?? site.name;
21
22
  const heroSubtitle = loc(h?.hero?.subtitle, lang) ?? t('site.tagline');
22
23
  const extAttrs = (external?: boolean) =>
23
24
  external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
25
+ // Internal links ("/products/…") are written locale-less in site.home and get the
26
+ // current locale's prefix here, so the en home links to the en pages.
27
+ const resolveHref = (href: string, external?: boolean) =>
28
+ !external && href.startsWith('/') ? getRelativeLocaleUrl(lang, href.replace(/^\//, '')) : href;
24
29
  ---
25
30
 
26
31
  <section class="py-14 text-center">
@@ -37,7 +42,7 @@ const extAttrs = (external?: boolean) =>
37
42
  <div class="flex flex-col gap-5">
38
43
  {h?.cards?.map((c) => (
39
44
  <a
40
- href={c.href}
45
+ href={resolveHref(c.href, c.external)}
41
46
  {...extAttrs(c.external)}
42
47
  class="aas-lift flex items-center gap-6 rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6 no-underline"
43
48
  >
@@ -84,7 +89,7 @@ const extAttrs = (external?: boolean) =>
84
89
  )}
85
90
  {h.cta.button && (
86
91
  <a
87
- href={h.cta.button.href}
92
+ href={resolveHref(h.cta.button.href, h.cta.button.external)}
88
93
  {...extAttrs(h.cta.button.external)}
89
94
  class="mt-7 inline-block rounded-full bg-[var(--aas-accent)] px-7 py-3 font-semibold text-white no-underline"
90
95
  >
@@ -1,5 +1,5 @@
1
1
  ---
2
- // Things-style app/product landing, rendered entirely from an `apps` entry's
2
+ // Things-style product landing, rendered entirely from a `products` entry's
3
3
  // frontmatter (`template: 'landing'`): hero (icon / store buttons / Product
4
4
  // Hunt badge), alternating feature rows (with optional device frame and
5
5
  // auto-rotating screenshot carousels), a video "themes" showcase, a highlights
@@ -9,11 +9,11 @@
9
9
  // All media (icons, screenshots, videos, the frame art) are `public/` paths;
10
10
  // `description`-like strings may carry authored <br> tags (rendered as HTML).
11
11
  import { site } from '@aas-data/site';
12
- import type { AppEntry } from '../lib/apps';
12
+ import type { ProductEntry } from '../lib/products';
13
13
  import type { Lang } from '../i18n/ui';
14
14
 
15
15
  interface Props {
16
- entry: AppEntry;
16
+ entry: ProductEntry;
17
17
  lang: Lang;
18
18
  }
19
19
 
@@ -0,0 +1,69 @@
1
+ ---
2
+ // The products index — the "what we offer" page. Top-level products (app
3
+ // landings, service pitches, education offers) render as wide cards (icon,
4
+ // name, subtitle, meta description) grouped by the site's mini-taxonomy
5
+ // (src/data/product-categories.ts). Cards mirror the cards-home style so the
6
+ // two read as one system.
7
+ import { getRelativeLocaleUrl } from 'astro:i18n';
8
+ import { productTree, productCatOf } from '@aas-data/product-categories';
9
+ import { getTopProducts, productSlugOf, type ProductEntry } from '../lib/products';
10
+ import { useTranslations, type Lang } from '../i18n/ui';
11
+
12
+ interface Props {
13
+ lang: Lang;
14
+ }
15
+
16
+ const { lang } = Astro.props;
17
+ const t = useTranslations(lang);
18
+ const all = await getTopProducts(lang);
19
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
20
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
21
+
22
+ // One section per root category that has products (subtree rolled up), in
23
+ // tree order — the mini-taxonomy is the page structure.
24
+ const sections = productTree.roots
25
+ .map((cat) => {
26
+ const ids = new Set(productTree.descendantIds(cat.id));
27
+ return { cat, items: all.filter((p) => ids.has(productCatOf(p.data.category))) };
28
+ })
29
+ .filter((s) => s.items.length > 0);
30
+ ---
31
+
32
+ <section class="py-6">
33
+ <h1 class="text-4xl font-bold tracking-tight">{t('products.title')}</h1>
34
+ <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('products.tagline')}</p>
35
+ </section>
36
+
37
+ {all.length === 0 && <p class="mt-8 text-[var(--aas-muted)]">{t('products.empty')}</p>}
38
+
39
+ {
40
+ sections.map(({ cat, items }) => (
41
+ <section class="mt-8">
42
+ <h2 class="text-2xl font-semibold">{cat.label[lang]}</h2>
43
+ <p class="mt-1 text-sm text-[var(--aas-muted)]">{cat.description[lang]}</p>
44
+ <div class="mt-4 flex flex-col gap-5">
45
+ {items.map((a: ProductEntry) => (
46
+ <a
47
+ href={getRelativeLocaleUrl(lang, `products/${productSlugOf(a)}/`)}
48
+ class="aas-lift flex items-center gap-6 rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-6 no-underline"
49
+ >
50
+ {a.data.icon && (
51
+ <img src={withBase(a.data.icon)} alt="" class="h-20 w-20 shrink-0 rounded-[22%]" loading="lazy" />
52
+ )}
53
+ <span class="min-w-0">
54
+ <span class="block text-xl font-semibold text-[var(--aas-text)]">{a.data.title}</span>
55
+ {a.data.subtitle && (
56
+ <span class="mt-1 block leading-relaxed text-[var(--aas-muted)]" set:html={a.data.subtitle} />
57
+ )}
58
+ {a.data.description && (
59
+ <span class="mt-1 block text-sm leading-relaxed text-[var(--aas-muted)] opacity-75">
60
+ {a.data.description}
61
+ </span>
62
+ )}
63
+ </span>
64
+ </a>
65
+ ))}
66
+ </div>
67
+ </section>
68
+ ))
69
+ }
package/src/content.ts CHANGED
@@ -18,12 +18,16 @@ import { glob } from 'astro/loaders';
18
18
  export function defineAasCollections({
19
19
  categoryMap,
20
20
  courseCategoryMap,
21
+ productCategoryMap,
21
22
  }: {
22
23
  categoryMap: Map<string, unknown>;
23
24
  /** The site's course category tree (`src/data/course-categories.ts`). Optional
24
25
  * because `courses` is an opt-in section; when provided, course `category`
25
26
  * ids are validated against it at build time (like stacks). */
26
27
  courseCategoryMap?: Map<string, unknown>;
28
+ /** The site's product category tree (`src/data/product-categories.ts`).
29
+ * Optional like `courseCategoryMap` — `products` is opt-in too. */
30
+ productCategoryMap?: Map<string, unknown>;
27
31
  }) {
28
32
  /**
29
33
  * The `stacks` collection holds one entry per tool/service used to build
@@ -307,26 +311,40 @@ export function defineAasCollections({
307
311
  });
308
312
 
309
313
  /**
310
- * The `apps` collection holds product/app pagesa Things-style marketing
311
- * landing per app (`template: 'landing'`) plus its plain subpages (privacy,
312
- * terms `template: 'page'`, the default). Entries are locale-partitioned
313
- * and may nest: `apps/<lang>/<slug>.mdx` renders at `/apps/<slug>/`,
314
- * `apps/<lang>/<slug>/privacy.mdx` at `/apps/<slug>/privacy/`.
314
+ * The `products` collection is the "what we offer" section an opt-in
315
+ * umbrella (`sections: { products: true }`) whose entries are grouped by a
316
+ * mini-taxonomy (apps, services, education, ) on the `/products/` index.
317
+ * Each entry is either a Things-style marketing landing
318
+ * (`template: 'landing'`) or a plain prose page (`template: 'page'`, the
319
+ * default — an outsourcing pitch, a legal subpage). Entries are
320
+ * locale-partitioned and may nest: `products/<lang>/<slug>.mdx` renders at
321
+ * `/products/<slug>/`, `products/<lang>/<slug>/privacy.mdx` at
322
+ * `/products/<slug>/privacy/`.
315
323
  *
316
324
  * Landing visuals (icons, screenshots, videos, the phone-frame art) are
317
325
  * plain `public/` paths — videos can't go through the image pipeline, so
318
326
  * the whole landing keeps one convention instead of two.
319
327
  */
320
- const apps = defineCollection({
321
- loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/apps' }),
328
+ const products = defineCollection({
329
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/products' }),
322
330
  schema: z.object({
323
331
  title: z.string(),
324
332
  description: z.string().optional(), // meta description
325
333
  // 'landing' renders the structured marketing page below; 'page' renders
326
- // the markdown body like a standalone page (legal subpages).
334
+ // the markdown body like a standalone page (pitches, legal subpages).
327
335
  template: z.enum(['landing', 'page']).default('page'),
336
+ // Mini-category on the site's src/data/product-categories.ts tree —
337
+ // groups the index. Validated when the site passes `productCategoryMap`;
338
+ // unknown/missing ids fall back to uncategorized at render.
339
+ category: (productCategoryMap
340
+ ? z.string().refine((id) => productCategoryMap.has(id), {
341
+ message:
342
+ 'unknown product category id — must match a node in the site data product category tree',
343
+ })
344
+ : z.string()
345
+ ).optional(),
328
346
  // Header-nav placement, like the `pages` collection (default off —
329
- // landings are usually reached from the home cards).
347
+ // products are usually reached from the index or the home cards).
330
348
  nav: z.boolean().default(false),
331
349
  navLabel: z.string().optional(),
332
350
  order: z.number().default(0),
@@ -469,5 +487,5 @@ export function defineAasCollections({
469
487
  }),
470
488
  });
471
489
 
472
- return { stacks, articles, concepts, courses, slides, apps, pages };
490
+ return { stacks, articles, concepts, courses, slides, products, pages };
473
491
  }
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,7 +7,7 @@ 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 { getNavApps, appSlugOf } from '../lib/apps';
10
+ import { getNavProducts, getTopProducts, productSlugOf } from '../lib/products';
11
11
  import { homeTemplate } from '../lib/home';
12
12
  import { sectionEnabled, type SectionKey } from '../lib/sections';
13
13
 
@@ -33,6 +33,21 @@ const container = wide ? 'max-w-[81rem]' : 'max-w-[75rem]';
33
33
  const home = getRelativeLocaleUrl(lang, '');
34
34
  const base = import.meta.env.BASE_URL;
35
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
+
36
51
  // Home URL per locale, for the language auto-detect redirect on the root home.
37
52
  const localeHomes = Object.fromEntries(
38
53
  (Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
@@ -43,8 +58,14 @@ const isDefaultHome = path === '' && lang === 'en';
43
58
  // e.g. an About/소개 page. Rendered as their own nav items alongside the
44
59
  // built-in sections; the accessible label comes from the page's own frontmatter.
45
60
  const navPages = await getNavPages(lang);
46
- // App landings that opt into a header link (the `apps` collection's `nav`).
47
- const navApps = await getNavApps(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;
48
69
 
49
70
  // The header sections, as data — rendered twice from one source so the two
50
71
  // layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
@@ -70,6 +91,16 @@ const allNavItems: {
70
91
  },
71
92
  ]
72
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
+ : []),
73
104
  {
74
105
  href: getRelativeLocaleUrl(lang, 'course/'),
75
106
  label: t('nav.courses'),
@@ -106,10 +137,10 @@ const allNavItems: {
106
137
  section: 'glossary',
107
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"/>',
108
139
  },
109
- ...navApps.map((a) => ({
110
- href: getRelativeLocaleUrl(lang, `apps/${appSlugOf(a)}/`),
140
+ ...navProducts.map((a) => ({
141
+ href: getRelativeLocaleUrl(lang, `products/${productSlugOf(a)}/`),
111
142
  label: a.data.navLabel ?? a.data.title,
112
- section: 'apps' as SectionKey,
143
+ section: 'products' as SectionKey,
113
144
  svg: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
114
145
  })),
115
146
  ...navPages.map((p) => ({
@@ -118,12 +149,16 @@ const allNavItems: {
118
149
  section: 'pages' as SectionKey,
119
150
  svg: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
120
151
  })),
121
- {
122
- href: site.repoUrl,
123
- label: 'GitHub',
124
- external: true,
125
- 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"/>',
126
- },
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
+ : []),
127
162
  ];
128
163
  // Drop the nav links for sections the site turned off (their routes aren't built).
129
164
  const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(item.section));
@@ -138,7 +173,9 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
138
173
  <title>{title}</title>
139
174
  <meta name="description" content={description} />
140
175
  {noindex && <meta name="robots" content="noindex" />}
141
- <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}`} />}
142
179
  {
143
180
  sectionEnabled('articles') && (
144
181
  <link
package/src/lib/home.ts CHANGED
@@ -16,6 +16,8 @@ import { defaultLang, type Lang } from '../i18n/ui';
16
16
  export type Localized = string | Record<string, string>;
17
17
 
18
18
  export interface HomeCard {
19
+ /** Locale-less internal path ("/products/foo/", prefixed per locale at
20
+ * render) or an external URL (set `external`). */
19
21
  href: string;
20
22
  external?: boolean;
21
23
  name: Localized;
@@ -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,7 +20,7 @@ export type SectionKey =
20
20
  | 'concepts'
21
21
  | 'articles'
22
22
  | 'courses'
23
- | 'apps'
23
+ | 'products'
24
24
  | 'samples'
25
25
  | 'slides'
26
26
  | 'glossary'
@@ -30,7 +30,7 @@ const DEFAULTS: Record<SectionKey, boolean> = {
30
30
  concepts: true,
31
31
  articles: true,
32
32
  courses: false, // opt-in: needs src/data/course-categories.ts on the site
33
- apps: true, // no site data needed; an empty collection builds zero pages
33
+ products: false, // opt-in: needs src/data/product-categories.ts on the site
34
34
  samples: true,
35
35
  slides: true,
36
36
  glossary: true,
@@ -3,26 +3,26 @@ import { site } from '@aas-data/site';
3
3
  import { render } from 'astro:content';
4
4
  import type { GetStaticPaths } from 'astro';
5
5
  import BaseLayout from '../../../layouts/BaseLayout.astro';
6
- import AppLanding from '../../../components/AppLanding.astro';
6
+ import ProductLanding from '../../../components/ProductLanding.astro';
7
7
  import PrivateGate from '../../../components/PrivateGate.astro';
8
8
  import TocRail from '../../../components/TocRail.astro';
9
9
  import MermaidLoader from '../../../components/MermaidLoader.astro';
10
- import { getApps, appSlugOf } from '../../../lib/apps';
10
+ import { getProducts, productSlugOf } from '../../../lib/products';
11
11
  import { allLocales, langParam } from '../../../lib/locales';
12
12
  import { inlineMd } from '../../../lib/inline-md';
13
13
 
14
14
  export const getStaticPaths = (async () => {
15
15
  const paths: Awaited<ReturnType<GetStaticPaths>> = [];
16
16
  for (const lang of allLocales) {
17
- for (const entry of await getApps(lang)) {
18
- paths.push({ params: { lang: langParam(lang), id: appSlugOf(entry) }, props: { lang, entry } });
17
+ for (const entry of await getProducts(lang)) {
18
+ paths.push({ params: { lang: langParam(lang), id: productSlugOf(entry) }, props: { lang, entry } });
19
19
  }
20
20
  }
21
21
  return paths;
22
22
  }) satisfies GetStaticPaths;
23
23
 
24
24
  const { lang, entry } = Astro.props;
25
- const slug = appSlugOf(entry);
25
+ const slug = productSlugOf(entry);
26
26
  const priv = entry.data.private;
27
27
  const isLanding = entry.data.template === 'landing';
28
28
  // Plain 'page' template (privacy/terms subpages) renders the markdown body.
@@ -35,13 +35,13 @@ const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
35
35
  title={`${entry.data.title} — ${site.name}`}
36
36
  description={priv ? entry.data.teaser : (entry.data.description ?? entry.data.subtitle)}
37
37
  lang={lang}
38
- path={`apps/${slug}/`}
38
+ path={`products/${slug}/`}
39
39
  noindex={priv}
40
40
  >
41
41
  <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
42
42
  {
43
43
  isLanding ? (
44
- <AppLanding entry={entry} lang={lang} />
44
+ <ProductLanding entry={entry} lang={lang} />
45
45
  ) : (
46
46
  <>
47
47
  <header class="pb-6">
@@ -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>
package/src/lib/apps.ts DELETED
@@ -1,24 +0,0 @@
1
- import { getCollection, type CollectionEntry } from 'astro:content';
2
- import type { Lang } from '../i18n/ui';
3
-
4
- export type AppEntry = CollectionEntry<'apps'>;
5
-
6
- /** The url slug of an app 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 appSlugOf(entry: AppEntry): string {
10
- return entry.id.replace(/^[a-z]{2}\//, '');
11
- }
12
-
13
- /** Published app pages for one locale, in `order` (then title) order. */
14
- export async function getApps(lang: Lang): Promise<AppEntry[]> {
15
- const all = await getCollection('apps');
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 getApps} that opts into a header-nav link. */
22
- export async function getNavApps(lang: Lang): Promise<AppEntry[]> {
23
- return (await getApps(lang)).filter((e) => e.data.nav);
24
- }