stack-site-builder 1.15.0 → 1.16.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,30 @@ 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.16.0] - 2026-07-22
15
+
16
+ ### Added
17
+
18
+ - **`apps` collection — product landings** — Things-style marketing pages
19
+ driven entirely by frontmatter (`template: 'landing'`): hero with app icon,
20
+ App Store / Google Play buttons (`"#"` renders disabled with a
21
+ "coming soon" label) and a Product Hunt badge; alternating feature rows
22
+ with optional device-frame screenshots and auto-rotating carousels; a
23
+ video "themes" showcase with tab switching; a highlights grid; pricing
24
+ tiers with a featured ribbon; a closing CTA; and legal links. Entries may
25
+ nest — `apps/<lang>/<slug>/privacy.mdx` renders at
26
+ `/apps/<slug>/privacy/` as a plain prose page (`template: 'page'`) — via
27
+ one catch-all route. Landing media (icons, screenshots, videos, frame art)
28
+ are `public/` paths. `nav: true` puts a landing in the header nav. The
29
+ section is on by default; an empty collection builds zero pages.
30
+ - **Data-driven "cards" home** — a site that isn't a catalog can declare
31
+ `home: { template: 'cards', hero, cards, cta }` in `src/data/site.ts` and
32
+ get a hero + wide-card grid + CTA homepage instead of the stack catalog.
33
+ Localized strings use per-locale records (`{ ko: '…', en: '…' }`) with
34
+ default-locale fallback, like category labels. On a cards home the
35
+ header's catalog-anchored Browse link hides itself; the catalog routes
36
+ still build (empty without stacks).
37
+
14
38
  ## [1.15.0] - 2026-07-22
15
39
 
16
40
  ### Added
@@ -162,6 +186,7 @@ catalog sites from a thin content-only repository.
162
186
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
163
187
  consuming site for developing and previewing the theme on its own.
164
188
 
