create-shibumi 0.2.8 → 0.3.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 +17 -4
- package/package.json +2 -2
- package/scripts/ship.lock.json +2 -2
- package/src/adopt.ts +250 -0
- package/src/args.ts +32 -6
- package/src/cli.ts +230 -41
- package/src/templates/blog/agents.md +4 -1
- package/src/templates/blog/bun.lock +1 -0
- package/src/templates/blog/gitignore +2 -0
- package/src/templates/blog/package.json +4 -2
- package/src/templates/blog/public/favicon.svg +1 -0
- package/src/templates/blog/public/style.css +77 -4
- package/src/templates/blog/src/components/BaseHead.astro +4 -0
- package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +7 -5
- package/src/templates/blog/src/content/blog/own-your-source.md +9 -5
- package/src/templates/blog/src/content/blog/writing-for-agents.md +7 -5
- package/src/templates/blog/src/content.config.ts +3 -0
- package/src/templates/blog/src/layouts/Base.astro +12 -1
- package/src/templates/blog/src/pages/index.astro +5 -2
- package/src/templates/blog/src/pages/llms.txt.ts +1 -1
- package/src/templates/blog/src/pages/posts/[id].astro +2 -1
- package/src/templates/blog/src/pages/posts/[id].md.ts +1 -1
- package/src/templates/blog/src/pages/rss.xml.ts +1 -1
- package/src/templates/full-stack/agents.md +1 -0
- package/src/templates/full-stack/package.json +1 -0
- package/src/templates/full-stack/public/favicon.svg +1 -0
- package/src/templates/full-stack/public/style.css +5 -6
- package/src/templates/full-stack/src/app.ts +5 -3
- package/src/templates/ship.ts +551 -153
- package/src/templates/static/agents.md +1 -0
- package/src/templates/{web → static}/bun.lock +1 -21
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +6 -2
- package/src/templates/static/public/favicon.svg +1 -0
- package/src/templates/static/public/index.html +11 -3
- package/src/templates/static/public/style.css +66 -20
- package/src/templates/web/.dockerignore +0 -12
- package/src/templates/web/Dockerfile +0 -24
- package/src/templates/web/README.md +0 -10
- package/src/templates/web/agents.md +0 -54
- package/src/templates/web/compose.yaml +0 -17
- package/src/templates/web/gitignore +0 -5
- package/src/templates/web/package.json +0 -34
- package/src/templates/web/public/app.js +0 -11
- package/src/templates/web/public/style.css +0 -150
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +0 -23
- package/src/templates/web/public/vendor/shibumi.css +0 -422
- package/src/templates/web/src/app.ts +0 -106
- package/src/templates/web/src/env.ts +0 -21
- package/src/templates/web/src/server.ts +0 -23
- package/src/templates/web/test/app.test.ts +0 -145
- package/src/templates/web/tsconfig.json +0 -13
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
|
|
4
|
-
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AdoptError,
|
|
8
|
+
adoptProject,
|
|
9
|
+
dependencyInstall,
|
|
10
|
+
detectBuildOutput,
|
|
11
|
+
loadShipStatic,
|
|
12
|
+
} from "./adopt";
|
|
13
|
+
import {
|
|
14
|
+
HELP_TEXT,
|
|
15
|
+
bunVersionProblem,
|
|
16
|
+
parseArgs,
|
|
17
|
+
validateName,
|
|
18
|
+
type ParsedArgs,
|
|
19
|
+
type TemplateId,
|
|
20
|
+
} from "./args";
|
|
7
21
|
import { CreateError, createProject } from "./create";
|
|
8
22
|
|
|
9
23
|
const VERSION = (
|
|
@@ -37,6 +51,209 @@ async function printInstaller(): Promise<never> {
|
|
|
37
51
|
process.exit(0);
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
|
|
55
|
+
const accent = (value: string) => `\x1b[38;5;208m${value}\x1b[0m`;
|
|
56
|
+
|
|
57
|
+
// Offer the first deployment in the same run. Both entry paths end here, so
|
|
58
|
+
// "Deploy to a VPS now?" means the same thing whether the project was just
|
|
59
|
+
// scaffolded or just adopted.
|
|
60
|
+
async function runShipSetup(dest: string, label: string): Promise<void> {
|
|
61
|
+
const proc = Bun.spawn(["bun", "run", "ship:setup"], {
|
|
62
|
+
cwd: dest,
|
|
63
|
+
stdin: "inherit",
|
|
64
|
+
stdout: "inherit",
|
|
65
|
+
stderr: "inherit",
|
|
66
|
+
});
|
|
67
|
+
const code = await proc.exited;
|
|
68
|
+
if (code !== 0) {
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
`ship:setup did not finish. Your project is intact; run "bun ship:setup" ${label} to retry.\n`
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `bun create shibumi .`: vendor the Ship client into the project that is
|
|
78
|
+
* already here instead of scaffolding a new one. Nothing that exists is
|
|
79
|
+
* overwritten or reinterpreted, and git is never touched.
|
|
80
|
+
*/
|
|
81
|
+
async function adoptExisting(args: ParsedArgs): Promise<void> {
|
|
82
|
+
const root = process.cwd();
|
|
83
|
+
const entries = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.name !== ".git");
|
|
84
|
+
if (entries.length === 0) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`Nothing to adopt: this directory is empty.\nRun bun create shibumi <name> to scaffold a new project.\n`
|
|
87
|
+
);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const packagePath = join(root, "package.json");
|
|
92
|
+
const pkg = (existsSync(packagePath)
|
|
93
|
+
? JSON.parse(readFileSync(packagePath, "utf8"))
|
|
94
|
+
: {}) as {
|
|
95
|
+
scripts?: Record<string, string>;
|
|
96
|
+
dependencies?: Record<string, string>;
|
|
97
|
+
devDependencies?: Record<string, string>;
|
|
98
|
+
};
|
|
99
|
+
// Adopt generates the static image and only that. A start script means
|
|
100
|
+
// something runs in the container, which is ship:setup's job: it asks
|
|
101
|
+
// server-or-static and writes the matching files.
|
|
102
|
+
if (pkg.scripts?.start) {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
[
|
|
105
|
+
`This looks like a server app (package.json has a start script).`,
|
|
106
|
+
`Next: run "bun ship:setup" here; it generates server deployment files.`,
|
|
107
|
+
`Shipping a static build from a project with a start script? Run "bun ship:setup --static --output-dir <dir>".`,
|
|
108
|
+
"",
|
|
109
|
+
].join("\n")
|
|
110
|
+
);
|
|
111
|
+
process.exit(2);
|
|
112
|
+
}
|
|
113
|
+
const detected = detectBuildOutput({
|
|
114
|
+
dependencies: { ...pkg.dependencies, ...pkg.devDependencies },
|
|
115
|
+
files: entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
|
|
116
|
+
directories: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
|
|
117
|
+
});
|
|
118
|
+
// A flat site at the project root has no directory to serve, and the image
|
|
119
|
+
// never packages the whole checkout.
|
|
120
|
+
if (!detected && entries.some((entry) => entry.isFile() && entry.name === "index.html")) {
|
|
121
|
+
process.stderr.write(
|
|
122
|
+
[
|
|
123
|
+
`A static image serves one directory, and index.html is at the project root.`,
|
|
124
|
+
`Next: move the site into public/ (mkdir public && git mv index.html public/), then run bun create shibumi . again.`,
|
|
125
|
+
"",
|
|
126
|
+
].join("\n")
|
|
127
|
+
);
|
|
128
|
+
process.exit(2);
|
|
129
|
+
}
|
|
130
|
+
log.info(
|
|
131
|
+
detected
|
|
132
|
+
? `Existing project found (${detected.framework} detected)`
|
|
133
|
+
: "Existing project found"
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const interactive = !args.yes;
|
|
137
|
+
if (interactive) {
|
|
138
|
+
const proceed = await confirm({
|
|
139
|
+
message: "Add deploy tooling to this project?",
|
|
140
|
+
active: "Yes",
|
|
141
|
+
inactive: "No",
|
|
142
|
+
initialValue: true,
|
|
143
|
+
});
|
|
144
|
+
if (isCancel(proceed) || !proceed) {
|
|
145
|
+
cancel("Cancelled. Nothing was changed.");
|
|
146
|
+
process.exit(130);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ship = await loadShipStatic();
|
|
151
|
+
let outputDir = detected?.outputDir;
|
|
152
|
+
if (interactive) {
|
|
153
|
+
const elsewhere = "";
|
|
154
|
+
if (detected) {
|
|
155
|
+
const choice = await select({
|
|
156
|
+
message: "Built site directory?",
|
|
157
|
+
options: [
|
|
158
|
+
{ value: detected.outputDir, label: `${detected.outputDir}/ ${dim("(detected)")}` },
|
|
159
|
+
{ value: elsewhere, label: "Somewhere else" },
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
if (isCancel(choice)) exitCancelled();
|
|
163
|
+
outputDir = choice === elsewhere ? undefined : choice;
|
|
164
|
+
}
|
|
165
|
+
if (!outputDir) {
|
|
166
|
+
const answer = await text({
|
|
167
|
+
message: "Built site directory?",
|
|
168
|
+
placeholder: detected?.outputDir ?? "dist",
|
|
169
|
+
validate: (value) =>
|
|
170
|
+
value ? ship.staticOutputDirProblem(value) : "Enter the directory your build writes",
|
|
171
|
+
});
|
|
172
|
+
if (isCancel(answer)) exitCancelled();
|
|
173
|
+
outputDir = answer;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!outputDir) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`Could not detect the built site directory.\nRun bun create shibumi . in a terminal to choose one.\n`
|
|
179
|
+
);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let result;
|
|
184
|
+
try {
|
|
185
|
+
result = await adoptProject({
|
|
186
|
+
root,
|
|
187
|
+
outputDir,
|
|
188
|
+
buildScript: typeof pkg.scripts?.build === "string" ? "build" : undefined,
|
|
189
|
+
spa: args.spa,
|
|
190
|
+
ship,
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof AdoptError) {
|
|
194
|
+
process.stderr.write(`${err.message}\n`);
|
|
195
|
+
process.exit(err.exitCode);
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
log.success(`Wrote ${result.written.join(", ")}`);
|
|
201
|
+
if (result.scripts.length > 0) log.success(`Added scripts: ${result.scripts.join(", ")}`);
|
|
202
|
+
if (result.kept.length > 0) log.info(`Left untouched: ${result.kept.join(", ")}`);
|
|
203
|
+
|
|
204
|
+
// The vendored client imports @clack/prompts. A project that already has a
|
|
205
|
+
// node_modules gets no auto-install, so declaring the dependency is not
|
|
206
|
+
// enough: every interactive ship command would die on the missing import.
|
|
207
|
+
if (result.dependency) {
|
|
208
|
+
const install = dependencyInstall(
|
|
209
|
+
entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
|
|
210
|
+
);
|
|
211
|
+
if (install.manual) {
|
|
212
|
+
log.warn(
|
|
213
|
+
`This project has its own lockfile, so nothing was installed here.\nNext: ${install.manual}, then bun ship:setup.`
|
|
214
|
+
);
|
|
215
|
+
} else if (!args.install) {
|
|
216
|
+
log.warn("Run bun install before bun ship:setup; the ship client imports @clack/prompts.");
|
|
217
|
+
} else {
|
|
218
|
+
const s = spinner();
|
|
219
|
+
s.start("Installing @clack/prompts");
|
|
220
|
+
const proc = Bun.spawn(install.command, {
|
|
221
|
+
cwd: root,
|
|
222
|
+
stdin: "ignore",
|
|
223
|
+
stdout: "ignore",
|
|
224
|
+
stderr: "pipe",
|
|
225
|
+
});
|
|
226
|
+
const ok = (await proc.exited) === 0;
|
|
227
|
+
s.stop(ok ? "Installed @clack/prompts" : "Could not install @clack/prompts");
|
|
228
|
+
if (!ok) log.warn("Run bun install before bun ship:setup; the ship client imports @clack/prompts.");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let deployNow = false;
|
|
233
|
+
if (interactive) {
|
|
234
|
+
const answer = await confirm({
|
|
235
|
+
message: "Deploy to a VPS now?",
|
|
236
|
+
active: "Yes",
|
|
237
|
+
inactive: "Later",
|
|
238
|
+
initialValue: false,
|
|
239
|
+
});
|
|
240
|
+
if (isCancel(answer)) exitCancelled();
|
|
241
|
+
deployNow = answer;
|
|
242
|
+
}
|
|
243
|
+
if (deployNow) await runShipSetup(root, "here");
|
|
244
|
+
|
|
245
|
+
log.message(
|
|
246
|
+
[
|
|
247
|
+
deployNow
|
|
248
|
+
? `${accent("next")} bun ship ${dim("deploy your first commit")}`
|
|
249
|
+
: `${accent("next")} bun ship:setup ${dim("connect your VPS when you're ready")}`,
|
|
250
|
+
"",
|
|
251
|
+
dim(`Deployments serve ${outputDir}/. Review the generated Dockerfile and compose.yaml.`),
|
|
252
|
+
].join("\n")
|
|
253
|
+
);
|
|
254
|
+
outro(`Docs: ${accent("https://shibumistack.dev/docs")}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
40
257
|
async function main(): Promise<void> {
|
|
41
258
|
if (process.argv.slice(2).includes("--print-installer")) {
|
|
42
259
|
await printInstaller();
|
|
@@ -65,13 +282,20 @@ async function main(): Promise<void> {
|
|
|
65
282
|
const interactive = !args.yes;
|
|
66
283
|
if (interactive && !process.stdin.isTTY) {
|
|
67
284
|
process.stderr.write(
|
|
68
|
-
|
|
285
|
+
args.adopt
|
|
286
|
+
? `No interactive terminal. Run "bun create shibumi . --yes" to adopt this project with its detected build directory.\n`
|
|
287
|
+
: `No interactive terminal. Use --yes with a project name and --template.\n`
|
|
69
288
|
);
|
|
70
289
|
process.exit(2);
|
|
71
290
|
}
|
|
72
291
|
|
|
73
292
|
intro("渋み shibumi");
|
|
74
293
|
|
|
294
|
+
if (args.adopt) {
|
|
295
|
+
await adoptExisting(args);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
75
299
|
let name: string;
|
|
76
300
|
if (args.name) {
|
|
77
301
|
name = args.name;
|
|
@@ -89,7 +313,6 @@ async function main(): Promise<void> {
|
|
|
89
313
|
if (args.template) {
|
|
90
314
|
template = args.template;
|
|
91
315
|
} else {
|
|
92
|
-
const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
|
|
93
316
|
const detail = (value: string) => `\n ${dim(value)}`;
|
|
94
317
|
const answer = await select({
|
|
95
318
|
message: "What are you shipping?",
|
|
@@ -99,8 +322,8 @@ async function main(): Promise<void> {
|
|
|
99
322
|
label: `Bun full-stack app ${dim("(recommended)")}${detail("Hono, Alpine, and SQLite with migrations and backups")}`,
|
|
100
323
|
},
|
|
101
324
|
{
|
|
102
|
-
value: "
|
|
103
|
-
label: `
|
|
325
|
+
value: "blog" as TemplateId,
|
|
326
|
+
label: `Blog${detail("Astro: posts, RSS, sitemap, SEO meta, llms.txt")}`,
|
|
104
327
|
},
|
|
105
328
|
{
|
|
106
329
|
value: "static" as TemplateId,
|
|
@@ -110,26 +333,6 @@ async function main(): Promise<void> {
|
|
|
110
333
|
});
|
|
111
334
|
if (isCancel(answer)) exitCancelled();
|
|
112
335
|
template = answer as TemplateId;
|
|
113
|
-
if (template === "static") {
|
|
114
|
-
const start = await select({
|
|
115
|
-
message: "Start from?",
|
|
116
|
-
options: [
|
|
117
|
-
{
|
|
118
|
-
value: "static" as TemplateId,
|
|
119
|
-
label: `Plain files${detail("index.html and friends in public/")}`,
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
value: "blog" as TemplateId,
|
|
123
|
-
label: `Astro blog${detail("posts, RSS, sitemap, SEO meta, llms.txt, markdown alternates")}`,
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
});
|
|
127
|
-
if (isCancel(start)) exitCancelled();
|
|
128
|
-
template = start as TemplateId;
|
|
129
|
-
if (template === "static") {
|
|
130
|
-
log.info("Using a generator? Point bun ship:setup at its output directory later.");
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
336
|
}
|
|
134
337
|
|
|
135
338
|
let deployNow = false;
|
|
@@ -189,19 +392,7 @@ async function main(): Promise<void> {
|
|
|
189
392
|
scripts?: Record<string, string>;
|
|
190
393
|
};
|
|
191
394
|
if (pkg.scripts?.["ship:setup"] && existsSync(join(dest, "scripts", "ship.ts"))) {
|
|
192
|
-
|
|
193
|
-
cwd: dest,
|
|
194
|
-
stdin: "inherit",
|
|
195
|
-
stdout: "inherit",
|
|
196
|
-
stderr: "inherit",
|
|
197
|
-
});
|
|
198
|
-
const code = await proc.exited;
|
|
199
|
-
if (code !== 0) {
|
|
200
|
-
process.stderr.write(
|
|
201
|
-
`ship:setup did not finish. Your project is intact; run "bun ship:setup" inside ${name} to retry.\n`
|
|
202
|
-
);
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
395
|
+
await runShipSetup(dest, `inside ${name}`);
|
|
205
396
|
} else {
|
|
206
397
|
process.stdout.write(
|
|
207
398
|
`This template has no ship:setup yet. Run "bun ship:setup" inside ${name} once it does.\n`
|
|
@@ -209,8 +400,6 @@ async function main(): Promise<void> {
|
|
|
209
400
|
}
|
|
210
401
|
}
|
|
211
402
|
|
|
212
|
-
const accent = (value: string) => `\x1b[38;5;208m${value}\x1b[0m`;
|
|
213
|
-
const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
|
|
214
403
|
log.message(
|
|
215
404
|
[
|
|
216
405
|
`${accent("next")} cd ${name}`,
|
|
@@ -10,11 +10,14 @@ bun run build # astro build to dist/
|
|
|
10
10
|
bun run check # astro check (types + templates)
|
|
11
11
|
bun ship:setup # configure the deploy target (preconfigured: static, dist/, build script)
|
|
12
12
|
bun ship # verify dist/, build the static image, upload, deploy
|
|
13
|
+
bun ship:webhook # opt in to push-to-deploy; --off reverses it
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
## Routes and templates
|
|
16
17
|
|
|
17
|
-
- Posts are markdown files in `src/content/blog/`; the schema in `src/content.config.ts` requires title (max 60), description (50 to 160 characters, drives meta/RSS/llms.txt), date, optional ogImage.
|
|
18
|
+
- Posts are markdown files in `src/content/blog/`; the schema in `src/content.config.ts` requires title (max 60), description (50 to 160 characters, drives meta/RSS/llms.txt), date, optional ogImage, and an optional `draft` flag (default false).
|
|
19
|
+
- `draft: true` keeps a post out of everything: no page, no listing, no RSS, no llms.txt, no markdown alternate. Publish by removing the flag and running `bun ship`.
|
|
20
|
+
- Images: put files in `src/assets/` and reference them relatively from the markdown (``); Astro optimizes them at build. Files in `public/` are served verbatim by URL.
|
|
18
21
|
- `src/pages/index.astro` lists posts; `src/pages/posts/[id].astro` renders one; `src/layouts/Base.astro` and `src/components/BaseHead.astro` own the frame and every meta tag.
|
|
19
22
|
- Generated endpoints: `/rss.xml`, `/llms.txt`, `/robots.txt`, and a markdown alternate for every post at `/posts/<id>.md`. All derive from the content collection; never hand-edit their output.
|
|
20
23
|
- Site identity lives in `src/site.ts`. Design lives in `public/style.css` (one file, shibumi tokens, semantic selectors).
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
"check": "astro check",
|
|
10
10
|
"preview": "astro preview",
|
|
11
11
|
"ship": "bun scripts/ship.ts",
|
|
12
|
-
"ship:setup": "bun scripts/ship.ts --setup --static --output-dir dist --build-script build",
|
|
12
|
+
"ship:setup": "bun scripts/ship.ts --setup --static --output-dir dist --build-script build --no-spa",
|
|
13
13
|
"ship:update": "bun scripts/ship.ts --update",
|
|
14
14
|
"ship:status": "bun scripts/ship.ts --status",
|
|
15
|
-
"ship:logs": "bun scripts/ship.ts --logs"
|
|
15
|
+
"ship:logs": "bun scripts/ship.ts --logs",
|
|
16
|
+
"ship:webhook": "bun scripts/ship.ts --webhook"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@astrojs/rss": "4.0.19",
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@astrojs/check": "0.9.10",
|
|
25
|
+
"@clack/prompts": "1.7.0",
|
|
24
26
|
"typescript": "5.9.2"
|
|
25
27
|
},
|
|
26
28
|
"engines": {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><style>text{fill:#e95f19;font-family:"Hiragino Sans","Noto Sans JP",sans-serif}@media (prefers-color-scheme:dark){text{fill:#ff8648}}</style><text x="50" y="55" font-size="62" text-anchor="middle" dominant-baseline="central">渋</text></svg>
|
|
@@ -36,14 +36,27 @@
|
|
|
36
36
|
line-height: 1.6;
|
|
37
37
|
|
|
38
38
|
> header {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
max-width: 40.5rem;
|
|
40
|
+
margin: 0 auto;
|
|
41
|
+
padding-top: 1rem;
|
|
41
42
|
|
|
42
43
|
a {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.7rem;
|
|
43
47
|
color: var(--ink);
|
|
44
|
-
font-
|
|
48
|
+
font-family: ui-serif, Georgia, "Iowan Old Style", Cambria, serif;
|
|
49
|
+
font-size: 1.25rem;
|
|
50
|
+
font-weight: 500;
|
|
51
|
+
letter-spacing: 0.01em;
|
|
45
52
|
text-decoration: none;
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
.mark-badge {
|
|
56
|
+
color: var(--accent);
|
|
57
|
+
font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
|
|
58
|
+
font-size: 1.125rem;
|
|
59
|
+
}
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
> main {
|
|
@@ -53,7 +66,6 @@
|
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
> footer {
|
|
56
|
-
border-top: 1px solid var(--hairline);
|
|
57
69
|
padding-block: 4rem;
|
|
58
70
|
text-align: center;
|
|
59
71
|
color: var(--muted);
|
|
@@ -145,3 +157,64 @@
|
|
|
145
157
|
margin-top: 0;
|
|
146
158
|
}
|
|
147
159
|
}
|
|
160
|
+
|
|
161
|
+
/* Post images: full column width, soft corners, credit line stays quiet. */
|
|
162
|
+
article img {
|
|
163
|
+
display: block;
|
|
164
|
+
width: 100%;
|
|
165
|
+
height: auto;
|
|
166
|
+
border-radius: 0.6rem;
|
|
167
|
+
opacity: 0.9;
|
|
168
|
+
transition: opacity 200ms ease;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
article img:hover,
|
|
172
|
+
article img:focus-visible {
|
|
173
|
+
opacity: 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
article small {
|
|
177
|
+
display: block;
|
|
178
|
+
margin-top: 0.5rem;
|
|
179
|
+
color: var(--muted);
|
|
180
|
+
font-size: 0.8rem;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.home-title {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: baseline;
|
|
186
|
+
gap: 1rem;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.home-title .mark-badge {
|
|
190
|
+
color: var(--accent);
|
|
191
|
+
font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
|
|
192
|
+
font-size: 0.42em;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.post-end {
|
|
196
|
+
margin-top: 4rem;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.ghost-pill {
|
|
200
|
+
display: inline-block;
|
|
201
|
+
padding: 0.5rem 1.3rem;
|
|
202
|
+
border: 1px solid var(--hairline);
|
|
203
|
+
border-radius: 999px;
|
|
204
|
+
color: var(--muted);
|
|
205
|
+
text-decoration: none;
|
|
206
|
+
transition: color 160ms ease, border-color 160ms ease;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.ghost-pill:hover,
|
|
210
|
+
.ghost-pill:focus-visible {
|
|
211
|
+
color: var(--accent);
|
|
212
|
+
border-color: var(--accent);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
article :not(pre) > code {
|
|
216
|
+
padding: 0.12em 0.42em;
|
|
217
|
+
border-radius: 0.35rem;
|
|
218
|
+
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
|
219
|
+
font-size: 0.85em;
|
|
220
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { ClientRouter } from "astro:transitions";
|
|
2
3
|
import { SITE } from "../site";
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
@@ -16,6 +17,9 @@ const isArticle = Boolean(publishedTime);
|
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
<meta charset="utf-8" />
|
|
20
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
21
|
+
<meta name="generator" content="create-shibumi (shibumistack.dev)" />
|
|
22
|
+
<ClientRouter />
|
|
19
23
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
20
24
|
<title>{title}</title>
|
|
21
25
|
<meta name="description" content={description} />
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: One VPS is plenty
|
|
3
|
-
description:
|
|
3
|
+
description: This blog is static files in a 1.4 MB container on a shared five-euro VPS. The deploy script, not the hardware, is what platforms were selling.
|
|
4
4
|
date: 2026-07-15
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
This site is static files behind a 1.4 MB container on a small VPS
|
|
7
|
+
This site is static files behind a 1.4 MB container on a small VPS that also runs four other projects. A five-euro machine serves this traffic without noticing.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
People stayed on platforms for the deploy story: what happens when the build breaks, how to roll back, whether the thing you tested is the thing that shipped.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+

|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
<small>Photo via [Pexels](https://www.pexels.com/photo/289586/)</small>
|
|
14
|
+
|
|
15
|
+
Those answers fit in a script. Build the exact commit, verify the output, upload the image over SSH, health-check before cutover, keep the previous image for rollback. `bun ship` runs all of it, and the script is short enough to read before you trust it.
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Own your source
|
|
3
|
-
description:
|
|
3
|
+
description: This starter copies routes, styles, and config into your repository as plain files. What that costs you, and what it saves, six months in.
|
|
4
4
|
date: 2026-08-01
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This starter does not install itself as a dependency. It copies files into your repository and gets out of the way.
|
|
8
8
|
|
|
9
|
-
The difference shows up six months in.
|
|
9
|
+
The difference shows up six months in. To change behavior that lives in a package, you read someone else's issue tracker and wait for a release. To change behavior that lives in your repository, you edit a file you have already read.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+

|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
<small>Photo via [Pexels](https://www.pexels.com/photo/close-up-of-ink-brushes-6315632/)</small>
|
|
14
|
+
|
|
15
|
+
Generated code earned its bad reputation from generators that produced more code than anyone could maintain. So this one generates little: a route file, a layout, a stylesheet with five variables. Reading all of it takes one sitting.
|
|
16
|
+
|
|
17
|
+
One test tells you whether you own a project: delete the tool that made it. This site still builds, still deploys, and still makes sense with create-shibumi gone.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Writing for readers who curl
|
|
3
|
-
description:
|
|
3
|
+
description: Every post here is served twice, as rendered HTML and as plain markdown, with an llms.txt index. One endpoint covers the agent readers.
|
|
4
4
|
date: 2026-06-20
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Half the readers of this post are agents: HTTP clients with a token budget instead of a viewport.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
So every post here exists twice. There is the HTML you may be reading, and a plain markdown file at the same URL with `.md` appended. An `llms.txt` at the root lists every post with a one-line summary, so an agent can survey the whole site in one small request instead of scraping navigation markup.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+

|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
<small>Photo via [Pexels](https://www.pexels.com/photo/calligraphy-using-a-blank-ink-and-paint-brush-9478289/)</small>
|
|
14
|
+
|
|
15
|
+
The markdown already existed; the build publishes it instead of throwing it away. One endpoint, about a dozen lines of it.
|
|
@@ -11,6 +11,9 @@ const blog = defineCollection({
|
|
|
11
11
|
description: z.string().min(50).max(160),
|
|
12
12
|
date: z.coerce.date(),
|
|
13
13
|
ogImage: z.string().optional(),
|
|
14
|
+
// Draft posts build nowhere: no page, no listing, no RSS, no llms.txt.
|
|
15
|
+
// Flip to false (or remove the line) to publish.
|
|
16
|
+
draft: z.boolean().default(false),
|
|
14
17
|
}),
|
|
15
18
|
});
|
|
16
19
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import BaseHead from "../components/BaseHead.astro";
|
|
3
3
|
import { SITE } from "../site";
|
|
4
4
|
|
|
5
|
+
const isHome = Astro.url.pathname === "/";
|
|
6
|
+
|
|
5
7
|
interface Props {
|
|
6
8
|
title: string;
|
|
7
9
|
description: string;
|
|
@@ -17,7 +19,16 @@ interface Props {
|
|
|
17
19
|
<BaseHead {...Astro.props} />
|
|
18
20
|
</head>
|
|
19
21
|
<body>
|
|
20
|
-
|
|
22
|
+
{
|
|
23
|
+
!isHome && (
|
|
24
|
+
<header>
|
|
25
|
+
<a href="/" class="mark">
|
|
26
|
+
<span class="mark-badge" transition:name="site-mark">渋み</span>
|
|
27
|
+
<span transition:name="site-title">{SITE.name}</span>
|
|
28
|
+
</a>
|
|
29
|
+
</header>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
21
32
|
<main>
|
|
22
33
|
<slot />
|
|
23
34
|
</main>
|
|
@@ -3,7 +3,7 @@ import { getCollection } from "astro:content";
|
|
|
3
3
|
import Base from "../layouts/Base.astro";
|
|
4
4
|
import { SITE } from "../site";
|
|
5
5
|
|
|
6
|
-
const posts = (await getCollection("blog")).sort(
|
|
6
|
+
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
|
|
7
7
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
8
8
|
);
|
|
9
9
|
const formatDate = (date: Date) =>
|
|
@@ -12,7 +12,10 @@ const formatDate = (date: Date) =>
|
|
|
12
12
|
|
|
13
13
|
<Base title={SITE.name} description={SITE.description}>
|
|
14
14
|
<article>
|
|
15
|
-
<h1>
|
|
15
|
+
<h1 class="home-title">
|
|
16
|
+
<span class="mark-badge" transition:name="site-mark">渋み</span>
|
|
17
|
+
<span transition:name="site-title">{SITE.name}</span>
|
|
18
|
+
</h1>
|
|
16
19
|
<ul class="posts">
|
|
17
20
|
{
|
|
18
21
|
posts.map((post) => (
|
|
@@ -5,7 +5,7 @@ import { getCollection } from "astro:content";
|
|
|
5
5
|
import { SITE } from "../site";
|
|
6
6
|
|
|
7
7
|
export const GET: APIRoute = async (context) => {
|
|
8
|
-
const posts = (await getCollection("blog")).sort(
|
|
8
|
+
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
|
|
9
9
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
10
10
|
);
|
|
11
11
|
const lines = [
|
|
@@ -3,7 +3,7 @@ import { getCollection, render } from "astro:content";
|
|
|
3
3
|
import Base from "../../layouts/Base.astro";
|
|
4
4
|
|
|
5
5
|
export async function getStaticPaths() {
|
|
6
|
-
return (await getCollection("blog")).map((post) => ({
|
|
6
|
+
return (await getCollection("blog", ({ data }) => !data.draft)).map((post) => ({
|
|
7
7
|
params: { id: post.id },
|
|
8
8
|
props: { post },
|
|
9
9
|
}));
|
|
@@ -27,5 +27,6 @@ const formatDate = (date: Date) =>
|
|
|
27
27
|
<h1>{post.data.title}</h1>
|
|
28
28
|
<p class="lede">{post.data.description}</p>
|
|
29
29
|
<Content />
|
|
30
|
+
<p class="post-end"><a class="ghost-pill" href="/">← Back to the blog</a></p>
|
|
30
31
|
</article>
|
|
31
32
|
</Base>
|