@pyreon/create-zero 0.14.0 → 0.15.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.
Files changed (65) hide show
  1. package/README.md +85 -22
  2. package/bin/create-pyreon-app.js +2 -0
  3. package/lib/index.js +1254 -191
  4. package/package.json +5 -2
  5. package/templates/{default → app}/src/routes/_layout.tsx +5 -2
  6. package/templates/blog/.mcp.json +8 -0
  7. package/templates/blog/CLAUDE.md +59 -0
  8. package/templates/blog/index.html +18 -0
  9. package/templates/blog/public/favicon.svg +4 -0
  10. package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
  11. package/templates/blog/src/content/posts/welcome.tsx +70 -0
  12. package/templates/blog/src/content/posts/why-signals.tsx +57 -0
  13. package/templates/blog/src/entry-client.ts +5 -0
  14. package/templates/blog/src/global.css +292 -0
  15. package/templates/blog/src/lib/posts.ts +45 -0
  16. package/templates/blog/src/routes/_layout.tsx +40 -0
  17. package/templates/blog/src/routes/about.tsx +28 -0
  18. package/templates/blog/src/routes/api/rss.ts +55 -0
  19. package/templates/blog/src/routes/blog/[slug].tsx +67 -0
  20. package/templates/blog/src/routes/blog/index.tsx +43 -0
  21. package/templates/blog/src/routes/index.tsx +52 -0
  22. package/templates/blog/tsconfig.json +16 -0
  23. package/templates/dashboard/.mcp.json +8 -0
  24. package/templates/dashboard/CLAUDE.md +50 -0
  25. package/templates/dashboard/index.html +16 -0
  26. package/templates/dashboard/public/favicon.svg +4 -0
  27. package/templates/dashboard/src/entry-client.ts +5 -0
  28. package/templates/dashboard/src/global.css +451 -0
  29. package/templates/dashboard/src/lib/auth.ts +106 -0
  30. package/templates/dashboard/src/lib/db.ts +118 -0
  31. package/templates/dashboard/src/routes/_layout.tsx +28 -0
  32. package/templates/dashboard/src/routes/api/signout.ts +15 -0
  33. package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
  34. package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
  35. package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
  36. package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
  37. package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
  38. package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
  39. package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
  40. package/templates/dashboard/src/routes/app/users.tsx +50 -0
  41. package/templates/dashboard/src/routes/index.tsx +40 -0
  42. package/templates/dashboard/src/routes/login.tsx +79 -0
  43. package/templates/dashboard/src/routes/signup.tsx +78 -0
  44. package/templates/dashboard/tsconfig.json +16 -0
  45. package/lib/index.js.map +0 -1
  46. /package/templates/{default → app}/.mcp.json +0 -0
  47. /package/templates/{default → app}/CLAUDE.md +0 -0
  48. /package/templates/{default → app}/index.html +0 -0
  49. /package/templates/{default → app}/public/favicon.svg +0 -0
  50. /package/templates/{default → app}/src/entry-client.ts +0 -0
  51. /package/templates/{default → app}/src/features/posts.ts +0 -0
  52. /package/templates/{default → app}/src/global.css +0 -0
  53. /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
  54. /package/templates/{default → app}/src/routes/_error.tsx +0 -0
  55. /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
  56. /package/templates/{default → app}/src/routes/about.tsx +0 -0
  57. /package/templates/{default → app}/src/routes/api/health.ts +0 -0
  58. /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
  59. /package/templates/{default → app}/src/routes/counter.tsx +0 -0
  60. /package/templates/{default → app}/src/routes/index.tsx +0 -0
  61. /package/templates/{default → app}/src/routes/posts/[id].tsx +0 -0
  62. /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
  63. /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
  64. /package/templates/{default → app}/src/stores/app.ts +0 -0
  65. /package/templates/{default → app}/tsconfig.json +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pyreon/create-zero",
3
- "version": "0.14.0",
4
- "description": "Create a new Pyreon Zero project",
3
+ "version": "0.15.0",
4
+ "description": "Create a new Pyreon project — invoke as `create-pyreon-app` (canonical) or `create-zero` (alias)",
5
5
  "license": "MIT",
6
6
  "author": "Vit Bokisch",
7
7
  "repository": {
@@ -10,17 +10,20 @@
10
10
  "directory": "packages/zero/create-zero"
11
11
  },
12
12
  "bin": {
13
+ "create-pyreon-app": "./bin/create-pyreon-app.js",
13
14
  "create-zero": "./bin/create-zero.js"
14
15
  },