189
+ [1.16.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.15.0...v1.16.0
165
190
  [1.15.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.14.0...v1.15.0
166
191
  [1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
167
192
  [1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
package/README.md CHANGED
@@ -125,6 +125,38 @@ 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.
139
+
140
+ ## Homepage
141
+
142
+ The default home is the stack catalog. A site that isn't a catalog can swap in
143
+ a data-driven home from `src/data/site.ts`:
144
+
145
+ ```ts
146
+ home: {
147
+ template: 'cards',
148
+ hero: { icon: '/img/logo.png', subtitle: { ko: '…', en: '…' } },
149
+ cardsTitle: { ko: '앱', en: 'Apps' },
150
+ cards: [{ href: '/apps/flowstate/', name: 'FlowState', icon: '/img/icon.png',
151
+ rounded: true, description: { ko: '…', en: '…' }, tags: ['iOS'] }],
152
+ cta: { title: { … }, description: { … }, button: { label: { … }, href: '/course/' } },
153
+ },
154
+ ```
155
+
156
+ Localized values are either one string or a per-locale record with
157
+ default-locale fallback. On a cards home the header's Browse link (which
158
+ anchors into the catalog) hides itself.
159
+
128
160
  ## Body components
129
161
 
130
162
  Reusable MDX-body components, importable from any collection's content:
package/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export type SectionKey =
7
7
  | 'concepts'
8
8
  | 'articles'
9
9
  | 'courses'
10
+ | 'apps'
10
11
  | 'samples'
11
12
  | 'slides'
12
13
  | 'glossary'
package/index.mjs CHANGED
@@ -29,6 +29,9 @@ 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
35
  // Per-locale RSS feed of the articles collection (an endpoint, not a page).
33
36
  'rss.xml.ts',
34
37
  'article/index.astro',
@@ -67,6 +70,7 @@ function sectionOf(file) {
67
70
  if (file.startsWith('concept/')) return 'concepts';
68
71
  if (file.startsWith('article/')) return 'articles';
69
72
  if (file.startsWith('course/')) return 'courses';
73
+ if (file.startsWith('apps/')) return 'apps';
70
74
  if (file.startsWith('sample/')) return 'samples';
71
75
  if (file.startsWith('slides/')) return 'slides';
72
76
  if (file === 'glossary.astro') return 'glossary';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.15.0",
4
+ "version": "1.16.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": {
@@ -0,0 +1,445 @@
1
+ ---
2
+ // Things-style app/product landing, rendered entirely from an `apps` entry's
3
+ // frontmatter (`template: 'landing'`): hero (icon / store buttons / Product
4
+ // Hunt badge), alternating feature rows (with optional device frame and
5
+ // auto-rotating screenshot carousels), a video "themes" showcase, a highlights
6
+ // grid, pricing tiers, a closing CTA and legal links. Sections render only
7
+ // when their data is present, so a minimal landing is just hero + features.
8
+ //
9
+ // All media (icons, screenshots, videos, the frame art) are `public/` paths;
10
+ // `description`-like strings may carry authored <br> tags (rendered as HTML).
11
+ import { site } from '@aas-data/site';
12
+ import type { AppEntry } from '../lib/apps';
13
+ import type { Lang } from '../i18n/ui';
14
+
15
+ interface Props {
16
+ entry: AppEntry;
17
+ lang: Lang;
18
+ }
19
+
20
+ const { entry } = Astro.props;
21
+ const d = entry.data;
22
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
23
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
24
+ const frame = withBase(d.phoneFrameImage);
25
+ // Optional site contact for the legal "support" link (read defensively — not
26
+ // every site declares an email).
27
+ const email = (site as { email?: string }).email;
28
+
29
+ const storeButtons = d.stores
30
+ ? [
31
+ { href: d.stores.appstore, brand: 'App Store', label: d.stores.appstoreLabel },
32
+ { href: d.stores.playstore, brand: 'Google Play', label: d.stores.playstoreLabel },
33
+ ].filter((b) => b.href)
34
+ : [];
35
+
36
+ // Alternation matches the Hugo original: odd rows get the tinted background,
37
+ // even rows put the visual on the left.
38
+ const altBg = (i: number) => i % 2 === 1;
39
+ ---
40
+
41
+ <div class="aas-landing">
42
+ {/* ---- hero ---- */}
43
+ <section
44
+ class:list={[
45
+ 'aas-bleed py-16 text-center',
46
+ d.heroBg && `aas-hero-bg-${d.heroBg}`,
47
+ ]}
48
+ >
49
+ <div class="mx-auto max-w-[75rem] px-5">
50
+ {d.icon && <img src={withBase(d.icon)} alt="" class="mx-auto h-24 w-24 rounded-[22%] shadow-lg" />}
51
+ <h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">{d.title}</h1>
52
+ {d.subtitle && <p class="mt-3 text-xl text-[var(--aas-muted)]" set:html={d.subtitle} />}
53
+ {
54
+ storeButtons.length > 0 && (
55
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
56
+ {storeButtons.map((b) => (
57
+ <span class="inline-flex flex-col items-center gap-1.5">
58
+ <a
59
+ href={b.href === '#' ? undefined : b.href}
60
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
61
+ class:list={[
62
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
63
+ b.href === '#' && 'pointer-events-none opacity-40',
64
+ ]}
65
+ >
66
+ {b.brand}
67
+ </a>
68
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
69
+ </span>
70
+ ))}
71
+ </div>
72
+ )
73
+ }
74
+ {d.tagline && <p class="mt-5 text-sm text-[var(--aas-muted)]" set:html={d.tagline} />}
75
+ {
76
+ d.productHunt && (
77
+ <p class="mt-6">
78
+ <a href={d.productHunt.url} target="_blank" rel="noopener noreferrer" class="inline-block">
79
+ <img src={d.productHunt.image} alt={d.productHunt.alt} width="250" height="54" loading="lazy" />
80
+ </a>
81
+ </p>
82
+ )
83
+ }
84
+ </div>
85
+ </section>
86
+
87
+ {/* ---- alternating feature rows ---- */}
88
+ {
89
+ d.features.map((f, i) => {
90
+ const imgs = f.images.length > 0 ? f.images : f.image ? [f.image] : [];
91
+ const isSvg = imgs.length === 1 && /\.svg$/i.test(imgs[0]);
92
+ return (
93
+ <section class:list={['aas-bleed py-16', altBg(i) && 'bg-[var(--aas-panel)]']}>
94
+ <div
95
+ class:list={[
96
+ 'mx-auto grid max-w-[75rem] items-center gap-10 px-5 md:grid-cols-2',
97
+ ]}
98
+ >
99
+ <div class:list={[!altBg(i) && 'md:order-2']}>
100
+ {f.label && (
101
+ <p class="text-sm font-semibold tracking-wide text-[var(--aas-accent)] uppercase">{f.label}</p>
102
+ )}
103
+ <h2 class="mt-2 text-3xl font-bold tracking-tight">{f.title}</h2>
104
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={f.description} />
105
+ </div>
106
+ <div class:list={['flex justify-center', !altBg(i) && 'md:order-1']}>
107
+ {imgs.length > 1 ? (
108
+ <div class="aas-carousel w-full max-w-xs" data-carousel>
109
+ <div class="aas-carousel-track" data-track>
110
+ {imgs.map((src) => (
111
+ <div class="aas-carousel-slide">
112
+ {f.phoneFrame && frame ? (
113
+ <span class="aas-phone">
114
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
115
+ <img class="aas-phone-screen" src={withBase(src)} alt={f.title} loading="lazy" />
116
+ </span>
117
+ ) : (
118
+ <img src={withBase(src)} alt={f.title} class="w-full rounded-2xl" loading="lazy" />
119
+ )}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ <div class="mt-3 flex justify-center gap-1.5" data-dots />
124
+ </div>
125
+ ) : imgs.length === 1 ? (
126
+ isSvg ? (
127
+ <img src={withBase(imgs[0])} alt={f.title} class="aas-feature-icon" loading="lazy" />
128
+ ) : f.phoneFrame && frame ? (
129
+ <span class="aas-phone max-w-xs">
130
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
131
+ <img class="aas-phone-screen" src={withBase(imgs[0])} alt={f.title} loading="lazy" />
132
+ </span>
133
+ ) : (
134
+ <img src={withBase(imgs[0])} alt={f.title} class="max-w-full rounded-2xl" loading="lazy" />
135
+ )
136
+ ) : null}
137
+ </div>
138
+ </div>
139
+ </section>
140
+ );
141
+ })
142
+ }
143
+
144
+ {/* ---- themes showcase (video tabs) ---- */}
145
+ {
146
+ d.themes.length > 0 && (
147
+ <section class:list={['aas-bleed py-16 text-center', altBg(d.features.length) && 'bg-[var(--aas-panel)]']}>
148
+ <div class="mx-auto max-w-[75rem] px-5" data-themes>
149
+ {d.themesTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.themesTitle}</h2>}
150
+ {d.themesSubtitle && <p class="mt-2 mb-10 text-[var(--aas-muted)]">{d.themesSubtitle}</p>}
151
+ <div class="mb-8 flex justify-center gap-2">
152
+ {d.themes.map((t, i) => (
153
+ <button
154
+ type="button"
155
+ class="aas-theme-tab rounded-full border border-[var(--aas-border)] px-4 py-2 text-lg"
156
+ data-theme={String(i)}
157
+ aria-pressed={i === 0 ? 'true' : 'false'}
158
+ title={t.name}
159
+ >
160
+ {t.icon ?? String(i + 1)}
161
+ </button>
162
+ ))}
163
+ </div>
164
+ <div class="grid items-center gap-10 md:grid-cols-2">
165
+ <div class="flex justify-center">
166
+ <span class="aas-phone max-w-xs">
167
+ {frame && <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />}
168
+ {d.themes.map((t, i) => (
169
+ <video
170
+ class:list={['aas-phone-screen aas-theme-video', i !== 0 && 'hidden']}
171
+ data-theme={String(i)}
172
+ autoplay={i === 0}
173
+ muted
174
+ playsinline
175
+ poster={withBase(t.poster)}
176
+ >
177
+ <source src={withBase(t.video)} type="video/mp4" />
178
+ </video>
179
+ ))}
180
+ </span>
181
+ </div>
182
+ <div class="text-left">
183
+ {d.themes.map((t, i) => (
184
+ <div class:list={['aas-theme-detail', i !== 0 && 'hidden']} data-theme={String(i)}>
185
+ <h3 class="text-2xl font-bold">{t.name}</h3>
186
+ {t.tabDesc && <p class="mt-1 text-sm text-[var(--aas-accent)]">{t.tabDesc}</p>}
187
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={t.description} />
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ {d.themesLandscape && (
193
+ <div class="mt-14">
194
+ <h3 class="text-xl font-bold">{d.themesLandscape.title}</h3>
195
+ <p class="mt-1 mb-6 text-[var(--aas-muted)]">{d.themesLandscape.description}</p>
196
+ <img
197
+ src={withBase(d.themesLandscape.image)}
198
+ alt={d.themesLandscape.title}
199
+ class="mx-auto max-w-full rounded-2xl sm:max-w-2xl"
200
+ loading="lazy"
201
+ />
202
+ </div>
203
+ )}
204
+ </div>
205
+ </section>
206
+ )
207
+ }
208
+
209
+ {/* ---- highlights grid ---- */}
210
+ {
211
+ d.highlights.length > 0 && (
212
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
213
+ <div class="mx-auto max-w-[75rem] px-5">
214
+ {d.highlightsTitle && <h2 class="mb-10 text-3xl font-bold tracking-tight sm:text-4xl">{d.highlightsTitle}</h2>}
215
+ <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
216
+ {d.highlights.map((h) => (
217
+ <div class="rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6">
218
+ {h.icon && <img src={withBase(h.icon)} alt="" class="aas-feature-glyph mx-auto mb-4 h-10 w-10" loading="lazy" />}
219
+ <h3 class="font-semibold">{h.title}</h3>
220
+ <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]" set:html={h.description} />
221
+ </div>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ </section>
226
+ )
227
+ }
228
+
229
+ {/* ---- pricing ---- */}
230
+ {
231
+ d.pricing.length > 0 && (
232
+ <section class="aas-bleed py-16 text-center">
233
+ <div class="mx-auto max-w-[75rem] px-5">
234
+ {d.pricingTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.pricingTitle}</h2>}
235
+ {d.pricingSubtitle && <p class="mt-2 text-[var(--aas-muted)]">{d.pricingSubtitle}</p>}
236
+ {d.pricingNotes.length > 0 && (
237
+ <p class="mt-2 text-sm text-[var(--aas-muted)] opacity-75">
238
+ {d.pricingNotes.map((n, i) => (
239
+ <>
240
+ {i > 0 && <br />}
241
+ {n}
242
+ </>
243
+ ))}
244
+ </p>
245
+ )}
246
+ <div class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
247
+ {d.pricing.map((p) => (
248
+ <div
249
+ class:list={[
250
+ 'relative rounded-3xl border p-6 text-left',
251
+ p.featured
252
+ ? 'border-[var(--aas-accent)] bg-[var(--aas-panel)] shadow-lg'
253
+ : 'border-[var(--aas-border)] bg-[var(--aas-panel)]',
254
+ ]}
255
+ >
256
+ {p.featured && d.pricingBadge && (
257
+ <span class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[var(--aas-accent)] px-3 py-0.5 text-xs font-semibold text-white">
258
+ {d.pricingBadge}
259
+ </span>
260
+ )}
261
+ <h3 class="font-semibold">{p.name}</h3>
262
+ <p class="mt-2 text-3xl font-bold">
263
+ {p.price}
264
+ {p.period && <span class="ml-1 text-sm font-normal text-[var(--aas-muted)]">{p.period}</span>}
265
+ </p>
266
+ <ul class="mt-4 space-y-2 text-sm text-[var(--aas-muted)]">
267
+ {p.items.map((it) => (
268
+ <li class="flex gap-2">
269
+ <span aria-hidden="true" class="text-[var(--aas-accent)]">✓</span>
270
+ {it}
271
+ </li>
272
+ ))}
273
+ </ul>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ </div>
278
+ </section>
279
+ )
280
+ }
281
+
282
+ {/* ---- closing CTA ---- */}
283
+ {
284
+ (d.ctaTitle || d.ctaDescription) && (
285
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
286
+ <div class="mx-auto max-w-[75rem] px-5">
287
+ {d.ctaTitle && <h2 class="text-3xl font-bold tracking-tight">{d.ctaTitle}</h2>}
288
+ {d.ctaDescription && <p class="mt-3 text-[var(--aas-muted)]" set:html={d.ctaDescription} />}
289
+ {storeButtons.length > 0 && (
290
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
291
+ {storeButtons.map((b) => (
292
+ <span class="inline-flex flex-col items-center gap-1.5">
293
+ <a
294
+ href={b.href === '#' ? undefined : b.href}
295
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
296
+ class:list={[
297
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
298
+ b.href === '#' && 'pointer-events-none opacity-40',
299
+ ]}
300
+ >
301
+ {b.brand}
302
+ </a>
303
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
304
+ </span>
305
+ ))}
306
+ </div>
307
+ )}
308
+ </div>
309
+ </section>
310
+ )
311
+ }
312
+
313
+ {/* ---- legal links ---- */}
314
+ {
315
+ d.legal && (
316
+ <section class="py-10 text-center text-sm">
317
+ <div class="flex flex-wrap justify-center gap-6 text-[var(--aas-muted)]">
318
+ {d.legal.privacy && <a href="privacy/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.privacy}</a>}
319
+ {d.legal.terms && <a href="terms/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.terms}</a>}
320
+ {d.legal.support && email && (
321
+ <a href={`mailto:${email}`} class="no-underline hover:text-[var(--aas-text)]">{d.legal.support}</a>
322
+ )}
323
+ </div>
324
+ </section>
325
+ )
326
+ }
327
+ </div>
328
+
329
+ <style>
330
+ /* Full-bleed section: escape BaseLayout's centered container. */
331
+ .aas-bleed {
332
+ position: relative;
333
+ left: 50%;
334
+ margin-left: -50vw;
335
+ margin-right: -50vw;
336
+ right: 50%;
337
+ width: 100vw;
338
+ }
339
+ /* Screenshot inside the device-frame art. The frame draws on top; the
340
+ screen sits inset behind it (tune per frame art via site CSS if needed). */
341
+ .aas-phone {
342
+ position: relative;
343
+ display: inline-block;
344
+ }
345
+ .aas-phone-frame {
346
+ position: relative;
347
+ z-index: 2;
348
+ width: 100%;
349
+ pointer-events: none;
350
+ }
351
+ .aas-phone :global(.aas-phone-screen) {
352
+ position: absolute;
353
+ z-index: 1;
354
+ inset: 2.5% 5.5%;
355
+ width: 89%;
356
+ height: 95%;
357
+ object-fit: cover;
358
+ border-radius: 9% / 4.5%;
359
+ }
360
+ .aas-feature-icon {
361
+ width: 10rem;
362
+ opacity: 0.9;
363
+ }
364
+ /* Monochrome icon files follow the text color in dark mode. */
365
+ :root[data-theme='dark'] .aas-feature-glyph,
366
+ :root[data-theme='dark'] .aas-feature-icon {
367
+ filter: invert(1) hue-rotate(180deg);
368
+ }
369
+ .aas-carousel {
370
+ overflow: hidden;
371
+ }
372
+ .aas-carousel-track {
373
+ display: flex;
374
+ transition: transform 0.45s ease;
375
+ }
376
+ .aas-carousel-slide {
377
+ flex: 0 0 100%;
378
+ display: flex;
379
+ justify-content: center;
380
+ }
381
+ .aas-theme-tab[aria-pressed='true'] {
382
+ border-color: var(--aas-accent);
383
+ background: var(--aas-tint);
384
+ }
385
+ </style>
386
+
387
+ <script>
388
+ // Screenshot carousels: dots + 4s auto-advance.
389
+ document.querySelectorAll<HTMLElement>('[data-carousel]').forEach((carousel) => {
390
+ const track = carousel.querySelector<HTMLElement>('[data-track]');
391
+ const dots = carousel.querySelector<HTMLElement>('[data-dots]');
392
+ const total = track?.children.length ?? 0;
393
+ if (!track || !dots || total < 2) return;
394
+ let current = 0;
395
+ const buttons: HTMLButtonElement[] = [];
396
+ const goTo = (idx: number) => {
397
+ current = idx;
398
+ track.style.transform = `translateX(-${current * 100}%)`;
399
+ buttons.forEach((b, j) => b.setAttribute('aria-pressed', String(j === current)));
400
+ };
401
+ for (let i = 0; i < total; i++) {
402
+ const b = document.createElement('button');
403
+ b.type = 'button';
404
+ b.className =
405
+ 'h-2 w-2 rounded-full border border-[var(--aas-border)] aria-[pressed=true]:bg-[var(--aas-accent)] aria-[pressed=true]:border-[var(--aas-accent)]';
406
+ b.setAttribute('aria-pressed', String(i === 0));
407
+ b.setAttribute('aria-label', String(i + 1));
408
+ b.addEventListener('click', () => goTo(i));
409
+ dots.appendChild(b);
410
+ buttons.push(b);
411
+ }
412
+ setInterval(() => goTo((current + 1) % total), 4000);
413
+ });
414
+
415
+ // Themes showcase: tab switching; a finished video advances to the next
416
+ // theme after a beat.
417
+ document.querySelectorAll<HTMLElement>('[data-themes]').forEach((root) => {
418
+ const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('.aas-theme-tab'));
419
+ const videos = Array.from(root.querySelectorAll<HTMLVideoElement>('.aas-theme-video'));
420
+ const details = Array.from(root.querySelectorAll<HTMLElement>('.aas-theme-detail'));
421
+ if (tabs.length < 2) return;
422
+ const switchTo = (idx: number) => {
423
+ const s = String(idx);
424
+ tabs.forEach((t) => t.setAttribute('aria-pressed', String(t.dataset.theme === s)));
425
+ details.forEach((el) => el.classList.toggle('hidden', el.dataset.theme !== s));
426
+ videos.forEach((v) => {
427
+ const on = v.dataset.theme === s;
428
+ v.classList.toggle('hidden', !on);
429
+ if (on) {
430
+ v.currentTime = 0;
431
+ v.play().catch(() => {});
432
+ } else {
433
+ v.pause();
434
+ }
435
+ });
436
+ };
437
+ tabs.forEach((t) => t.addEventListener('click', () => switchTo(Number(t.dataset.theme))));
438
+ videos.forEach((v) =>
439
+ v.addEventListener('ended', () => {
440
+ const cur = Number(v.dataset.theme);
441
+ setTimeout(() => switchTo((cur + 1) % tabs.length), 2000);
442
+ }),
443
+ );
444
+ });
445
+ </script>
@@ -0,0 +1,108 @@
1
+ ---
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
4
+ // Things-style studio home), and a closing CTA banner. Everything renders
5
+ // from `site.home`; sections without data simply don't render.
6
+ import { site } from '@aas-data/site';
7
+ import { home, loc, locList } from '../lib/home';
8
+ import { useTranslations, type Lang } from '../i18n/ui';
9
+
10
+ interface Props {
11
+ lang: Lang;
12
+ }
13
+
14
+ const { lang } = Astro.props;
15
+ const t = useTranslations(lang);
16
+ const h = home;
17
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
18
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
19
+
20
+ const heroTitle = loc(h?.hero?.title, lang) ?? site.name;
21
+ const heroSubtitle = loc(h?.hero?.subtitle, lang) ?? t('site.tagline');
22
+ const extAttrs = (external?: boolean) =>
23
+ external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
24
+ ---
25
+
26
+ <section class="py-14 text-center">
27
+ {h?.hero?.icon && <img src={withBase(h.hero.icon)} alt="" class="mx-auto h-20 w-20 rounded-[22%] shadow-md" />}
28
+ <h1 class="mt-5 text-4xl font-bold tracking-tight sm:text-5xl">{heroTitle}</h1>
29
+ <p class="mx-auto mt-4 max-w-2xl text-xl text-[var(--aas-muted)]" set:html={heroSubtitle} />
30
+ </section>
31
+
32
+ {
33
+ (h?.cards?.length ?? 0) > 0 && (
34
+ <section class="aas-bleed bg-[var(--aas-panel)] py-14">
35
+ <div class="mx-auto max-w-[75rem] px-5">
36
+ {h?.cardsTitle && <h2 class="mb-6 text-3xl font-bold tracking-tight">{loc(h.cardsTitle, lang)}</h2>}
37
+ <div class="flex flex-col gap-5">
38
+ {h?.cards?.map((c) => (
39
+ <a
40
+ href={c.href}
41
+ {...extAttrs(c.external)}
42
+ class="aas-lift flex items-center gap-6 rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6 no-underline"
43
+ >
44
+ {c.icon && (
45
+ <img
46
+ src={withBase(c.icon)}
47
+ alt=""
48
+ class:list={['h-20 w-20 shrink-0', c.rounded ? 'rounded-[22%]' : 'rounded-xl']}
49
+ loading="lazy"
50
+ />
51
+ )}
52
+ <span class="min-w-0">
53
+ <span class="block text-xl font-semibold text-[var(--aas-text)]">{loc(c.name, lang)}</span>
54
+ {c.description && (
55
+ <span
56
+ class="mt-1 block leading-relaxed text-[var(--aas-muted)]"
57
+ set:html={loc(c.description, lang)}
58
+ />
59
+ )}
60
+ {locList(c.tags, lang).length > 0 && (
61
+ <span class="mt-3 flex flex-wrap gap-1.5">
62
+ {locList(c.tags, lang).map((tag) => (
63
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
64
+ {tag}
65
+ </span>
66
+ ))}
67
+ </span>
68
+ )}
69
+ </span>
70
+ </a>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ </section>
75
+ )
76
+ }
77
+
78
+ {
79
+ h?.cta && (
80
+ <section class="py-16 text-center">
81
+ {h.cta.title && <h2 class="text-3xl font-bold tracking-tight">{loc(h.cta.title, lang)}</h2>}
82
+ {h.cta.description && (
83
+ <p class="mx-auto mt-3 max-w-2xl text-[var(--aas-muted)]" set:html={loc(h.cta.description, lang)} />
84
+ )}
85
+ {h.cta.button && (
86
+ <a
87
+ href={h.cta.button.href}
88
+ {...extAttrs(h.cta.button.external)}
89
+ class="mt-7 inline-block rounded-full bg-[var(--aas-accent)] px-7 py-3 font-semibold text-white no-underline"
90
+ >
91
+ {loc(h.cta.button.label, lang)}
92
+ </a>
93
+ )}
94
+ </section>
95
+ )
96
+ }
97
+
98
+ <style>
99
+ /* Full-bleed section: escape BaseLayout's centered container. */
100
+ .aas-bleed {
101
+ position: relative;
102
+ left: 50%;
103
+ margin-left: -50vw;
104
+ margin-right: -50vw;
105
+ right: 50%;
106
+ width: 100vw;
107
+ }
108
+ </style>
package/src/content.ts CHANGED
@@ -306,6 +306,137 @@ export function defineAasCollections({
306
306
  }),
307
307
  });
308
308
 
309
+ /**
310
+ * The `apps` collection holds product/app pages — a 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/`.
315
+ *
316
+ * Landing visuals (icons, screenshots, videos, the phone-frame art) are
317
+ * plain `public/` paths — videos can't go through the image pipeline, so
318
+ * the whole landing keeps one convention instead of two.
319
+ */
320
+ const apps = defineCollection({
321
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/apps' }),
322
+ schema: z.object({
323
+ title: z.string(),
324
+ description: z.string().optional(), // meta description
325
+ // 'landing' renders the structured marketing page below; 'page' renders
326
+ // the markdown body like a standalone page (legal subpages).
327
+ template: z.enum(['landing', 'page']).default('page'),
328
+ // Header-nav placement, like the `pages` collection (default off —
329
+ // landings are usually reached from the home cards).
330
+ nav: z.boolean().default(false),
331
+ navLabel: z.string().optional(),
332
+ order: z.number().default(0),
333
+
334
+ // ---- hero ----
335
+ subtitle: z.string().optional(),
336
+ icon: z.string().optional(), // app icon (public/ path)
337
+ // Free-form key a site can target from its CSS (`.aas-hero-bg-<key>`)
338
+ // for a custom hero background.
339
+ heroBg: z.string().optional(),
340
+ tagline: z.string().optional(), // small print under the store buttons (may contain <br>)
341
+ productHunt: z
342
+ .object({ url: z.string().url(), image: z.string().url(), alt: z.string() })
343
+ .optional(),
344
+ // Store links; "#" renders the button disabled (not yet released), the
345
+ // label is small print under a button (e.g. "5월 출시 예정").
346
+ stores: z
347
+ .object({
348
+ appstore: z.string().optional(),
349
+ appstoreLabel: z.string().optional(),
350
+ playstore: z.string().optional(),
351
+ playstoreLabel: z.string().optional(),
352
+ })
353
+ .optional(),
354
+
355
+ // ---- alternating feature rows ----
356
+ // Device-frame art that screenshots render inside when `phoneFrame` is
357
+ // set (one per page; features opt in individually).
358
+ phoneFrameImage: z.string().optional(),
359
+ features: z
360
+ .array(
361
+ z.object({
362
+ label: z.string().optional(), // small eyebrow above the title
363
+ title: z.string(),
364
+ description: z.string(), // may contain <br>
365
+ image: z.string().optional(),
366
+ images: z.array(z.string()).default([]), // 2+ → auto-rotating carousel
367
+ phoneFrame: z.boolean().default(false),
368
+ }),
369
+ )
370
+ .default([]),
371
+
372
+ // ---- highlights grid (icon cards) ----
373
+ highlightsTitle: z.string().optional(),
374
+ highlights: z
375
+ .array(
376
+ z.object({
377
+ icon: z.string().optional(), // public/ path
378
+ title: z.string(),
379
+ description: z.string(),
380
+ }),
381
+ )
382
+ .default([]),
383
+
384
+ // ---- themes showcase (video tabs, auto-rotating) ----
385
+ themesTitle: z.string().optional(),
386
+ themesSubtitle: z.string().optional(),
387
+ themes: z
388
+ .array(
389
+ z.object({
390
+ name: z.string(),
391
+ description: z.string(),
392
+ tabDesc: z.string().optional(),
393
+ video: z.string(), // public/ path (mp4)
394
+ poster: z.string().optional(),
395
+ icon: z.string().optional(), // emoji or short text on the tab button
396
+ }),
397
+ )
398
+ .default([]),
399
+ themesLandscape: z
400
+ .object({ title: z.string(), description: z.string(), image: z.string() })
401
+ .optional(),
402
+
403
+ // ---- pricing ----
404
+ pricingTitle: z.string().optional(),
405
+ pricingSubtitle: z.string().optional(),
406
+ pricingBadge: z.string().optional(), // ribbon on the featured tier
407
+ pricingNotes: z.array(z.string()).default([]),
408
+ pricing: z
409
+ .array(
410
+ z.object({
411
+ name: z.string(),
412
+ price: z.string(),
413
+ period: z.string().optional(), // "/월", "일회성", …
414
+ featured: z.boolean().default(false),
415
+ items: z.array(z.string()).default([]),
416
+ }),
417
+ )
418
+ .default([]),
419
+
420
+ // ---- closing CTA + legal links ----
421
+ ctaTitle: z.string().optional(),
422
+ ctaDescription: z.string().optional(),
423
+ // Localized labels; privacy/terms link to the sibling subpages, support
424
+ // opens mailto site.email.
425
+ legal: z
426
+ .object({
427
+ privacy: z.string().optional(),
428
+ terms: z.string().optional(),
429
+ support: z.string().optional(),
430
+ })
431
+ .optional(),
432
+
433
+ draft: z.boolean().default(false),
434
+ // Login-gated page (encrypted body; see docs/private-content-design.md).
435
+ private: z.boolean().default(false),
436
+ teaser: z.string().optional(),
437
+ }),
438
+ });
439
+
309
440
  /**
310
441
  * The `pages` collection holds standalone top-level pages — an About/소개, a
311
442
  * contact page, terms, etc. Unlike the other collections there's no index or
@@ -338,5 +469,5 @@ export function defineAasCollections({
338
469
  }),
339
470
  });
340
471
 
341
- return { stacks, articles, concepts, courses, slides, pages };
472
+ return { stacks, articles, concepts, courses, slides, apps, pages };
342
473
  }
@@ -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 { getNavApps, appSlugOf } from '../lib/apps';
11
+ import { homeTemplate } from '../lib/home';
10
12
  import { sectionEnabled, type SectionKey } from '../lib/sections';
11
13
 
12
14
  interface Props {
@@ -41,6 +43,8 @@ const isDefaultHome = path === '' && lang === 'en';
41
43
  // e.g. an About/소개 page. Rendered as their own nav items alongside the
42
44
  // built-in sections; the accessible label comes from the page's own frontmatter.
43
45
  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);
44
48
 
45
49
  // The header sections, as data — rendered twice from one source so the two
46
50
  // layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
@@ -54,12 +58,18 @@ const allNavItems: {
54
58
  keepFilters?: boolean;
55
59
  section?: SectionKey;
56
60
  }[] = [
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
- },
61
+ // Browse anchors into the catalog home's category sections — it only makes
62
+ // sense when the site keeps the catalog home template.
63
+ ...(homeTemplate === 'catalog'
64
+ ? [
65
+ {
66
+ href: `${home}#categories`,
67
+ label: t('nav.browse'),
68
+ keepFilters: true,
69
+ 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"/>',
70
+ },
71
+ ]
72
+ : []),
63
73
  {
64
74
  href: getRelativeLocaleUrl(lang, 'course/'),
65
75
  label: t('nav.courses'),
@@ -96,6 +106,12 @@ const allNavItems: {
96
106
  section: 'glossary',
97
107
  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
108
  },
109
+ ...navApps.map((a) => ({
110
+ href: getRelativeLocaleUrl(lang, `apps/${appSlugOf(a)}/`),
111
+ label: a.data.navLabel ?? a.data.title,
112
+ section: 'apps' as SectionKey,
113
+ svg: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
114
+ })),
99
115
  ...navPages.map((p) => ({
100
116
  href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
101
117
  label: p.data.navLabel ?? p.data.title,
@@ -0,0 +1,24 @@
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
+ }
@@ -0,0 +1,61 @@
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
+ href: string;
20
+ external?: boolean;
21
+ name: Localized;
22
+ description?: Localized;
23
+ icon?: string; // public/ path
24
+ rounded?: boolean; // app-icon style rounding for the icon
25
+ tags?: string[] | Record<string, string[]>;
26
+ }
27
+
28
+ export interface HomeConfig {
29
+ template: 'cards';
30
+ hero?: {
31
+ icon?: string; // public/ path, shown above the title
32
+ title?: Localized; // defaults to site.name
33
+ subtitle?: Localized; // defaults to the site.tagline UI string
34
+ };
35
+ cardsTitle?: Localized;
36
+ cards?: HomeCard[];
37
+ cta?: {
38
+ title?: Localized;
39
+ description?: Localized;
40
+ button?: { label: Localized; href: string; external?: boolean };
41
+ };
42
+ }
43
+
44
+ export const home: HomeConfig | undefined = (site as { home?: HomeConfig }).home;
45
+
46
+ /** Which homepage the site gets: the catalog (default) or the cards home. */
47
+ export const homeTemplate: 'catalog' | 'cards' = home?.template === 'cards' ? 'cards' : 'catalog';
48
+
49
+ /** Resolve a Localized string for `lang` (default locale, then any, as fallback). */
50
+ export function loc(v: Localized | undefined, lang: Lang): string | undefined {
51
+ if (v == null) return undefined;
52
+ if (typeof v === 'string') return v;
53
+ return v[lang] ?? v[defaultLang] ?? Object.values(v)[0];
54
+ }
55
+
56
+ /** Resolve a per-locale (or shared) string list for `lang`. */
57
+ export function locList(v: string[] | Record<string, string[]> | undefined, lang: Lang): string[] {
58
+ if (v == null) return [];
59
+ if (Array.isArray(v)) return v;
60
+ return v[lang] ?? v[defaultLang] ?? Object.values(v)[0] ?? [];
61
+ }
@@ -20,6 +20,7 @@ export type SectionKey =
20
20
  | 'concepts'
21
21
  | 'articles'
22
22
  | 'courses'
23
+ | 'apps'
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
+ apps: true, // no site data needed; an empty collection builds zero pages
32
34
  samples: true,
33
35
  slides: true,
34
36
  glossary: true,
@@ -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 AppLanding from '../../../components/AppLanding.astro';
7
+ import PrivateGate from '../../../components/PrivateGate.astro';
8
+ import TocRail from '../../../components/TocRail.astro';
9
+ import MermaidLoader from '../../../components/MermaidLoader.astro';
10
+ import { getApps, appSlugOf } from '../../../lib/apps';
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 getApps(lang)) {
18
+ paths.push({ params: { lang: langParam(lang), id: appSlugOf(entry) }, props: { lang, entry } });
19
+ }
20
+ }
21
+ return paths;
22
+ }) satisfies GetStaticPaths;
23
+
24
+ const { lang, entry } = Astro.props;
25
+ const slug = appSlugOf(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={`apps/${slug}/`}
39
+ noindex={priv}
40
+ >
41
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
42
+ {
43
+ isLanding ? (
44
+ <AppLanding 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>
@@ -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>