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 +40 -0
- package/README.md +25 -16
- package/index.d.ts +1 -1
- package/index.mjs +10 -7
- package/package.json +1 -1
- package/src/components/CardsHome.astro +8 -3
- package/src/components/{AppLanding.astro → ProductLanding.astro} +3 -3
- package/src/components/ProductsIndex.astro +69 -0
- package/src/content.ts +28 -10
- package/src/i18n/ui.ts +8 -0
- package/src/layouts/BaseLayout.astro +50 -13
- package/src/lib/home.ts +2 -0
- package/src/lib/products.ts +31 -0
- package/src/lib/sections.ts +2 -2
- package/src/pages/[...lang]/{apps → products}/[...id].astro +7 -7
- package/src/pages/[...lang]/products/index.astro +18 -0
- package/src/lib/apps.ts +0 -24
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**
|
|
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
|
-
##
|
|
129
|
-
|
|
130
|
-
The `
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
prose page
|
|
138
|
-
`
|
|
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: '/
|
|
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
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
|
-
//
|
|
33
|
-
// nested subpages (privacy/terms) via one
|
|
34
|
-
|
|
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('
|
|
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 }`.
|
|
84
|
-
// (src/data/course-categories.ts), so a theme upgrade alone must
|
|
85
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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 {
|
|
12
|
+
import type { ProductEntry } from '../lib/products';
|
|
13
13
|
import type { Lang } from '../i18n/ui';
|
|
14
14
|
|
|
15
15
|
interface Props {
|
|
16
|
-
entry:
|
|
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 `
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* `
|
|
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
|
|
321
|
-
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/
|
|
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
|
-
//
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
47
|
-
|
|
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
|
-
...
|
|
110
|
-
href: getRelativeLocaleUrl(lang, `
|
|
140
|
+
...navProducts.map((a) => ({
|
|
141
|
+
href: getRelativeLocaleUrl(lang, `products/${productSlugOf(a)}/`),
|
|
111
142
|
label: a.data.navLabel ?? a.data.title,
|
|
112
|
-
section: '
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
+
}
|
package/src/lib/sections.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type SectionKey =
|
|
|
20
20
|
| 'concepts'
|
|
21
21
|
| 'articles'
|
|
22
22
|
| 'courses'
|
|
23
|
-
| '
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
18
|
-
paths.push({ params: { lang: langParam(lang), id:
|
|
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 =
|
|
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={`
|
|
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
|
-
<
|
|
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
|
-
}
|