15
16
  "files": [
16
17
  "bin",
17
18
  "lib",
19
+ "!lib/**/*.map",
18
20
  "!lib/analysis",
19
21
  "templates",
20
22
  "LICENSE",
21
23
  "README.md"
22
24
  ],
23
25
  "type": "module",
26
+ "sideEffects": false,
24
27
  "main": "./lib/index.js",
25
28
  "scripts": {
26
29
  "build": "vl_rolldown_build",
@@ -1,4 +1,5 @@
1
1
  import { QueryClient, QueryClientProvider } from "@pyreon/query"
2
+ import { RouterView } from "@pyreon/router"
2
3
  import { Link } from "@pyreon/zero/link"
3
4
  import { ThemeToggle } from "@pyreon/zero/theme"
4
5
  import { useAppStore } from "../stores/app"
@@ -7,7 +8,7 @@ const queryClient = new QueryClient({
7
8
  defaultOptions: { queries: { staleTime: 30000 } },
8
9
  })
9
10
 
10
- export function layout(props: { children: any }) {
11
+ export function layout() {
11
12
  const app = useAppStore()
12
13
  const sidebarOpen = app.store.sidebarOpen
13
14
  const toggleSidebar = app.store.toggleSidebar
@@ -49,7 +50,9 @@ export function layout(props: { children: any }) {
49
50
  </div>
50
51
  </header>
51
52
 
52
- <main class="app-main">{props.children}</main>
53
+ <main class="app-main">
54
+ <RouterView />
55
+ </main>
53
56
 
54
57
  <footer class="app-footer">Built with Pyreon Zero — signal-based, blazing fast.</footer>
55
58
  </QueryClientProvider>
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "pyreon": {
4
+ "command": "bunx",
5
+ "args": ["@pyreon/mcp"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,59 @@
1
+ # Blog (Pyreon Zero)
2
+
3
+ A statically-rendered blog. Posts live as TSX files in `src/content/posts/`.
4
+
5
+ ## Reactivity (Pyreon, not React)
6
+
7
+ - `signal()` not `useState`; `computed()` not `useMemo`; `effect()` not `useEffect`.
8
+ - Write signals via `signal.set(value)` or `signal.update(fn)`. Calling `signal(value)` does NOT write — it reads.
9
+ - Inside JSX, signals auto-call: `{count}` (compiler inserts `()`). Outside JSX, call explicitly.
10
+
11
+ ## JSX
12
+
13
+ - `class=` not `className`; `for=` not `htmlFor`; camelCase events (`onClick`, `onMouseEnter`).
14
+ - JSX import source is `@pyreon/core` — auto-configured via tsconfig.
15
+
16
+ ## Adding a post
17
+
18
+ Drop a `.tsx` file in `src/content/posts/`:
19
+
20
+ ```tsx
21
+ export const meta = {
22
+ title: "My new post",
23
+ date: "2026-04-28",
24
+ description: "One sentence summary used in meta tags + RSS.",
25
+ tags: ["pyreon", "ssg"],
26
+ }
27
+
28
+ export default function Post() {
29
+ return (
30
+ <>
31
+ <p>The body of the post — JSX, including code blocks, images, etc.</p>
32
+ </>
33
+ )
34
+ }
35
+ ```
36
+
37
+ The post auto-appears in `/blog/` and the `/rss.xml` feed via `src/lib/posts.ts`'s
38
+ `import.meta.glob` scan. The slug is derived from the filename.
39
+
40
+ ## Routes
41
+
42
+ - `/` — landing page with the most recent posts
43
+ - `/blog/` — full post archive
44
+ - `/blog/:slug` — post detail with hydration
45
+ - `/rss.xml` — RSS feed (API route)
46
+
47
+ ## Rendering
48
+
49
+ This template is configured for SSG — every route is pre-rendered at build time.
50
+ The `/rss.xml` API route is also pre-rendered. Per-post viewers are hydrated client-
51
+ side so internal links and theme toggles stay reactive.
52
+
53
+ ## Commands
54
+
55
+ ```bash
56
+ bun run dev # dev server with HMR
57
+ bun run build # static build → dist/
58
+ bun run preview # serve dist/ locally
59
+ ```
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#0a0a0b" />
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8
+ <link rel="alternate" type="application/rss+xml" title="RSS" href="/api/rss" />
9
+ <!-- Prevent flash of wrong theme -->
10
+ <script>(()=> {try{var t=localStorage.getItem("blog-theme");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r}catch(e){}})()</script>
11
+ <!--pyreon-head-->
12
+ </head>
13
+ <body>
14
+ <div id="app"><!--pyreon-app--></div>
15
+ <!--pyreon-scripts-->
16
+ <script type="module" src="/src/entry-client.ts"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="6" fill="#0a0a0b"/>
3
+ <path d="M9 8h14v3H9zm0 7h14v3H9zm0 7h10v3H9z" fill="#fafafa"/>
4
+ </svg>
@@ -0,0 +1,54 @@
1
+ export const meta = {
2
+ title: "Static vs SSR — picking the right rendering mode",
3
+ date: "2026-04-20",
4
+ description:
5
+ "Pyreon Zero ships four rendering modes. Here's a one-paragraph guide to picking between them for a content site.",
6
+ tags: ["pyreon", "ssg", "ssr"],
7
+ }
8
+
9
+ export default function Post() {
10
+ return (
11
+ <>
12
+ <p>
13
+ Pyreon Zero ships four rendering modes — SSG, SSR (string), SSR (stream), and SPA.
14
+ For a blog, SSG is almost always the right choice. Here's why.
15
+ </p>
16
+
17
+ <h2>SSG (this template's default)</h2>
18
+ <p>
19
+ Every page is HTML on disk. Cheap to host (any static CDN), fast to load (zero
20
+ server work), and indexable by every search engine and AI crawler. The downside:
21
+ new content requires a rebuild + redeploy. For a blog where posts are written, not
22
+ scraped, this is a feature.
23
+ </p>
24
+
25
+ <h2>SSR string / stream</h2>
26
+ <p>
27
+ Render on every request. Required for per-user content (dashboards, social feeds).
28
+ The streaming variant flushes HTML chunks as they're ready — fastest TTFB. Overkill
29
+ for a blog unless you're personalizing posts.
30
+ </p>
31
+
32
+ <h2>SPA</h2>
33
+ <p>
34
+ Client-only rendering. Never the right answer for a content site — search engines and
35
+ AI crawlers see an empty shell. Reserve for behind-login dashboards.
36
+ </p>
37
+
38
+ <h2>Mixing modes</h2>
39
+ <p>
40
+ Pyreon supports per-route overrides. Even on this SSG template, you can mark a single
41
+ route SSR by exporting <code>renderMode</code>:
42
+ </p>
43
+ <pre>
44
+ <code>
45
+ {`export const renderMode = "ssr-stream"
46
+
47
+ export default function LiveStats() {
48
+ // ...
49
+ }`}
50
+ </code>
51
+ </pre>
52
+ </>
53
+ )
54
+ }
@@ -0,0 +1,70 @@
1
+ export const meta = {
2
+ title: "Welcome to your new Pyreon blog",
3
+ date: "2026-04-28",
4
+ description:
5
+ "A quick tour of how this blog is wired together — TSX posts, SSG, RSS, and dark mode out of the box.",
6
+ tags: ["pyreon", "ssg", "intro"],
7
+ }
8
+
9
+ export default function Post() {
10
+ return (
11
+ <>
12
+ <p>
13
+ This blog ships pre-rendered at build time. Every post is a small TSX file in{" "}
14
+ <code>src/content/posts/</code>, and the loader at <code>src/lib/posts.ts</code> walks
15
+ the directory via Vite's <code>import.meta.glob</code>.
16
+ </p>
17
+
18
+ <h2>Adding a post</h2>
19
+
20
+ <p>
21
+ Drop a new <code>.tsx</code> file alongside this one. Export <code>meta</code> with{" "}
22
+ <code>title</code>, <code>date</code>, and <code>description</code>, then a default
23
+ component for the body:
24
+ </p>
25
+
26
+ <pre>
27
+ <code>
28
+ {`export const meta = {
29
+ title: "My new post",
30
+ date: "2026-05-01",
31
+ description: "One sentence summary.",
32
+ }
33
+
34
+ export default function Post() {
35
+ return <p>The body of the post.</p>
36
+ }`}
37
+ </code>
38
+ </pre>
39
+
40
+ <p>
41
+ The post auto-appears in <code>/blog/</code> and the <code>/api/rss</code> feed. The
42
+ slug comes from the filename.
43
+ </p>
44
+
45
+ <h2>Why TSX instead of Markdown?</h2>
46
+
47
+ <p>
48
+ TSX gives you the full Pyreon component model inside posts — interactive demos, live
49
+ signals, charts — without an extra build step or a markdown-to-JSX bridge. If you'd
50
+ prefer markdown, swap the loader for a remark/MDX pipeline; the route shape stays the
51
+ same.
52
+ </p>
53
+
54
+ <h2>What's next?</h2>
55
+
56
+ <ul>
57
+ <li>
58
+ Edit <code>src/routes/_layout.tsx</code> to change the site header
59
+ </li>
60
+ <li>
61
+ Edit <code>src/global.css</code> for typography
62
+ </li>
63
+ <li>
64
+ Update <code>src/routes/api/rss.xml.ts</code> with your domain
65
+ </li>
66
+ <li>Replace this post with your own.</li>
67
+ </ul>
68
+ </>
69
+ )
70
+ }
@@ -0,0 +1,57 @@
1
+ export const meta = {
2
+ title: "Why signals beat hooks for content sites",
3
+ date: "2026-04-25",
4
+ description:
5
+ "Pyreon's fine-grained reactivity model means a blog's interactive widgets don't pay the React re-render tax.",
6
+ tags: ["pyreon", "reactivity"],
7
+ }
8
+
9
+ export default function Post() {
10
+ return (
11
+ <>
12
+ <p>
13
+ A typical content site has a few interactive widgets — a theme toggle, a search box, a
14
+ comments thread — sitting in an otherwise static page. Most frameworks re-render the
15
+ whole component subtree every time one of those widgets changes state. Pyreon doesn't.
16
+ </p>
17
+
18
+ <h2>Components run once</h2>
19
+
20
+ <p>
21
+ In Pyreon, a component function executes exactly once at mount. Reactivity comes from
22
+ signals reading themselves at use sites — the framework subscribes the surrounding
23
+ DOM node to the signal and updates only the affected text or attribute when the signal
24
+ changes.
25
+ </p>
26
+
27
+ <pre>
28
+ <code>
29
+ {`const count = signal(0)
30
+
31
+ function Counter() {
32
+ return <button onClick={() => count.update(n => n + 1)}>{count}</button>
33
+ }`}
34
+ </code>
35
+ </pre>
36
+
37
+ <p>
38
+ <code>{"{count}"}</code> auto-calls the signal in JSX (the compiler inserts the parens),
39
+ and the framework binds the resulting text node to the signal's subscription set. When
40
+ you click, only the button's text node updates — the component function never re-runs.
41
+ </p>
42
+
43
+ <h2>What this means for a blog</h2>
44
+
45
+ <ul>
46
+ <li>Toggling dark mode flips a single <code>data-theme</code> attribute on <code>html</code>.</li>
47
+ <li>Live search filters update only the post list, not the layout.</li>
48
+ <li>Embedded interactive demos don't re-render their surroundings.</li>
49
+ </ul>
50
+
51
+ <p>
52
+ The blog template ships with a theme toggle in the header — open the page, switch
53
+ themes, and watch only the relevant CSS variables update.
54
+ </p>
55
+ </>
56
+ )
57
+ }
@@ -0,0 +1,5 @@
1
+ import "./global.css"
2
+ import { routes } from "virtual:zero/routes"
3
+ import { startClient } from "@pyreon/zero/client"
4
+
5
+ startClient({ routes })
@@ -0,0 +1,292 @@
1
+ /* ─── Reset ──────────────────────────────────────────────────────────────── */
2
+
3
+ *,
4
+ *::before,
5
+ *::after {
6
+ box-sizing: border-box;
7
+ margin: 0;
8
+ padding: 0;
9
+ }
10
+
11
+ :root {
12
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
13
+ --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
14
+
15
+ --c-bg: #0a0a0b;
16
+ --c-surface: #141416;
17
+ --c-border: #27272a;
18
+ --c-text: #fafafa;
19
+ --c-text-secondary: #a1a1aa;
20
+ --c-text-muted: #71717a;
21
+ --c-accent: #6d5cff;
22
+ --c-accent-hover: #7d6fff;
23
+
24
+ --measure: 68ch;
25
+ --space-xs: 0.25rem;
26
+ --space-sm: 0.5rem;
27
+ --space-md: 1rem;
28
+ --space-lg: 1.5rem;
29
+ --space-xl: 2rem;
30
+ --space-2xl: 3rem;
31
+ --space-3xl: 5rem;
32
+
33
+ --radius-sm: 6px;
34
+ --radius-md: 10px;
35
+ }
36
+
37
+ [data-theme='light'] {
38
+ --c-bg: #ffffff;
39
+ --c-surface: #f4f4f5;
40
+ --c-border: #d4d4d8;
41
+ --c-text: #18181b;
42
+ --c-text-secondary: #3f3f46;
43
+ --c-text-muted: #71717a;
44
+ --c-accent: #5b4fd6;
45
+ --c-accent-hover: #4a3fb8;
46
+ }
47
+
48
+ html {
49
+ font-family: var(--font-sans);
50
+ background: var(--c-bg);
51
+ color: var(--c-text);
52
+ -webkit-font-smoothing: antialiased;
53
+ -moz-osx-font-smoothing: grayscale;
54
+ line-height: 1.6;
55
+ }
56
+
57
+ body {
58
+ min-height: 100vh;
59
+ display: flex;
60
+ flex-direction: column;
61
+ }
62
+
63
+ #app {
64
+ display: flex;
65
+ flex-direction: column;
66
+ flex: 1;
67
+ }
68
+
69
+ /* ─── Layout ─────────────────────────────────────────────────────────────── */
70
+
71
+ .site-header {
72
+ border-bottom: 1px solid var(--c-border);
73
+ padding: var(--space-md) var(--space-lg);
74
+ }
75
+
76
+ .site-header-inner {
77
+ max-width: 960px;
78
+ margin: 0 auto;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ gap: var(--space-lg);
83
+ }
84
+
85
+ .site-logo {
86
+ font-weight: 700;
87
+ font-size: 1.125rem;
88
+ letter-spacing: -0.02em;
89
+ color: var(--c-text);
90
+ text-decoration: none;
91
+ }
92
+
93
+ .site-nav {
94
+ display: flex;
95
+ gap: var(--space-lg);
96
+ align-items: center;
97
+ }
98
+
99
+ .site-nav a {
100
+ color: var(--c-text-secondary);
101
+ text-decoration: none;
102
+ font-size: 0.9375rem;
103
+ transition: color 150ms ease;
104
+ }
105
+
106
+ .site-nav a:hover,
107
+ .site-nav a.nav-active {
108
+ color: var(--c-text);
109
+ }
110
+
111
+ .site-main {
112
+ flex: 1;
113
+ padding: var(--space-2xl) var(--space-lg);
114
+ }
115
+
116
+ .site-main-inner {
117
+ max-width: var(--measure);
118
+ margin: 0 auto;
119
+ }
120
+
121
+ .site-footer {
122
+ border-top: 1px solid var(--c-border);
123
+ padding: var(--space-lg);
124
+ color: var(--c-text-muted);
125
+ font-size: 0.875rem;
126
+ text-align: center;
127
+ }
128
+
129
+ /* ─── Typography ─────────────────────────────────────────────────────────── */
130
+
131
+ h1,
132
+ h2,
133
+ h3,
134
+ h4 {
135
+ letter-spacing: -0.02em;
136
+ line-height: 1.2;
137
+ margin-bottom: var(--space-md);
138
+ }
139
+
140
+ h1 {
141
+ font-size: 2.5rem;
142
+ font-weight: 800;
143
+ }
144
+
145
+ h2 {
146
+ font-size: 1.75rem;
147
+ font-weight: 700;
148
+ margin-top: var(--space-2xl);
149
+ }
150
+
151
+ h3 {
152
+ font-size: 1.25rem;
153
+ font-weight: 600;
154
+ margin-top: var(--space-xl);
155
+ }
156
+
157
+ p {
158
+ margin-bottom: var(--space-md);
159
+ color: var(--c-text-secondary);
160
+ }
161
+
162
+ a {
163
+ color: var(--c-accent);
164
+ }
165
+
166
+ a:hover {
167
+ color: var(--c-accent-hover);
168
+ }
169
+
170
+ code {
171
+ font-family: var(--font-mono);
172
+ font-size: 0.875em;
173
+ background: var(--c-surface);
174
+ border: 1px solid var(--c-border);
175
+ border-radius: var(--radius-sm);
176
+ padding: 0.125em 0.375em;
177
+ }
178
+
179
+ pre {
180
+ font-family: var(--font-mono);
181
+ font-size: 0.875rem;
182
+ background: var(--c-surface);
183
+ border: 1px solid var(--c-border);
184
+ border-radius: var(--radius-md);
185
+ padding: var(--space-md);
186
+ margin-bottom: var(--space-lg);
187
+ overflow-x: auto;
188
+ }
189
+
190
+ pre code {
191
+ background: transparent;
192
+ border: 0;
193
+ padding: 0;
194
+ }
195
+
196
+ ul,
197
+ ol {
198
+ margin-bottom: var(--space-md);
199
+ padding-left: 1.5rem;
200
+ color: var(--c-text-secondary);
201
+ }
202
+
203
+ li {
204
+ margin-bottom: var(--space-xs);
205
+ }
206
+
207
+ blockquote {
208
+ border-left: 3px solid var(--c-accent);
209
+ padding-left: var(--space-md);
210
+ margin: var(--space-lg) 0;
211
+ color: var(--c-text-secondary);
212
+ font-style: italic;
213
+ }
214
+
215
+ img {
216
+ max-width: 100%;
217
+ border-radius: var(--radius-md);
218
+ margin: var(--space-lg) 0;
219
+ }
220
+
221
+ hr {
222
+ border: 0;
223
+ border-top: 1px solid var(--c-border);
224
+ margin: var(--space-2xl) 0;
225
+ }
226
+
227
+ /* ─── Post lists ─────────────────────────────────────────────────────────── */
228
+
229
+ .post-list {
230
+ list-style: none;
231
+ padding: 0;
232
+ margin: 0;
233
+ }
234
+
235
+ .post-list li {
236
+ margin-bottom: var(--space-xl);
237
+ padding-bottom: var(--space-xl);
238
+ border-bottom: 1px solid var(--c-border);
239
+ }
240
+
241
+ .post-list li:last-child {
242
+ border-bottom: 0;
243
+ }
244
+
245
+ .post-title {
246
+ font-size: 1.5rem;
247
+ font-weight: 700;
248
+ margin-bottom: var(--space-xs);
249
+ }
250
+
251
+ .post-title a {
252
+ color: var(--c-text);
253
+ text-decoration: none;
254
+ }
255
+
256
+ .post-title a:hover {
257
+ color: var(--c-accent);
258
+ }
259
+
260
+ .post-meta {
261
+ color: var(--c-text-muted);
262
+ font-size: 0.875rem;
263
+ margin-bottom: var(--space-sm);
264
+ }
265
+
266
+ .post-summary {
267
+ color: var(--c-text-secondary);
268
+ }
269
+
270
+ .tags {
271
+ display: flex;
272
+ gap: var(--space-xs);
273
+ flex-wrap: wrap;
274
+ margin-top: var(--space-sm);
275
+ }
276
+
277
+ .tag {
278
+ font-size: 0.75rem;
279
+ background: var(--c-surface);
280
+ border: 1px solid var(--c-border);
281
+ border-radius: var(--radius-sm);
282
+ padding: 0.125rem 0.5rem;
283
+ color: var(--c-text-secondary);
284
+ }
285
+
286
+ /* ─── Post detail ────────────────────────────────────────────────────────── */
287
+
288
+ .post-header {
289
+ margin-bottom: var(--space-2xl);
290
+ padding-bottom: var(--space-xl);
291
+ border-bottom: 1px solid var(--c-border);
292
+ }
@@ -0,0 +1,45 @@
1
+ import type { ComponentFn } from "@pyreon/core"
2
+
3
+ export interface PostMeta {
4
+ title: string
5
+ date: string
6
+ description: string
7
+ tags?: string[]
8
+ }
9
+
10
+ export interface Post extends PostMeta {
11
+ slug: string
12
+ Component: ComponentFn<unknown>
13
+ }
14
+
15
+ interface PostModule {
16
+ meta: PostMeta
17
+ default: ComponentFn<unknown>
18
+ }
19
+
20
+ /**
21
+ * Enumerate all `.tsx` posts in `src/content/posts/` at build time. Eagerly
22
+ * loads modules so SSG renders see frontmatter immediately. The slug is the
23
+ * filename without extension (`welcome.tsx` → `welcome`).
24
+ */
25
+ const modules = import.meta.glob<PostModule>("../content/posts/*.tsx", {
26
+ eager: true,
27
+ })
28
+
29
+ export const posts: Post[] = Object.entries(modules)
30
+ .map(([path, mod]) => {
31
+ const slug = path
32
+ .split("/")
33
+ .pop()!
34
+ .replace(/\.tsx$/, "")
35
+ return { ...mod.meta, slug, Component: mod.default }
36
+ })
37
+ .sort((a, b) => (a.date < b.date ? 1 : -1))
38
+
39
+ export function postBySlug(slug: string): Post | undefined {
40
+ return posts.find((p) => p.slug === slug)
41
+ }
42
+
43
+ export function postSlugs(): string[] {
44
+ return posts.map((p) => p.slug)
45
+ }