create-shibumi 0.3.2 → 0.3.6

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 (38) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/scripts/shibumi.lock.json +0 -1
  4. package/scripts/ship.lock.json +3 -3
  5. package/src/create.ts +9 -0
  6. package/src/templates/blog/README.md +4 -3
  7. package/src/templates/blog/agents.md +22 -11
  8. package/src/templates/blog/bun.lock +7 -753
  9. package/src/templates/blog/gitignore +1 -2
  10. package/src/templates/blog/package.json +8 -9
  11. package/src/templates/blog/scripts/build.ts +48 -0
  12. package/src/templates/blog/scripts/preview.ts +42 -0
  13. package/src/templates/blog/serve.ts +11 -0
  14. package/src/templates/blog/src/app.ts +418 -0
  15. package/src/templates/blog/src/layout.html +25 -0
  16. package/src/templates/blog/src/pages/404.html +6 -0
  17. package/src/templates/blog/src/pages/index.html +11 -0
  18. package/src/templates/blog/src/pages/post.html +15 -0
  19. package/src/templates/blog/src/site.ts +7 -2
  20. package/src/templates/blog/tsconfig.json +18 -4
  21. package/src/templates/full-stack/Dockerfile +1 -1
  22. package/src/templates/full-stack/agents.md +1 -1
  23. package/src/templates/full-stack/compose.yaml +5 -2
  24. package/src/templates/full-stack/public/vendor/shibumi.css +15 -3
  25. package/src/templates/full-stack/src/env.ts +1 -1
  26. package/src/templates/ship.ts +50 -15
  27. package/src/templates/static/scripts/preview.ts +1 -1
  28. package/src/templates/blog/astro.config.mjs +0 -13
  29. package/src/templates/blog/src/components/BaseHead.astro +0 -41
  30. package/src/templates/blog/src/content.config.ts +0 -20
  31. package/src/templates/blog/src/layouts/Base.astro +0 -37
  32. package/src/templates/blog/src/pages/404.astro +0 -11
  33. package/src/templates/blog/src/pages/index.astro +0 -33
  34. package/src/templates/blog/src/pages/llms.txt.ts +0 -29
  35. package/src/templates/blog/src/pages/posts/[id].astro +0 -32
  36. package/src/templates/blog/src/pages/posts/[id].md.ts +0 -18
  37. package/src/templates/blog/src/pages/robots.txt.ts +0 -6
  38. package/src/templates/blog/src/pages/rss.xml.ts +0 -21
@@ -1,6 +1,5 @@
1
1
  node_modules/
2
2
  dist/
3
- .astro/
4
3
  .env
5
4
  .env.*
6
- .DS_Store
5
+ .DS_Store
@@ -4,10 +4,11 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "astro dev",
8
- "build": "astro build",
9
- "check": "astro check",
10
- "preview": "astro preview",
7
+ "dev": "bun --hot serve.ts",
8
+ "start": "bun serve.ts",
9
+ "build": "bun scripts/build.ts",
10
+ "preview": "bun scripts/preview.ts",
11
+ "check": "tsc --noEmit",
11
12
  "ship": "bun scripts/ship.ts",
12
13
  "ship:setup": "bun scripts/ship.ts --setup --static --output-dir dist --build-script build --no-spa",
13
14
  "ship:update": "bun scripts/ship.ts --update",
@@ -16,16 +17,14 @@
16
17
  "ship:webhook": "bun scripts/ship.ts --webhook"
17
18
  },
18
19
  "dependencies": {
19
- "@astrojs/rss": "4.0.19",
20
- "@astrojs/sitemap": "3.7.3",
21
- "astro": "7.2.4"
20
+ "hono": "4.13.3"
22
21
  },
23
22
  "devDependencies": {
24
- "@astrojs/check": "0.9.10",
25
23
  "@clack/prompts": "1.7.0",
24
+ "@types/bun": "1.4.0",
26
25
  "typescript": "5.9.2"
27
26
  },
28
27
  "engines": {
29
28
  "bun": ">=1.4.0"
30
29
  }
