create-shibumi 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +57 -5
- package/scripts/shibumi.lock.json +22 -0
- package/scripts/ship.lock.json +4 -0
- package/src/args.ts +162 -0
- package/src/cli.ts +232 -0
- package/src/create.ts +272 -0
- package/src/templates/blog/README.md +10 -0
- package/src/templates/blog/agents.md +36 -0
- package/src/templates/blog/astro.config.mjs +13 -0
- package/src/templates/blog/bun.lock +787 -0
- package/src/templates/blog/gitignore +4 -0
- package/src/templates/blog/package.json +29 -0
- package/src/templates/blog/public/og-default.png +0 -0
- package/src/templates/blog/public/style.css +147 -0
- package/src/templates/blog/src/components/BaseHead.astro +37 -0
- package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +13 -0
- package/src/templates/blog/src/content/blog/own-your-source.md +13 -0
- package/src/templates/blog/src/content/blog/writing-for-agents.md +13 -0
- package/src/templates/blog/src/content.config.ts +17 -0
- package/src/templates/blog/src/layouts/Base.astro +26 -0
- package/src/templates/blog/src/pages/404.astro +11 -0
- package/src/templates/blog/src/pages/index.astro +30 -0
- package/src/templates/blog/src/pages/llms.txt.ts +29 -0
- package/src/templates/blog/src/pages/posts/[id].astro +31 -0
- package/src/templates/blog/src/pages/posts/[id].md.ts +18 -0
- package/src/templates/blog/src/pages/robots.txt.ts +6 -0
- package/src/templates/blog/src/pages/rss.xml.ts +21 -0
- package/src/templates/blog/src/site.ts +7 -0
- package/src/templates/blog/tsconfig.json +5 -0
- package/src/templates/full-stack/.dockerignore +14 -0
- package/src/templates/full-stack/Dockerfile +23 -0
- package/src/templates/full-stack/README.md +10 -0
- package/src/templates/full-stack/agents.md +60 -0
- package/src/templates/full-stack/bun.lock +225 -0
- package/src/templates/full-stack/compose.yaml +24 -0
- package/src/templates/full-stack/drizzle.config.ts +11 -0
- package/src/templates/full-stack/gitignore +6 -0
- package/src/templates/full-stack/package.json +39 -0
- package/src/templates/full-stack/public/app.js +11 -0
- package/src/templates/full-stack/public/style.css +60 -0
- package/src/templates/full-stack/public/vendor/alpine-csp-3.16.2.min.js +23 -0
- package/src/templates/full-stack/scripts/db.ts +39 -0
- package/src/templates/full-stack/src/app.ts +106 -0
- package/src/templates/full-stack/src/db/index.ts +11 -0
- package/src/templates/full-stack/src/db/lifecycle.ts +214 -0
- package/src/templates/full-stack/src/db/migrations/0001_notes.sql +9 -0
- package/src/templates/full-stack/src/db/schema.ts +13 -0
- package/src/templates/full-stack/src/env.ts +24 -0
- package/src/templates/full-stack/src/server.ts +30 -0
- package/src/templates/full-stack/test/app.test.ts +144 -0
- package/src/templates/full-stack/test/db.test.ts +169 -0
- package/src/templates/full-stack/tsconfig.json +19 -0
- package/src/templates/shibumi.ts +809 -0
- package/src/templates/ship.ts +2056 -0
- package/src/templates/static/README.md +10 -0
- package/src/templates/static/agents.md +31 -0
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +17 -0
- package/src/templates/static/public/404.html +15 -0
- package/src/templates/static/public/index.html +16 -0
- package/src/templates/static/public/style.css +50 -0
- package/src/templates/static/scripts/preview.ts +42 -0
- package/src/templates/web/.dockerignore +12 -0
- package/src/templates/web/Dockerfile +24 -0
- package/src/templates/web/README.md +10 -0
- package/src/templates/web/agents.md +54 -0
- package/src/templates/web/bun.lock +45 -0
- package/src/templates/web/compose.yaml +17 -0
- package/src/templates/web/gitignore +5 -0
- package/src/templates/web/package.json +33 -0
- package/src/templates/web/public/app.js +11 -0
- package/src/templates/web/public/style.css +60 -0
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +23 -0
- package/src/templates/web/src/app.ts +96 -0
- package/src/templates/web/src/env.ts +21 -0
- package/src/templates/web/src/server.ts +23 -0
- package/src/templates/web/test/app.test.ts +137 -0
- package/src/templates/web/tsconfig.json +13 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shibumi-blog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "astro dev",
|
|
8
|
+
"build": "astro build",
|
|
9
|
+
"check": "astro check",
|
|
10
|
+
"preview": "astro preview",
|
|
11
|
+
"ship": "bun scripts/ship.ts",
|
|
12
|
+
"ship:setup": "bun scripts/ship.ts --setup --static --output-dir dist --build-script build",
|
|
13
|
+
"ship:update": "bun scripts/ship.ts --update",
|
|
14
|
+
"ship:status": "bun scripts/ship.ts --status",
|
|
15
|
+
"ship:logs": "bun scripts/ship.ts --logs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@astrojs/rss": "4.0.19",
|
|
19
|
+
"@astrojs/sitemap": "3.7.3",
|
|
20
|
+
"astro": "7.2.4"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@astrojs/check": "0.9.10",
|
|
24
|
+
"typescript": "5.9.2"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.4.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* One small stylesheet: shibumi tokens on a Nue-style semantic skeleton. */
|
|
2
|
+
@layer base, components;
|
|
3
|
+
|
|
4
|
+
@layer base {
|
|
5
|
+
*, *::before, *::after {
|
|
6
|
+
box-sizing: border-box;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--paper: #f5f0e4;
|
|
12
|
+
--ink: #1b130f;
|
|
13
|
+
--muted: #6e6259;
|
|
14
|
+
--hairline: #e3dbc9;
|
|
15
|
+
--accent: #e95f19;
|
|
16
|
+
--font-serif: Georgia, "Times New Roman", serif;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--paper: #1b130f;
|
|
22
|
+
--ink: #f5f0e4;
|
|
23
|
+
--muted: #a2968c;
|
|
24
|
+
--hairline: #372a22;
|
|
25
|
+
--accent: #ff8648;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 1rem 1.5rem;
|
|
32
|
+
background: var(--paper);
|
|
33
|
+
color: var(--ink);
|
|
34
|
+
font-family: system-ui, sans-serif;
|
|
35
|
+
font-size: 1.125rem;
|
|
36
|
+
line-height: 1.6;
|
|
37
|
+
|
|
38
|
+
> header {
|
|
39
|
+
border-bottom: 1px solid var(--hairline);
|
|
40
|
+
padding-bottom: 1rem;
|
|
41
|
+
|
|
42
|
+
a {
|
|
43
|
+
color: var(--ink);
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
> main {
|
|
50
|
+
max-width: 40.5rem;
|
|
51
|
+
margin: 6rem auto;
|
|
52
|
+
min-height: 60vh;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
> footer {
|
|
56
|
+
border-top: 1px solid var(--hairline);
|
|
57
|
+
padding-block: 4rem;
|
|
58
|
+
text-align: center;
|
|
59
|
+
color: var(--muted);
|
|
60
|
+
font-size: 0.875rem;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
h1, h2, h3 {
|
|
65
|
+
font-family: var(--font-serif);
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
line-height: 1.15;
|
|
68
|
+
color: var(--ink);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
h1 {
|
|
72
|
+
font-size: 2.5rem;
|
|
73
|
+
letter-spacing: -0.02em;
|
|
74
|
+
margin-block: 0.25em;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
p {
|
|
78
|
+
text-wrap: pretty;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
a {
|
|
82
|
+
color: inherit;
|
|
83
|
+
text-decoration: underline;
|
|
84
|
+
text-decoration-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
|
85
|
+
text-underline-offset: 0.18em;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
a:hover {
|
|
89
|
+
text-decoration-color: var(--accent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
time {
|
|
93
|
+
color: var(--muted);
|
|
94
|
+
font-size: 0.875rem;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
code {
|
|
98
|
+
font-family: ui-monospace, monospace;
|
|
99
|
+
font-size: 0.9em;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pre {
|
|
103
|
+
padding: 1rem 1.25rem;
|
|
104
|
+
overflow-x: auto;
|
|
105
|
+
border: 1px solid var(--hairline);
|
|
106
|
+
border-radius: 4px;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@layer components {
|
|
111
|
+
/* Post list: whole-block links, hierarchy from type alone. */
|
|
112
|
+
.posts {
|
|
113
|
+
list-style: none;
|
|
114
|
+
margin: 3rem 0 0;
|
|
115
|
+
padding: 0;
|
|
116
|
+
|
|
117
|
+
li + li {
|
|
118
|
+
margin-top: 3rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
a {
|
|
122
|
+
display: block;
|
|
123
|
+
text-decoration: none;
|
|
124
|
+
color: inherit;
|
|
125
|
+
|
|
126
|
+
h3 {
|
|
127
|
+
margin-block: 0.25rem;
|
|
128
|
+
font-size: 1.375rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
p {
|
|
132
|
+
margin: 0;
|
|
133
|
+
color: var(--muted);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
a:hover h3 {
|
|
138
|
+
color: var(--accent);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.lede {
|
|
143
|
+
font-size: 1.25rem;
|
|
144
|
+
color: var(--muted);
|
|
145
|
+
margin-top: 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SITE } from "../site";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
ogImage?: string;
|
|
8
|
+
publishedTime?: Date;
|
|
9
|
+
markdownPath?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { title, description, ogImage, publishedTime, markdownPath } = Astro.props;
|
|
13
|
+
const canonical = new URL(Astro.url.pathname, Astro.site);
|
|
14
|
+
const image = new URL(ogImage ?? "/og-default.png", Astro.site);
|
|
15
|
+
const isArticle = Boolean(publishedTime);
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<meta charset="utf-8" />
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
20
|
+
<title>{title}</title>
|
|
21
|
+
<meta name="description" content={description} />
|
|
22
|
+
<meta name="author" content={SITE.author} />
|
|
23
|
+
<link rel="canonical" href={canonical} />
|
|
24
|
+
<link rel="stylesheet" href="/style.css" />
|
|
25
|
+
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
|
|
26
|
+
{markdownPath && <link rel="alternate" type="text/markdown" href={markdownPath} />}
|
|
27
|
+
<meta property="og:title" content={title} />
|
|
28
|
+
<meta property="og:description" content={description} />
|
|
29
|
+
<meta property="og:type" content={isArticle ? "article" : "website"} />
|
|
30
|
+
<meta property="og:url" content={canonical} />
|
|
31
|
+
<meta property="og:image" content={image} />
|
|
32
|
+
<meta property="og:site_name" content={SITE.name} />
|
|
33
|
+
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
|
|
34
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
35
|
+
<meta name="twitter:title" content={title} />
|
|
36
|
+
<meta name="twitter:description" content={description} />
|
|
37
|
+
<meta name="twitter:image" content={image} />
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: One VPS is plenty
|
|
3
|
+
description: A five-euro server runs more blogs than most of us will ever write. The hard part was never capacity; it was confidence.
|
|
4
|
+
date: 2026-07-15
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This site is static files behind a 1.4 MB container on a small VPS. It could serve every reader I will ever have from a machine that also runs four other projects.
|
|
8
|
+
|
|
9
|
+
What kept people off their own servers was never capacity. It was the deploy story: what happens when the build breaks, how you roll back, whether the thing you tested is the thing that shipped. Platforms answered those questions and charged rent on the answer.
|
|
10
|
+
|
|
11
|
+
The answers turn out to be portable. Build the exact commit, verify the output, ship the image, health-check before cutover, keep the previous image for rollback. None of that requires a platform; it requires a script you can read.
|
|
12
|
+
|
|
13
|
+
Rent is fine. Owning is calmer.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Own your source
|
|
3
|
+
description: Frameworks lend you abstractions and charge interest. Generated code you can read is a debt-free way to start a project.
|
|
4
|
+
date: 2026-08-01
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Most starters hand you a dependency. This one hands you files.
|
|
8
|
+
|
|
9
|
+
The difference shows up six months in. When behavior lives in a package, changing it means reading someone else's issue tracker. When behavior lives in your repository, changing it means editing a file you have already read.
|
|
10
|
+
|
|
11
|
+
Generated code has a bad reputation because generators used to produce piles you could not maintain. The fix is not less generation; it is generating less. A route file, a layout, a stylesheet with five variables. Small enough to read in one sitting, which means small enough to own.
|
|
12
|
+
|
|
13
|
+
There is a test for this: delete the tool that made the project. If the project still builds, still deploys, and still makes sense, you own it. If not, you rented it.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Writing for readers who curl
|
|
3
|
+
description: Half your readers are now programs. Serving markdown alternates and an llms.txt costs one endpoint and respects both audiences.
|
|
4
|
+
date: 2026-06-20
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Someone will read this post in a browser. Something will read it with an HTTP client and a token budget.
|
|
8
|
+
|
|
9
|
+
The polite response is to serve both well. Every post here exists twice: the HTML you may be reading, and a plain markdown file at the same path with `.md` on the end. An `llms.txt` at the root lists them all with one-line summaries, so an agent can survey the whole site in a single small request instead of scraping navigation markup.
|
|
10
|
+
|
|
11
|
+
This is not much work. The markdown already exists; the build just refrains from destroying it. One endpoint, a dozen lines.
|
|
12
|
+
|
|
13
|
+
Formats are a courtesy. HTML for eyes, markdown for context windows, RSS for the patient. The content is the same; only the reader changes.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { glob } from "astro/loaders";
|
|
2
|
+
import { defineCollection, z } from "astro:content";
|
|
3
|
+
|
|
4
|
+
// The schema enforces the SEO contract: every post has a title that fits a
|
|
5
|
+
// tab and a description that fits a search snippet, and both feed the meta
|
|
6
|
+
// tags, RSS, and llms.txt.
|
|
7
|
+
const blog = defineCollection({
|
|
8
|
+
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
|
|
9
|
+
schema: z.object({
|
|
10
|
+
title: z.string().max(60),
|
|
11
|
+
description: z.string().min(50).max(160),
|
|
12
|
+
date: z.coerce.date(),
|
|
13
|
+
ogImage: z.string().optional(),
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const collections = { blog };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseHead from "../components/BaseHead.astro";
|
|
3
|
+
import { SITE } from "../site";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
ogImage?: string;
|
|
9
|
+
publishedTime?: Date;
|
|
10
|
+
markdownPath?: string;
|
|
11
|
+
}
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!doctype html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<BaseHead {...Astro.props} />
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<header><a href="/">{SITE.name}</a></header>
|
|
21
|
+
<main>
|
|
22
|
+
<slot />
|
|
23
|
+
</main>
|
|
24
|
+
<footer>© {new Date().getFullYear()} {SITE.author} · <a href="/rss.xml">RSS</a> · <a href="/llms.txt">llms.txt</a></footer>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from "../layouts/Base.astro";
|
|
3
|
+
import { SITE } from "../site";
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<Base title={`Not found · ${SITE.name}`} description={SITE.description}>
|
|
7
|
+
<article>
|
|
8
|
+
<h1>Not found.</h1>
|
|
9
|
+
<p><a href="/">Back to the start.</a></p>
|
|
10
|
+
</article>
|
|
11
|
+
</Base>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import Base from "../layouts/Base.astro";
|
|
4
|
+
import { SITE } from "../site";
|
|
5
|
+
|
|
6
|
+
const posts = (await getCollection("blog")).sort(
|
|
7
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
8
|
+
);
|
|
9
|
+
const formatDate = (date: Date) =>
|
|
10
|
+
date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<Base title={SITE.name} description={SITE.description}>
|
|
14
|
+
<article>
|
|
15
|
+
<h1>{SITE.name}</h1>
|
|
16
|
+
<ul class="posts">
|
|
17
|
+
{
|
|
18
|
+
posts.map((post) => (
|
|
19
|
+
<li>
|
|
20
|
+
<a href={`/posts/${post.id}/`}>
|
|
21
|
+
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
|
|
22
|
+
<h3>{post.data.title}</h3>
|
|
23
|
+
<p>{post.data.description}</p>
|
|
24
|
+
</a>
|
|
25
|
+
</li>
|
|
26
|
+
))
|
|
27
|
+
}
|
|
28
|
+
</ul>
|
|
29
|
+
</article>
|
|
30
|
+
</Base>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Agent-facing index. Generated from the content collection, so it is
|
|
2
|
+
// always current; links point at the markdown alternates.
|
|
3
|
+
import type { APIRoute } from "astro";
|
|
4
|
+
import { getCollection } from "astro:content";
|
|
5
|
+
import { SITE } from "../site";
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async (context) => {
|
|
8
|
+
const posts = (await getCollection("blog")).sort(
|
|
9
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
10
|
+
);
|
|
11
|
+
const lines = [
|
|
12
|
+
`# ${SITE.name}`,
|
|
13
|
+
"",
|
|
14
|
+
`> ${SITE.description}`,
|
|
15
|
+
"",
|
|
16
|
+
"Every post is also available as plain markdown at the .md links below.",
|
|
17
|
+
"",
|
|
18
|
+
"## Posts",
|
|
19
|
+
"",
|
|
20
|
+
...posts.map(
|
|
21
|
+
(post) =>
|
|
22
|
+
`- [${post.data.title}](${new URL(`/posts/${post.id}.md`, context.site)}): ${post.data.description}`
|
|
23
|
+
),
|
|
24
|
+
"",
|
|
25
|
+
];
|
|
26
|
+
return new Response(lines.join("\n"), {
|
|
27
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
28
|
+
});
|
|
29
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render } from "astro:content";
|
|
3
|
+
import Base from "../../layouts/Base.astro";
|
|
4
|
+
|
|
5
|
+
export async function getStaticPaths() {
|
|
6
|
+
return (await getCollection("blog")).map((post) => ({
|
|
7
|
+
params: { id: post.id },
|
|
8
|
+
props: { post },
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { post } = Astro.props;
|
|
13
|
+
const { Content } = await render(post);
|
|
14
|
+
const formatDate = (date: Date) =>
|
|
15
|
+
date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Base
|
|
19
|
+
title={post.data.title}
|
|
20
|
+
description={post.data.description}
|
|
21
|
+
ogImage={post.data.ogImage}
|
|
22
|
+
publishedTime={post.data.date}
|
|
23
|
+
markdownPath={`/posts/${post.id}.md`}
|
|
24
|
+
>
|
|
25
|
+
<article>
|
|
26
|
+
<p><time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time></p>
|
|
27
|
+
<h1>{post.data.title}</h1>
|
|
28
|
+
<p class="lede">{post.data.description}</p>
|
|
29
|
+
<Content />
|
|
30
|
+
</article>
|
|
31
|
+
</Base>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Markdown alternates: every post is also served as plain markdown at
|
|
2
|
+
// posts/<id>.md for agents and curl. llms.txt links here.
|
|
3
|
+
import { getCollection, getEntry } from "astro:content";
|
|
4
|
+
import type { APIRoute } from "astro";
|
|
5
|
+
|
|
6
|
+
export async function getStaticPaths() {
|
|
7
|
+
return (await getCollection("blog")).map((post) => ({ params: { id: post.id } }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
11
|
+
const post = await getEntry("blog", params.id!);
|
|
12
|
+
if (!post) return new Response("Not found", { status: 404 });
|
|
13
|
+
const date = post.data.date.toISOString().slice(0, 10);
|
|
14
|
+
const body = `# ${post.data.title}\n\n${date}\n\n${post.body ?? ""}`;
|
|
15
|
+
return new Response(body, {
|
|
16
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = ({ site }) => {
|
|
4
|
+
const body = `User-agent: *\nAllow: /\n\nSitemap: ${new URL("sitemap-index.xml", site)}\n`;
|
|
5
|
+
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
6
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import rss from "@astrojs/rss";
|
|
2
|
+
import type { APIRoute } from "astro";
|
|
3
|
+
import { getCollection } from "astro:content";
|
|
4
|
+
import { SITE } from "../site";
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async (context) => {
|
|
7
|
+
const posts = (await getCollection("blog")).sort(
|
|
8
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
9
|
+
);
|
|
10
|
+
return rss({
|
|
11
|
+
title: SITE.name,
|
|
12
|
+
description: SITE.description,
|
|
13
|
+
site: context.site!,
|
|
14
|
+
items: posts.map((post) => ({
|
|
15
|
+
title: post.data.title,
|
|
16
|
+
description: post.data.description,
|
|
17
|
+
pubDate: post.data.date,
|
|
18
|
+
link: `/posts/${post.id}/`,
|
|
19
|
+
})),
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Major-pinned: minors and patches flow, a future Bun 2 cannot break deploys.
|
|
2
|
+
FROM oven/bun:1-alpine AS build
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
COPY package.json bun.lock ./
|
|
5
|
+
RUN bun install --frozen-lockfile
|
|
6
|
+
COPY tsconfig.json ./
|
|
7
|
+
COPY src ./src
|
|
8
|
+
RUN bun run build
|
|
9
|
+
|
|
10
|
+
FROM oven/bun:1-alpine
|
|
11
|
+
WORKDIR /app
|
|
12
|
+
COPY --from=build /app/dist ./dist
|
|
13
|
+
COPY src/db/migrations ./dist/migrations
|
|
14
|
+
COPY public ./public
|
|
15
|
+
COPY package.json ./
|
|
16
|
+
RUN mkdir -p /data && chown bun:bun /data
|
|
17
|
+
USER bun
|
|
18
|
+
# Production mode: extensions (auth) gate security behavior on this, e.g.
|
|
19
|
+
# refusing to build login links from the Host header. Never unset it here.
|
|
20
|
+
ENV NODE_ENV=production
|
|
21
|
+
ENV DB_PATH=/data/app.db
|
|
22
|
+
EXPOSE 3000
|
|
23
|
+
CMD ["bun", "dist/server.js"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Shibumi full-stack app
|
|
2
|
+
|
|
3
|
+
Bun, Hono, Zod, Alpine, Drizzle, and SQLite. Everything here is your source, including the migration and backup tooling.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bun install
|
|
7
|
+
bun dev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Deploy with `bun ship:setup`, then `bun ship`. Data lives on a persistent volume at `/data`; see `agents.md` for migration, backup, restore, and rollback rules.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Agent Notes
|
|
2
|
+
|
|
3
|
+
Bun full-stack app generated by create-shibumi. Hono routes, Zod validation, Alpine behavior, Drizzle + SQLite under a persistent volume. All source is owned by this project.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun dev # Ship wrapper starts hot reload on the configured app port
|
|
9
|
+
bun dev:app # plain watch mode on PORT (default 3000)
|
|
10
|
+
bun test # route, security-header, migration, backup, restore tests
|
|
11
|
+
bun run check # TypeScript check
|
|
12
|
+
bun run build # bundle src/server.ts to dist/server.js
|
|
13
|
+
bun db:status # applied and pending migrations
|
|
14
|
+
bun db:migrate # backup (when needed), then apply pending migrations
|
|
15
|
+
bun db:backup # manual VACUUM INTO backup with sha256 sidecar
|
|
16
|
+
bun db:restore <backup> # offline restore; stop the app first
|
|
17
|
+
bun ship # build image, upload, deploy via shibumi-server
|
|
18
|
+
bun run shibumi add <name> # install an extension (auth, email); --dry-run previews
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Routes and templates
|
|
22
|
+
|
|
23
|
+
- `src/app.ts`: routes and the HTML page. `src/server.ts`: boot ordering and shutdown. `src/env.ts`: environment schema (`PORT`, `DB_PATH`).
|
|
24
|
+
- `src/db/schema.ts`: Drizzle schema. `src/db/migrations/*.sql`: tracked SQL migrations, applied in filename order by the owned runner. `drizzle-kit generate` may draft a migration from a schema change; review it, then commit both.
|
|
25
|
+
- Static assets under `public/`, served at `/public/*`. Alpine is the vendored CSP build; register components in `public/app.js`.
|
|
26
|
+
|
|
27
|
+
## Trust boundaries and validation
|
|
28
|
+
|
|
29
|
+
- Environment goes through `src/env.ts`; request input through Zod at each route.
|
|
30
|
+
- The `SECURITY_HEADERS` set in `src/app.ts` is asserted verbatim by tests on every response class. The CSP allows no inline or eval'd script.
|
|
31
|
+
- Add no unauthenticated mutation endpoints (POST/PUT/PATCH/DELETE); tests fail if a route registers one. Write examples live in tests, which insert through `db` directly.
|
|
32
|
+
|
|
33
|
+
## Database
|
|
34
|
+
|
|
35
|
+
- Path: `DB_PATH`, default `data/app.db` locally, `/data/app.db` in the container (persistent named volume `app_data`). WAL, `foreign_keys ON`, `busy_timeout 5000` applied on every open.
|
|
36
|
+
- Boot ordering: pre-migration backup (existing database with pending migrations), apply migrations in `BEGIN IMMEDIATE` transactions, then serve. A failed migration rolls back, the process exits, the health check never passes, and the previous deployment stays live.
|
|
37
|
+
- Backups: `VACUUM INTO` snapshots in `<db-dir>/backups/` with sha256 sidecars; newest 5 kept; refused when free disk is under 2x the database size.
|
|
38
|
+
- Restore: `bun db:restore <backup>` with the app stopped; checksum verified, temp-and-rename swap, stale WAL/SHM removed.
|
|
39
|
+
- Rollback semantics: `bun ship --rollback` reverses the image only. Schema and data changes stay; restore a pre-migration backup for those, then redeploy the matching code.
|
|
40
|
+
|
|
41
|
+
## Files Ship generates
|
|
42
|
+
|
|
43
|
+
`bun ship:setup` writes `shibumi-server.json` (committed) and stores SSH targets in `~/.config/shibumi/config.json` (never committed). `scripts/ship.ts` is owned vendored source; update with `bun ship:update`.
|
|
44
|
+
|
|
45
|
+
## Secrets and runtime config
|
|
46
|
+
|
|
47
|
+
Set production env vars (secrets, per-deploy config) on the server, never in git or the image:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bun ship:env set KEY=VALUE # one or more
|
|
51
|
+
bun ship:env import .env.production # bulk from a local file
|
|
52
|
+
bun ship:env list # names only
|
|
53
|
+
bun ship:env rm KEY
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Values are stored per-app on the server and injected at deploy; run `bun ship` to apply. Non-secret tunables that belong in git live in `src/config/*.yaml` instead.
|
|
57
|
+
|
|
58
|
+
## Checks required before commit
|
|
59
|
+
|
|
60
|
+
`bun test` and `bun run check` must pass. Schema changes require a matching committed migration. The image copies `dist/`, `dist/migrations/`, `public/`, and `package.json`; update the Dockerfile and `.dockerignore` together if runtime files move.
|