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 +25 -0
- package/README.md +32 -0
- package/index.d.ts +1 -0
- package/index.mjs +4 -0
- package/package.json +1 -1
- package/src/components/AppLanding.astro +445 -0
- package/src/components/CardsHome.astro +108 -0
- package/src/content.ts +132 -1
- package/src/layouts/BaseLayout.astro +22 -6
- package/src/lib/apps.ts +24 -0
- package/src/lib/home.ts +61 -0
- package/src/lib/sections.ts +2 -0
- package/src/pages/[...lang]/apps/[...id].astro +67 -0
- package/src/pages/[...lang]/index.astro +3 -1
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
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.
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
package/src/lib/apps.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/home.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/sections.ts
CHANGED
|
@@ -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>
|