31
- }
30
+ }
@@ -0,0 +1,48 @@
1
+ // Pre-renders the blog to static files in dist/: the same Hono app that
2
+ // `bun dev` serves, rendered once. Ship packages this output; nothing here
3
+ // runs in production.
4
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import app, { publishedPosts } from "../src/app";
7
+
8
+ const output = "dist";
9
+
10
+ async function write(path: string, body: string | ArrayBuffer): Promise<void> {
11
+ await mkdir(dirname(path), { recursive: true });
12
+ await writeFile(path, body instanceof ArrayBuffer ? new Uint8Array(body) : body);
13
+ }
14
+
15
+ async function responseBody(path: string): Promise<string> {
16
+ const response = await app.request(path);
17
+ if (!response.ok) {
18
+ throw new Error(`cannot build ${path}: HTTP ${response.status}`);
19
+ }
20
+ return response.text();
21
+ }
22
+
23
+ await rm(output, { recursive: true, force: true });
24
+ await cp("public", output, { recursive: true });
25
+
26
+ const posts = await publishedPosts();
27
+ const htmlRoutes = ["/", ...posts.map((post) => `/posts/${post.slug}`)];
28
+ for (const route of htmlRoutes) {
29
+ const path = route === "/" ? join(output, "index.html") : join(output, route.slice(1), "index.html");
30
+ await write(path, await responseBody(route));
31
+ }
32
+
33
+ const notFound = await app.request("/404");
34
+ if (notFound.status !== 404) {
35
+ throw new Error(`cannot build /404: HTTP ${notFound.status}`);
36
+ }
37
+ await write(join(output, "404.html"), await notFound.text());
38
+
39
+ for (const file of ["rss.xml", "llms.txt", "robots.txt", "sitemap.xml"]) {
40
+ await write(join(output, file), await responseBody(`/${file}`));
41
+ }
42
+ for (const post of posts) {
43
+ // Markdown alternates: every post is also served as plain markdown so
44
+ // agents can read the source of the writing (llms.txt links here).
45
+ await write(join(output, "posts", `${post.slug}.md`), await responseBody(`/posts/${post.slug}.md`));
46
+ }
47
+
48
+ console.log(`Built ${htmlRoutes.length} HTML routes + feeds in ${output}/`);
@@ -0,0 +1,42 @@
1
+ // Local preview of dist/ (run `bun run build` first). Production serving is
2
+ // the pinned static image that bun ship builds; this file never ships.
3
+ import { realpathSync } from "node:fs";
4
+ import { join, normalize } from "node:path";
5
+
6
+ const ROOT = join(import.meta.dir, "..", "dist");
7
+ function withHeaders(res: Response): Response {
8
+ res.headers.set("X-Content-Type-Options", "nosniff");
9
+ res.headers.set("X-Frame-Options", "DENY");
10
+ res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
11
+ return res;
12
+ }
13
+ const server = Bun.serve({
14
+ port: Number(process.env.PORT) || 9001,
15
+ async fetch(request) {
16
+ if (request.method !== "GET" && request.method !== "HEAD") return withHeaders(new Response("Not found", { status: 404 }));
17
+ let pathname;
18
+ try {
19
+ pathname = decodeURIComponent(new URL(request.url).pathname);
20
+ } catch {
21
+ return withHeaders(new Response("Bad request", { status: 400 }));
22
+ }
23
+ const safe = normalize(pathname).replaceAll("\\", "/");
24
+ if (safe.includes("..")) return withHeaders(new Response("Not found", { status: 404 }));
25
+ const candidate = safe.endsWith("/") ? join(ROOT, safe, "index.html") : join(ROOT, safe);
26
+ if (!candidate.startsWith(ROOT)) return withHeaders(new Response("Not found", { status: 404 }));
27
+ let resolved;
28
+ try {
29
+ resolved = realpathSync(candidate);
30
+ } catch {
31
+ resolved = undefined;
32
+ }
33
+ // realpath containment: a symlink inside dist/ must not escape it.
34
+ if (resolved && (resolved === realpathSync(ROOT) || resolved.startsWith(realpathSync(ROOT) + "/"))) {
35
+ const file = Bun.file(resolved);
36
+ if (await file.exists()) return withHeaders(new Response(file));
37
+ }
38
+ const notFound = Bun.file(join(ROOT, "404.html"));
39
+ return withHeaders(new Response((await notFound.exists()) ? notFound : "Not found", { status: 404 }));
40
+ },
41
+ });
42
+ console.log(`Previewing dist/ on http://localhost:${server.port}`);
@@ -0,0 +1,11 @@
1
+ import app from "./src/app";
2
+
3
+ const port = Number(process.env.SHIBUMI_PORT ?? process.env.PORT ?? 9001);
4
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
5
+ throw new Error("SHIBUMI_PORT must be an integer from 1 to 65535");
6
+ }
7
+
8
+ export default {
9
+ port,
10
+ fetch: app.fetch,
11
+ };
@@ -0,0 +1,418 @@
1
+ // The blog engine: a small Bun + Hono renderer, the same engine that runs
2
+ // shibumistack.dev. Posts are markdown with YAML frontmatter in
3
+ // src/content/blog/; Bun's built-in markdown renderer turns them into HTML.
4
+ // `bun dev` serves this app live; scripts/build.ts pre-renders the very same
5
+ // routes to dist/, so what you build is what you preview and what ships.
6
+ import { Hono } from "hono";
7
+ import { serveStatic } from "hono/bun";
8
+ import { createHash } from "node:crypto";
9
+ import { readFileSync } from "node:fs";
10
+ import { readdir, stat } from "node:fs/promises";
11
+ import { YAML } from "bun";
12
+ import { SITE } from "./site";
13
+
14
+ const app = new Hono();
15
+
16
+ const slugPattern = /^[a-z0-9][a-z0-9-]*$/;
17
+ const urlBase = SITE.url.replace(/\/$/, "");
18
+
19
+ export type Post = {
20
+ slug: string;
21
+ title: string;
22
+ description: string;
23
+ date: Date;
24
+ ogImage?: string;
25
+ ogImageAlt?: string;
26
+ draft: boolean;
27
+ path: string;
28
+ };
29
+
30
+ const assetVersion = createHash("sha256").update(readFileSync("public/style.css")).digest("hex").slice(0, 12);
31
+
32
+ function escapeHtml(value: string): string {
33
+ return value
34
+ .replaceAll("&", "&amp;")
35
+ .replaceAll("<", "&lt;")
36
+ .replaceAll(">", "&gt;")
37
+ .replaceAll('"', "&quot;")
38
+ .replaceAll("'", "&#39;");
39
+ }
40
+
41
+ function escapeXml(value: string): string {
42
+ return value
43
+ .replaceAll("&", "&amp;")
44
+ .replaceAll("<", "&lt;")
45
+ .replaceAll(">", "&gt;")
46
+ .replaceAll('"', "&quot;");
47
+ }
48
+
49
+ async function read(path: string): Promise<string> {
50
+ return Bun.file(path).text();
51
+ }
52
+
53
+ function parseFrontmatter(text: string): { frontmatter: Record<string, unknown>; body: string } {
54
+ if (!text.startsWith("---")) {
55
+ return { frontmatter: {}, body: text };
56
+ }
57
+ const end = text.indexOf("---", 3);
58
+ if (end === -1) {
59
+ return { frontmatter: {}, body: text };
60
+ }
61
+ return {
62
+ frontmatter: (YAML.parse(text.slice(3, end).trim()) as Record<string, unknown> | undefined) ?? {},
63
+ body: text.slice(end + 3).trimStart(),
64
+ };
65
+ }
66
+
67
+ // The SEO contract, enforced at build and serve time so a bad post fails
68
+ // loudly instead of shipping silently: a title that fits a tab and a
69
+ // description that fits a search snippet, both feeding the meta tags, RSS,
70
+ // and llms.txt. Carried over from the previous template's content schema.
71
+ function validatePost(slug: string, frontmatter: Record<string, unknown>): void {
72
+ const title = frontmatter.title;
73
+ if (typeof title !== "string" || title.length === 0 || title.length > 60) {
74
+ throw new Error(`posts/${slug}.md: title must be a string from 1 to 60 characters`);
75
+ }
76
+ const description = frontmatter.description;
77
+ if (typeof description !== "string" || description.length < 50 || description.length > 160) {
78
+ throw new Error(`posts/${slug}.md: description must be 50 to 160 characters`);
79
+ }
80
+ if (frontmatter.date === undefined || Number.isNaN(new Date(String(frontmatter.date)).getTime())) {
81
+ throw new Error(`posts/${slug}.md: date must be a parseable date`);
82
+ }
83
+ if (frontmatter.ogImage !== undefined && typeof frontmatter.ogImage !== "string") {
84
+ throw new Error(`posts/${slug}.md: ogImage must be a path string`);
85
+ }
86
+ if (frontmatter.ogImageAlt !== undefined && typeof frontmatter.ogImageAlt !== "string") {
87
+ throw new Error(`posts/${slug}.md: ogImageAlt must be a string`);
88
+ }
89
+ }
90
+
91
+ async function discoverPosts(): Promise<Post[]> {
92
+ const dir = "src/content/blog";
93
+ const posts: Post[] = [];
94
+ try {
95
+ if (!(await stat(dir)).isDirectory()) return posts;
96
+ } catch {
97
+ return posts;
98
+ }
99
+
100
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
101
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
102
+
103
+ const slug = entry.name.slice(0, -3);
104
+ if (!slugPattern.test(slug)) {
105
+ throw new Error(`Unsafe file name in ${dir}: ${entry.name}`);
106
+ }
107
+
108
+ const text = await read(`${dir}/${entry.name}`);
109
+ const { frontmatter } = parseFrontmatter(text);
110
+ validatePost(slug, frontmatter);
111
+
112
+ posts.push({
113
+ slug,
114
+ title: String(frontmatter.title),
115
+ description: String(frontmatter.description),
116
+ date: new Date(String(frontmatter.date)),
117
+ ogImage: typeof frontmatter.ogImage === "string" ? frontmatter.ogImage : undefined,
118
+ ogImageAlt: typeof frontmatter.ogImageAlt === "string" ? frontmatter.ogImageAlt : undefined,
119
+ // Draft posts build nowhere: no page, no listing, no RSS, no llms.txt.
120
+ // Flip to false (or remove the line) to publish.
121
+ draft: frontmatter.draft === true,
122
+ path: `${dir}/${entry.name}`,
123
+ });
124
+ }
125
+
126
+ return posts.sort((a, b) => b.date.getTime() - a.date.getTime());
127
+ }
128
+
129
+ export async function publishedPosts(): Promise<Post[]> {
130
+ return (await discoverPosts()).filter((post) => !post.draft);
131
+ }
132
+
133
+ function canonical(path: string): string {
134
+ return path === "/" ? urlBase : `${urlBase}${path.replace(/\/$/, "")}/`;
135
+ }
136
+
137
+ function replaceValueTokens(content: string, vars: Record<string, string>): string {
138
+ for (const [key, value] of Object.entries(vars)) {
139
+ content = content.replaceAll(`{{${key}}}`, escapeHtml(value));
140
+ }
141
+ return content;
142
+ }
143
+
144
+ function assertNoTokens(label: string, content: string): void {
145
+ const unresolved = content.match(/{{[^}]+}}/);
146
+ if (unresolved) {
147
+ throw new Error(`Unresolved token in ${label}: ${unresolved[0]}`);
148
+ }
149
+ }
150
+
151
+ function insert(content: string, name: string, value: string): string {
152
+ return content.replaceAll(`<!-- insert:${name} -->`, value);
153
+ }
154
+
155
+ function assertNoInserts(content: string): void {
156
+ const unresolved = content.match(/<!-- insert:[a-z0-9-]+ -->/);
157
+ if (unresolved) {
158
+ throw new Error(`Unresolved insert: ${unresolved[0]}`);
159
+ }
160
+ }
161
+
162
+ async function renderTokens(label: string, content: string, vars: Record<string, string> = {}): Promise<string> {
163
+ const rendered = replaceValueTokens(content, vars);
164
+ assertNoTokens(label, rendered);
165
+ return rendered;
166
+ }
167
+
168
+ function isSafeHref(href: string): boolean {
169
+ return /^https?:\/\//i.test(href) || /^mailto:/i.test(href) || /^tel:/i.test(href) || /^\//.test(href) || /^#/.test(href);
170
+ }
171
+
172
+ function highlightCode(text: string, language = "text"): string {
173
+ let code = escapeHtml(text);
174
+ const stash: string[] = [];
175
+ const token = (className: string, value: string) => {
176
+ const key = String.fromCodePoint(0xe000 + stash.length);
177
+ stash.push(`<span class="syntax-${className}">${value}</span>`);
178
+ return key;
179
+ };
180
+
181
+ if (["sh", "bash", "shell"].includes(language)) {
182
+ code = code
183
+ .replace(/(^|\s)(#[^\n]*)/gm, (_match, lead, value) => `${lead}${token("comment", value)}`)
184
+ .replace(/(&quot;[^\n]*?&quot;|'[^\n]*?')/g, (value) => token("string", value))
185
+ .replace(/(^|[;&|]\s*)(bun|shis|ssh|sh|git|gh|curl|cd|systemctl|journalctl|podman|npm|docker|brew|mkdir|sudo)(?=\s|$)/gm, (_match, lead, value) => `${lead}${token("command", value)}`)
186
+ .replace(/(^|\s)(--?[a-z][a-z0-9-]*)(?=\s|$)/g, (_match, lead, value) => `${lead}${token("option", value)}`);
187
+ } else if (language === "json") {
188
+ code = code
189
+ .replace(/(&quot;[^&\n]*?&quot;)(\s*:)?/g, (_match, value, colon) => token(colon ? "key" : "string", value) + (colon ?? ""))
190
+ .replace(/\b(true|false|null)\b/g, (value) => token("literal", value))
191
+ .replace(/\b-?\d+(?:\.\d+)?\b/g, (value) => token("number", value));
192
+ } else if (["ts", "typescript", "js", "javascript"].includes(language)) {
193
+ code = code
194
+ .replace(/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/g, (value) => token("comment", value))
195
+ .replace(/(&quot;[^\n]*?&quot;|'[^\n]*?'|`[^\n]*?`)/g, (value) => token("string", value))
196
+ .replace(/\b(import|export|from|const|let|function|async|await|return|if|else|new|throw|type|interface)\b/g, (value) => token("keyword", value));
197
+ } else if (["html", "xml"].includes(language)) {
198
+ code = code
199
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, (value) => token("comment", value))
200
+ .replace(/(&quot;[^\n]*?&quot;)/g, (value) => token("string", value))
201
+ .replace(/(&lt;\/?)([a-zA-Z][a-zA-Z0-9-]*)/g, (_match, lead, name) => `${lead}${token("keyword", name)}`)
202
+ .replace(/([a-zA-Z][a-zA-Z0-9-]*)(=)/g, (_match, attr, eq) => `${token("key", attr)}${eq}`);
203
+ }
204
+
205
+ return code.replace(/[\ue000-\uf8ff]/g, (key) => stash[key.codePointAt(0)! - 0xe000]!);
206
+ }
207
+
208
+ // Safe-by-default markdown: raw HTML is dropped, links must be http(s),
209
+ // mailto, tel, relative, or fragment, and code gets classed spans.
210
+ function safeMarkdownHtml(markdown: string): string {
211
+ return Bun.markdown.render(markdown, {
212
+ html: () => "",
213
+ heading: (children, attrs: { level: number }) => `<h${attrs.level}>${children}</h${attrs.level}>`,
214
+ paragraph: (children) => `<p>${children}</p>`,
215
+ strong: (children) => `<strong>${children}</strong>`,
216
+ emphasis: (children) => `<em>${children}</em>`,
217
+ codespan: (text) => `<code>${escapeHtml(text)}</code>`,
218
+ code: (text, meta?: { language?: string }) => {
219
+ const lang = meta?.language ? ` language="${meta.language}"` : "";
220
+ return `<pre><code${lang}>${highlightCode(text, (meta?.language ?? "text").toLowerCase())}</code></pre>`;
221
+ },
222
+ link: (children, attrs: { href: string }) => (isSafeHref(attrs.href) ? `<a href="${attrs.href}">${children}</a>` : children),
223
+ image: (children, attrs: { src: string }) =>
224
+ isSafeHref(attrs.src) ? `<img src="${attrs.src}" alt="${escapeHtml(children.replace(/<[^>]+>/g, ""))}" loading="lazy">` : "",
225
+ list: (children, attrs: { ordered: boolean }) => {
226
+ const tag = attrs.ordered ? "ol" : "ul";
227
+ return `<${tag}>${children}</${tag}>`;
228
+ },
229
+ listItem: (children) => `<li>${children}</li>`,
230
+ blockquote: (children) => `<blockquote>${children}</blockquote>`,
231
+ });
232
+ }
233
+
234
+ function ogImageFor(post?: Post): string {
235
+ if (post?.ogImage) {
236
+ return post.ogImage.startsWith("/") ? urlBase + post.ogImage : `${urlBase}/${post.ogImage}`;
237
+ }
238
+ return `${urlBase}/og-default.png`;
239
+ }
240
+
241
+ async function metaHtml(title: string, description: string, path: string, post?: Post): Promise<string> {
242
+ const url = canonical(path);
243
+ const image = ogImageFor(post);
244
+ // og:image dimensions are only declared for the default 1200×630 image;
245
+ // a custom ogImage can be any size, so we leave its dimensions to the
246
+ // platform to measure. Image alt comes from the post's ogImageAlt or
247
+ // falls back to the site name; without a custom image there is no alt.
248
+ const defaultImage = post === undefined || post.ogImage === undefined;
249
+ // Alt text for the declared image: the post's ogImageAlt when it has a
250
+ // custom image, the site name for the known default og image, nothing
251
+ // when a custom image ships without an alt to describe it.
252
+ const imageAlt = post?.ogImageAlt ?? (defaultImage ? SITE.name : undefined);
253
+ const twitter = SITE.twitter.replace(/^@/, "");
254
+ const tags = [
255
+ `<meta property="og:type" content="${post ? "article" : "website"}">`,
256
+ `<meta property="og:url" content="${url}">`,
257
+ `<meta property="og:title" content="${escapeHtml(title)}">`,
258
+ `<meta property="og:description" content="${escapeHtml(description)}">`,
259
+ `<meta property="og:image" content="${image}">`,
260
+ ...(defaultImage
261
+ ? [`<meta property="og:image:width" content="1200">`, `<meta property="og:image:height" content="630">`]
262
+ : []),
263
+ ...(imageAlt ? [`<meta property="og:image:alt" content="${escapeHtml(imageAlt)}">`] : []),
264
+ `<meta property="og:locale" content="en_US">`,
265
+ `<meta property="og:site_name" content="${escapeHtml(SITE.name)}">`,
266
+ ...(post ? [`<meta property="article:published_time" content="${post.date.toISOString()}">`] : []),
267
+ `<meta name="twitter:card" content="summary_large_image">`,
268
+ ...(twitter ? [`<meta name="twitter:site" content="${escapeHtml(twitter)}">`] : []),
269
+ `<meta name="twitter:url" content="${url}">`,
270
+ `<meta name="twitter:title" content="${escapeHtml(title)}">`,
271
+ `<meta name="twitter:description" content="${escapeHtml(description)}">`,
272
+ `<meta name="twitter:image" content="${image}">`,
273
+ ...(imageAlt ? [`<meta name="twitter:image:alt" content="${escapeHtml(imageAlt)}">`] : []),
274
+ ...(post ? [`<link rel="alternate" type="text/markdown" href="/posts/${post.slug}.md">`] : []),
275
+ ];
276
+ return tags.join("\n ");
277
+ }
278
+
279
+ async function frame(title: string, description: string, path: string, page: string, post?: Post): Promise<string> {
280
+ let layout = await renderTokens("layout", await read("src/layout.html"), {
281
+ title,
282
+ description,
283
+ author: SITE.author,
284
+ "site-name": SITE.name,
285
+ canonical: canonical(path),
286
+ year: String(new Date().getFullYear()),
287
+ "asset-version": assetVersion,
288
+ });
289
+ layout = insert(layout, "meta", await metaHtml(title, description, path, post));
290
+ layout = insert(layout, "page-style", "");
291
+ layout = insert(layout, "page", page);
292
+ layout = insert(layout, "page-script", "");
293
+ assertNoInserts(layout);
294
+ return layout;
295
+ }
296
+
297
+ async function homeHtml(): Promise<string> {
298
+ const posts = await publishedPosts();
299
+ const items = posts
300
+ .map(
301
+ (post) =>
302
+ `<li><a href="/posts/${post.slug}/"><time datetime="${post.date.toISOString().split("T")[0]}">${post.date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time><h3>${escapeHtml(post.title)}</h3><p>${escapeHtml(post.description)}</p></a></li>`,
303
+ )
304
+ .join("\n ");
305
+
306
+ let page = await renderTokens("home", await read("src/pages/index.html"), { "site-name": SITE.name });
307
+ page = insert(page, "posts", items);
308
+ return frame(SITE.name, SITE.description, "/", page);
309
+ }
310
+
311
+ async function postHtml(slug: string): Promise<string | undefined> {
312
+ const post = (await publishedPosts()).find((candidate) => candidate.slug === slug);
313
+ if (!post) return;
314
+
315
+ const { body } = parseFrontmatter(await read(post.path));
316
+ const dateIso = post.date.toISOString().split("T")[0]!;
317
+ const dateDisplay = post.date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
318
+
319
+ // Neutralize any remaining template tokens that arrived in the markdown
320
+ // itself so a `{{ }}` in a post cannot become markup.
321
+ const renderedBody = safeMarkdownHtml(body).replaceAll("{{", "&#123;&#123;").replaceAll("}}", "&#125;&#125;");
322
+
323
+ let page = await renderTokens("post", await read("src/pages/post.html"), {
324
+ "site-name": SITE.name,
325
+ "date-iso": dateIso,
326
+ date: dateDisplay,
327
+ title: post.title,
328
+ description: post.description,
329
+ });
330
+ page = insert(page, "body", renderedBody);
331
+ return frame(post.title, post.description, `/posts/${post.slug}`, page, post);
332
+ }
333
+
334
+ async function postMarkdown(slug: string): Promise<string | undefined> {
335
+ const post = (await publishedPosts()).find((candidate) => candidate.slug === slug);
336
+ if (!post) return;
337
+ const { body } = parseFrontmatter(await read(post.path));
338
+ return `# ${post.title}\n\n${post.date.toISOString().slice(0, 10)}\n\n${body.trim()}\n`;
339
+ }
340
+
341
+ async function notFoundHtml(): Promise<string> {
342
+ const page = await renderTokens("404", await read("src/pages/404.html"), { "site-name": SITE.name });
343
+ return frame(`Not found · ${SITE.name}`, SITE.description, "/404", page);
344
+ }
345
+
346
+ async function rssXml(): Promise<string> {
347
+ const posts = await publishedPosts();
348
+ const items = posts
349
+ .map((post) => {
350
+ const url = canonical(`/posts/${post.slug}`);
351
+ return [
352
+ " <item>",
353
+ ` <title>${escapeXml(post.title)}</title>`,
354
+ ` <link>${url}</link>`,
355
+ ` <guid>${url}</guid>`,
356
+ ` <pubDate>${post.date.toUTCString()}</pubDate>`,
357
+ ` <description>${escapeXml(post.description)}</description>`,
358
+ " </item>",
359
+ ].join("\n");
360
+ })
361
+ .join("\n");
362
+ return ['<?xml version="1.0" encoding="UTF-8"?>', '<rss version="2.0">', " <channel>", ` <title>${escapeXml(SITE.name)}</title>`, ` <link>${urlBase}/</link>`, ` <description>${escapeXml(SITE.description)}</description>`, " <language>en</language>", items, " </channel>", "</rss>", ""].join("\n");
363
+ }
364
+
365
+ async function llmsTxt(): Promise<string> {
366
+ const posts = await publishedPosts();
367
+ return [
368
+ `# ${SITE.name}`,
369
+ "",
370
+ `> ${SITE.description}`,
371
+ "",
372
+ "Every post is also available as plain markdown at the .md links below.",
373
+ "",
374
+ "## Posts",
375
+ "",
376
+ ...posts.map((post) => `- [${post.title}](${urlBase}/posts/${post.slug}.md): ${post.description}`),
377
+ "",
378
+ ].join("\n");
379
+ }
380
+
381
+ async function robotsTxt(): Promise<string> {
382
+ return `User-agent: *\nAllow: /\n\nSitemap: ${urlBase}/sitemap.xml\n`;
383
+ }
384
+
385
+ async function sitemapXml(): Promise<string> {
386
+ const posts = await publishedPosts();
387
+ const urls = ["/", ...posts.map((post) => `/posts/${post.slug}`)]
388
+ .map((path) => ` <url><loc>${canonical(path)}</loc></url>`)
389
+ .join("\n");
390
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
391
+ }
392
+
393
+ app.get("/", async (c) => c.html(await homeHtml()));
394
+
395
+ app.get("/posts/:slug", async (c) => {
396
+ const slug = c.req.param("slug");
397
+ // Markdown alternates live at the same URL with `.md` appended; the
398
+ // `:slug` param would otherwise swallow them as an unknown post id.
399
+ if (slug.endsWith(".md")) {
400
+ const body = await postMarkdown(slug.slice(0, -3));
401
+ if (body === undefined) return c.notFound();
402
+ return c.body(body, 200, { "content-type": "text/plain; charset=utf-8", "content-disposition": "inline" });
403
+ }
404
+ const html = await postHtml(slug);
405
+ if (html) return c.html(html);
406
+ return c.notFound();
407
+ });
408
+
409
+ app.get("/rss.xml", async (c) => c.body(await rssXml(), 200, { "content-type": "application/rss+xml; charset=utf-8" }));
410
+ app.get("/llms.txt", async (c) => c.body(await llmsTxt(), 200, { "content-type": "text/plain; charset=utf-8" }));
411
+ app.get("/robots.txt", async (c) => c.body(await robotsTxt(), 200, { "content-type": "text/plain; charset=utf-8" }));
412
+ app.get("/sitemap.xml", async (c) => c.body(await sitemapXml(), 200, { "content-type": "application/xml; charset=utf-8" }));
413
+
414
+ app.use("/*", serveStatic({ root: "./public" }));
415
+
416
+ app.notFound(async (c) => c.html(await notFoundHtml(), 404));
417
+
418
+ export default app;
@@ -0,0 +1,25 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="generator" content="Shibumistack.dev v{{generator-version}}">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ <meta name="theme-color" content="#f5f0e4">
9
+ <meta name="color-scheme" content="light dark">
10
+ <meta name="robots" content="index, follow, max-image-preview:large">
11
+ <title>{{title}}</title>
12
+ <meta name="description" content="{{description}}" />
13
+ <meta name="author" content="{{author}}" />
14
+ <link rel="canonical" href="{{canonical}}" />
15
+ <link rel="stylesheet" href="/style.css?v={{asset-version}}" />
16
+ <link rel="alternate" type="application/rss+xml" title="{{site-name}}" href="/rss.xml" />
17
+ <!-- insert:meta -->
18
+ <!-- insert:page-style -->
19
+ </head>
20
+ <body>
21
+ <!-- insert:page -->
22
+ <footer>© {{year}} {{author}} · <a href="/rss.xml">RSS</a> · <a href="/llms.txt">llms.txt</a></footer>
23
+ <!-- insert:page-script -->
24
+ </body>
25
+ </html>
@@ -0,0 +1,6 @@
1
+ <main>
2
+ <article>
3
+ <h1>Not found.</h1>
4
+ <p><a href="/">Back to the start.</a></p>
5
+ </article>
6
+ </main>
@@ -0,0 +1,11 @@
1
+ <main>
2
+ <article>
3
+ <h1 class="home-title">
4
+ <span class="mark-badge">渋み</span>
5
+ <span>{{site-name}}</span>
6
+ </h1>
7
+ <ul class="posts">
8
+ <!-- insert:posts -->
9
+ </ul>
10
+ </article>
11
+ </main>
@@ -0,0 +1,15 @@
1
+ <header>
2
+ <a href="/" class="mark">
3
+ <span class="mark-badge">渋み</span>
4
+ <span>{{site-name}}</span>
5
+ </a>
6
+ </header>
7
+ <main>
8
+ <article>
9
+ <p><time datetime="{{date-iso}}">{{date}}</time></p>
10
+ <h1>{{title}}</h1>
11
+ <p class="lede">{{description}}</p>
12
+ <!-- insert:body -->
13
+ <p class="post-end"><a class="ghost-pill" href="/">← Back to the blog</a></p>
14
+ </article>
15
+ </main>
@@ -1,7 +1,12 @@
1
1
  // One place for site identity. Everything (head tags, RSS, llms.txt,
2
- // footer) reads from here.
2
+ // sitemap, robots) reads from here.
3
3
  export const SITE = {
4
4
  name: "Quiet notes",
5
5
  description: "A blog about building calm, owned software.",
6
6
  author: "Your Name",
7
- };
7
+ // Set to your handle (without "@") to emit twitter:site, e.g. "quietnotes".
8
+ twitter: "",
9
+ // TODO: set your real domain before the first `bun ship`; canonicals,
10
+ // og:url, RSS links, and the sitemap all derive from it.
11
+ url: "https://example.com",
12
+ };
@@ -1,5 +1,19 @@
1
1
  {
2
- "extends": "astro/tsconfigs/strict",
3
- "include": [".astro/types.d.ts", "src/**/*"],
4
- "exclude": ["dist"]
5
- }
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "resolveJsonModule": true,
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "types": [
10
+ "bun-types"
11
+ ],
12
+ "skipLibCheck": true
13
+ },
14
+ "include": [
15
+ "src",
16
+ "serve.ts",
17
+ "scripts"
18
+ ]
19
+ }