@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.
- package/README.md +85 -22
- package/bin/create-pyreon-app.js +2 -0
- package/lib/index.js +1254 -191
- package/package.json +5 -2
- package/templates/{default → app}/src/routes/_layout.tsx +5 -2
- package/templates/blog/.mcp.json +8 -0
- package/templates/blog/CLAUDE.md +59 -0
- package/templates/blog/index.html +18 -0
- package/templates/blog/public/favicon.svg +4 -0
- package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
- package/templates/blog/src/content/posts/welcome.tsx +70 -0
- package/templates/blog/src/content/posts/why-signals.tsx +57 -0
- package/templates/blog/src/entry-client.ts +5 -0
- package/templates/blog/src/global.css +292 -0
- package/templates/blog/src/lib/posts.ts +45 -0
- package/templates/blog/src/routes/_layout.tsx +40 -0
- package/templates/blog/src/routes/about.tsx +28 -0
- package/templates/blog/src/routes/api/rss.ts +55 -0
- package/templates/blog/src/routes/blog/[slug].tsx +67 -0
- package/templates/blog/src/routes/blog/index.tsx +43 -0
- package/templates/blog/src/routes/index.tsx +52 -0
- package/templates/blog/tsconfig.json +16 -0
- package/templates/dashboard/.mcp.json +8 -0
- package/templates/dashboard/CLAUDE.md +50 -0
- package/templates/dashboard/index.html +16 -0
- package/templates/dashboard/public/favicon.svg +4 -0
- package/templates/dashboard/src/entry-client.ts +5 -0
- package/templates/dashboard/src/global.css +451 -0
- package/templates/dashboard/src/lib/auth.ts +106 -0
- package/templates/dashboard/src/lib/db.ts +118 -0
- package/templates/dashboard/src/routes/_layout.tsx +28 -0
- package/templates/dashboard/src/routes/api/signout.ts +15 -0
- package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
- package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
- package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
- package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
- package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
- package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
- package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
- package/templates/dashboard/src/routes/app/users.tsx +50 -0
- package/templates/dashboard/src/routes/index.tsx +40 -0
- package/templates/dashboard/src/routes/login.tsx +79 -0
- package/templates/dashboard/src/routes/signup.tsx +78 -0
- package/templates/dashboard/tsconfig.json +16 -0
- package/lib/index.js.map +0 -1
- /package/templates/{default → app}/.mcp.json +0 -0
- /package/templates/{default → app}/CLAUDE.md +0 -0
- /package/templates/{default → app}/index.html +0 -0
- /package/templates/{default → app}/public/favicon.svg +0 -0
- /package/templates/{default → app}/src/entry-client.ts +0 -0
- /package/templates/{default → app}/src/features/posts.ts +0 -0
- /package/templates/{default → app}/src/global.css +0 -0
- /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
- /package/templates/{default → app}/src/routes/_error.tsx +0 -0
- /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
- /package/templates/{default → app}/src/routes/about.tsx +0 -0
- /package/templates/{default → app}/src/routes/api/health.ts +0 -0
- /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
- /package/templates/{default → app}/src/routes/counter.tsx +0 -0
- /package/templates/{default → app}/src/routes/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/[id].tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
- /package/templates/{default → app}/src/stores/app.ts +0 -0
- /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.
|
|
4
|
-
"description": "Create a new Pyreon
|
|
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(
|
|
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">
|
|
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,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,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,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
|
+
}
|