create-shibumi 0.2.3 → 0.2.7
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/package.json +3 -2
- package/scripts/ship.lock.json +2 -2
- package/src/templates/full-stack/agents.md +1 -1
- package/src/templates/full-stack/public/app.js +7 -2
- package/src/templates/full-stack/public/style.css +123 -39
- package/src/templates/full-stack/public/vendor/shibumi.css +418 -0
- package/src/templates/full-stack/src/app.ts +54 -9
- package/src/templates/full-stack/src/db/migrations/0002_counter.sql +6 -0
- package/src/templates/full-stack/src/db/schema.ts +8 -0
- package/src/templates/full-stack/test/app.test.ts +36 -5
- package/src/templates/ship.ts +18 -15
- package/src/templates/web/public/style.css +123 -39
- package/src/templates/web/public/vendor/shibumi.css +418 -0
- package/src/templates/web/src/app.ts +17 -7
- package/src/templates/web/test/app.test.ts +12 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { HTTPException } from "hono/http-exception";
|
|
3
3
|
import { serveStatic } from "hono/bun";
|
|
4
|
-
import { desc } from "drizzle-orm";
|
|
4
|
+
import { desc, eq, sql } from "drizzle-orm";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { db } from "./db";
|
|
7
|
-
import { notes } from "./db/schema";
|
|
7
|
+
import { counters, notes } from "./db/schema";
|
|
8
8
|
|
|
9
9
|
// The exact response header set; test/app.test.ts asserts every entry on
|
|
10
10
|
// success, error, API, and static responses. Change both together.
|
|
@@ -67,12 +67,47 @@ export function createApp(): Hono {
|
|
|
67
67
|
return c.json({ notes: rows });
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
// The demo counter is the template's single unauthenticated mutation. It is
|
|
71
|
+
// safe by construction: one shared row, no request body read, value clamped
|
|
72
|
+
// by the schema CHECK, and an in-memory per-IP rate limit. Treat any other
|
|
73
|
+
// mutation route as needing authentication first (bun shi add auth).
|
|
74
|
+
const COUNTER_RATE = 30;
|
|
75
|
+
const COUNTER_WINDOW_MS = 60_000;
|
|
76
|
+
const counterHits = new Map<string, { count: number; resetAt: number }>();
|
|
77
|
+
function counterAllowed(key: string, now = Date.now()): boolean {
|
|
78
|
+
const entry = counterHits.get(key);
|
|
79
|
+
if (!entry || entry.resetAt <= now) {
|
|
80
|
+
if (counterHits.size > 10_000) counterHits.clear();
|
|
81
|
+
counterHits.set(key, { count: 1, resetAt: now + COUNTER_WINDOW_MS });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
entry.count += 1;
|
|
85
|
+
return entry.count <= COUNTER_RATE;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
app.get("/api/counter", async (c) => {
|
|
89
|
+
const row = await db.select().from(counters).where(eq(counters.id, 1)).get();
|
|
90
|
+
return c.json({ count: row?.value ?? 0 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.post("/api/counter", async (c) => {
|
|
94
|
+
const ip = (c.req.header("x-forwarded-for") ?? "local").split(",")[0]!.trim();
|
|
95
|
+
if (!counterAllowed(ip)) return c.json({ error: "Too many increments; try again in a minute." }, 429);
|
|
96
|
+
const [row] = await db
|
|
97
|
+
.update(counters)
|
|
98
|
+
.set({ value: sql`MIN(${counters.value} + 1, 1000000000)` })
|
|
99
|
+
.where(eq(counters.id, 1))
|
|
100
|
+
.returning();
|
|
101
|
+
return c.json({ count: row?.value ?? 0 });
|
|
102
|
+
});
|
|
103
|
+
|
|
70
104
|
const page = `<!doctype html>
|
|
71
105
|
<html lang="en">
|
|
72
106
|
<head>
|
|
73
107
|
<meta charset="utf-8" />
|
|
74
108
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
75
|
-
<title>
|
|
109
|
+
<title>Full Stack app · shibumi</title>
|
|
110
|
+
<link rel="stylesheet" href="/public/vendor/shibumi.css" />
|
|
76
111
|
<link rel="stylesheet" href="/public/style.css" />
|
|
77
112
|
<!-- app.js must load before Alpine so the alpine:init listener exists
|
|
78
113
|
when Alpine starts; both defer, so document order is execution order. -->
|
|
@@ -80,14 +115,24 @@ const page = `<!doctype html>
|
|
|
80
115
|
<script src="/public/vendor/alpine-csp-3.16.2.min.js" defer></script>
|
|
81
116
|
</head>
|
|
82
117
|
<body>
|
|
83
|
-
<main>
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
<
|
|
118
|
+
<main class="scaffold">
|
|
119
|
+
<p class="masthead"><span class="masthead-mark">渋み</span> shibumi full stack</p>
|
|
120
|
+
<h1>Full Stack app</h1>
|
|
121
|
+
<p class="lede">Hono serves the routes, Zod validates every input, Alpine runs the client behavior, and SQLite with Drizzle keeps your data on a volume that survives every deploy.</p>
|
|
122
|
+
<section class="demo" x-data="counter" aria-label="Alpine counter demo">
|
|
87
123
|
<button x-on:click="inc" type="button">Count</button>
|
|
88
|
-
<output x-text="count"
|
|
124
|
+
<output x-text="count">0</output>
|
|
125
|
+
<span class="demo-hint">stored in SQLite; reloads and deploys keep it</span>
|
|
89
126
|
</section>
|
|
90
|
-
<
|
|
127
|
+
<ul class="endpoints">
|
|
128
|
+
<li><a href="/api/hello?name=you"><span class="endpoint-path">GET /api/hello</span><span>query validated with Zod</span></a></li>
|
|
129
|
+
<li><a href="/api/notes"><span class="endpoint-path">GET /api/notes</span><span>Drizzle read from SQLite on the data volume</span></a></li>
|
|
130
|
+
<li><a href="/healthz"><span class="endpoint-path">GET /healthz</span><span>the check every deploy waits for</span></a></li>
|
|
131
|
+
</ul>
|
|
132
|
+
<footer class="colophon">
|
|
133
|
+
<p>This page lives in <code>src/app.ts</code>. House rules for coding agents are in <code>agents.md</code>. Add features with <code>bun shi add auth</code>.</p>
|
|
134
|
+
<p class="colophon-links"><a href="https://shibumistack.dev/docs" rel="noreferrer">shibumi docs</a> · <a href="https://server.shibumistack.dev/docs" rel="noreferrer">server docs</a> · <a href="https://shibumistack.dev/docs/cli/extensions" rel="noreferrer">extensions</a></p>
|
|
135
|
+
</footer>
|
|
91
136
|
</main>
|
|
92
137
|
</body>
|
|
93
138
|
</html>
|
|
@@ -11,3 +11,11 @@ export const notes = sqliteTable("notes", {
|
|
|
11
11
|
.notNull()
|
|
12
12
|
.default(sql`(datetime('now'))`),
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
// One demo counter row (id fixed to 1). The page increments it through
|
|
16
|
+
// POST /api/counter, the only unauthenticated mutation in the template:
|
|
17
|
+
// rate-limited, clamped by a CHECK constraint, and holding no user data.
|
|
18
|
+
export const counters = sqliteTable("counters", {
|
|
19
|
+
id: integer("id").primaryKey(),
|
|
20
|
+
value: integer("value").notNull().default(0),
|
|
21
|
+
});
|
|
@@ -39,7 +39,7 @@ describe("routes", () => {
|
|
|
39
39
|
const res = await req("/");
|
|
40
40
|
expect(res.status).toBe(200);
|
|
41
41
|
const html = await res.text();
|
|
42
|
-
expect(html).toContain("
|
|
42
|
+
expect(html).toContain("Full Stack app");
|
|
43
43
|
expect(html.indexOf("/public/app.js")).toBeLessThan(
|
|
44
44
|
html.indexOf("/public/vendor/alpine-csp-3.16.2.min.js")
|
|
45
45
|
);
|
|
@@ -84,9 +84,17 @@ describe("routes", () => {
|
|
|
84
84
|
probe.get("/__boom", () => {
|
|
85
85
|
throw new Error("test explosion");
|
|
86
86
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// onError logs the exception before answering; silence it so the
|
|
88
|
+
// deliberate explosion does not read as a failure in test output.
|
|
89
|
+
const errorLog = console.error;
|
|
90
|
+
console.error = () => {};
|
|
91
|
+
try {
|
|
92
|
+
const res = await probe.fetch(new Request("http://localhost/__boom"));
|
|
93
|
+
expect(res.status).toBe(500);
|
|
94
|
+
expectSecurityHeaders(res);
|
|
95
|
+
} finally {
|
|
96
|
+
console.error = errorLog;
|
|
97
|
+
}
|
|
90
98
|
});
|
|
91
99
|
});
|
|
92
100
|
|
|
@@ -100,18 +108,41 @@ describe("security", () => {
|
|
|
100
108
|
"GET /",
|
|
101
109
|
"GET /api/hello",
|
|
102
110
|
"GET /api/notes",
|
|
111
|
+
"GET /api/counter",
|
|
112
|
+
"POST /api/counter",
|
|
103
113
|
"GET /healthz",
|
|
104
114
|
].sort()
|
|
105
115
|
);
|
|
106
116
|
});
|
|
107
117
|
|
|
108
|
-
it("rejects every mutation verb on every route", async () => {
|
|
118
|
+
it("rejects every mutation verb on every route except the demo counter", async () => {
|
|
109
119
|
for (const path of ["/", "/api/hello", "/api/notes", "/healthz", "/public/style.css"]) {
|
|
110
120
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
111
121
|
const res = await req(path, { method });
|
|
112
122
|
expect([404, 405]).toContain(res.status);
|
|
113
123
|
}
|
|
114
124
|
}
|
|
125
|
+
for (const method of ["PUT", "PATCH", "DELETE"]) {
|
|
126
|
+
expect([404, 405]).toContain((await req("/api/counter", { method })).status);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("persists counter increments in the database", async () => {
|
|
131
|
+
const before = (await (await req("/api/counter")).json()).count;
|
|
132
|
+
const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.1" } });
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
expect((await res.json()).count).toBe(before + 1);
|
|
135
|
+
// A fresh read (what a reload does) sees the stored value.
|
|
136
|
+
expect((await (await req("/api/counter")).json()).count).toBe(before + 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rate-limits counter increments per IP", async () => {
|
|
140
|
+
let limited = false;
|
|
141
|
+
for (let i = 0; i < 40; i++) {
|
|
142
|
+
const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.2" } });
|
|
143
|
+
if (res.status === 429) { limited = true; break; }
|
|
144
|
+
}
|
|
145
|
+
expect(limited).toBe(true);
|
|
115
146
|
});
|
|
116
147
|
|
|
117
148
|
it("pins a CSP without unsafe-inline or unsafe-eval", () => {
|
package/src/templates/ship.ts
CHANGED
|
@@ -25,7 +25,7 @@ const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
|
|
25
25
|
const COMMIT = /^[a-f0-9]{40}$/;
|
|
26
26
|
const SERVER_CLI = "~/.local/bin/shibumi-server";
|
|
27
27
|
const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
|
|
28
|
-
const CURRENT_SOURCE = "https://shibumistack.dev/ship/
|
|
28
|
+
const CURRENT_SOURCE = "https://shibumistack.dev/ship/v46.ts";
|
|
29
29
|
let sshControlDirectory: string | undefined;
|
|
30
30
|
let sshControlTarget: string | undefined;
|
|
31
31
|
|
|
@@ -1703,13 +1703,13 @@ function portIsBusy(port: number): Promise<boolean> {
|
|
|
1703
1703
|
});
|
|
1704
1704
|
}
|
|
1705
1705
|
|
|
1706
|
-
export function formatDevStartup(port: number, domain: string, time: string, color = false): string {
|
|
1706
|
+
export function formatDevStartup(port: number, domain: string | undefined, time: string, color = false): string {
|
|
1707
1707
|
const paint = (code: string, value: string) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
|
|
1708
1708
|
const row = (label: string, url: string) => `${paint("2", "┃")} ${label.padEnd(8)} ${paint("34", url)}`;
|
|
1709
1709
|
return [
|
|
1710
1710
|
`${paint("38;5;208", "渋み")} ship dev`,
|
|
1711
1711
|
row("Local", `http://localhost:${port}/`),
|
|
1712
|
-
row("Remote", `https://${domain}`),
|
|
1712
|
+
...(domain ? [row("Remote", `https://${domain}`)] : []),
|
|
1713
1713
|
`${paint("2", time)} starting app dev server...`,
|
|
1714
1714
|
].join("\n");
|
|
1715
1715
|
}
|
|
@@ -1719,30 +1719,33 @@ function localTime(date = new Date()): string {
|
|
|
1719
1719
|
}
|
|
1720
1720
|
|
|
1721
1721
|
async function runDev(): Promise<void> {
|
|
1722
|
+
// Dev must work on a fresh scaffold, before any server exists: without
|
|
1723
|
+
// setup, fall back to the Shibumi port convention (registered apps get the
|
|
1724
|
+
// first free port above 9000) and skip the Remote row.
|
|
1722
1725
|
const config = await readConfig();
|
|
1723
|
-
|
|
1724
|
-
if (await portIsBusy(
|
|
1726
|
+
const port = config?.port ?? 9000;
|
|
1727
|
+
if (await portIsBusy(port)) {
|
|
1725
1728
|
const lsof = Bun.which("lsof");
|
|
1726
1729
|
const fuser = Bun.which("fuser");
|
|
1727
|
-
if (!lsof && !fuser) throw new Error(`Port ${
|
|
1730
|
+
if (!lsof && !fuser) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
|
|
1728
1731
|
const found = lsof
|
|
1729
|
-
? await run([lsof, "-nP", `-iTCP:${
|
|
1730
|
-
: await run([fuser!, "-n", "tcp", String(
|
|
1732
|
+
? await run([lsof, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { allowFailure: true })
|
|
1733
|
+
: await run([fuser!, "-n", "tcp", String(port)], { allowFailure: true });
|
|
1731
1734
|
const pids = [...new Set(`${found.stdout} ${found.stderr}`.split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number))];
|
|
1732
|
-
if (pids.length === 0) throw new Error(`Port ${
|
|
1735
|
+
if (pids.length === 0) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
|
|
1733
1736
|
const details = await run(["ps", "-o", "pid=,comm=", "-p", pids.join(",")], { allowFailure: true });
|
|
1734
|
-
log.warn(`Port ${
|
|
1737
|
+
log.warn(`Port ${port} is in use${details.stdout.trim() ? `:\n${details.stdout.trim()}` : ""}`);
|
|
1735
1738
|
const accepted = await confirm({ message: "Stop it and start this project?", initialValue: false });
|
|
1736
1739
|
if (isCancel(accepted) || !accepted) return;
|
|
1737
1740
|
for (const pid of pids) process.kill(pid, "SIGTERM");
|
|
1738
1741
|
const deadline = Date.now() + 5_000;
|
|
1739
|
-
while (await portIsBusy(
|
|
1740
|
-
if (await portIsBusy(
|
|
1742
|
+
while (await portIsBusy(port) && Date.now() < deadline) await Bun.sleep(100);
|
|
1743
|
+
if (await portIsBusy(port)) throw new Error(`Port ${port} did not stop.\n\nNext: stop PID ${pids.join(", ")} manually, then run bun dev again.`);
|
|
1741
1744
|
}
|
|
1742
|
-
process.stdout.write(`${formatDevStartup(
|
|
1745
|
+
process.stdout.write(`${formatDevStartup(port, config?.domain, localTime(), supportsTerminalColor())}\n`);
|
|
1743
1746
|
const child = Bun.spawn([process.execPath, "run", "dev:app"], {
|
|
1744
1747
|
cwd: root,
|
|
1745
|
-
env: { ...process.env, PORT: String(
|
|
1748
|
+
env: { ...process.env, PORT: String(port), SHIBUMI_PORT: String(port) },
|
|
1746
1749
|
stdin: "inherit",
|
|
1747
1750
|
stdout: "inherit",
|
|
1748
1751
|
stderr: "inherit",
|
|
@@ -1808,7 +1811,7 @@ export async function runShip(): Promise<void> {
|
|
|
1808
1811
|
? await confirm({ message: "Ship now?", initialValue: true })
|
|
1809
1812
|
: false;
|
|
1810
1813
|
if (shipNow !== true || isCancel(shipNow)) {
|
|
1811
|
-
outro(`${accent("Next:")} ${result.config.trigger === "github-push" ?
|
|
1814
|
+
outro(`${accent("Next:")} ${result.config.trigger === "github-push" ? `git push origin ${result.config.branch} to deploy` : "bun ship"}`);
|
|
1812
1815
|
return;
|
|
1813
1816
|
}
|
|
1814
1817
|
}
|
|
@@ -1,60 +1,144 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
--ink: #1b130f;
|
|
5
|
-
--accent: #e95f19;
|
|
6
|
-
}
|
|
1
|
+
/* App styles on top of the vendored shibumi.css tokens (public/vendor/).
|
|
2
|
+
Edit freely; this file is yours. Tokens: --paper --ink --muted --line
|
|
3
|
+
--accent, type scale --text-xs..--text-2xl, --font-sans/serif/mono. */
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
--accent: #ff8648;
|
|
13
|
-
}
|
|
5
|
+
.scaffold {
|
|
6
|
+
max-width: 40.625rem;
|
|
7
|
+
margin: 0 auto;
|
|
8
|
+
padding: clamp(3rem, 9vh, 6.5rem) 1.5rem 4rem;
|
|
14
9
|
}
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
.masthead {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: baseline;
|
|
14
|
+
gap: 0.6rem;
|
|
15
|
+
margin: 0 0 3.5rem;
|
|
16
|
+
padding-bottom: 1rem;
|
|
17
|
+
border-bottom: 1px solid var(--line);
|
|
18
|
+
color: var(--muted);
|
|
19
|
+
font-size: var(--text-sm);
|
|
20
|
+
letter-spacing: 0.04em;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
padding: 4rem 1.5rem;
|
|
23
|
+
.masthead-mark {
|
|
24
|
+
color: var(--accent);
|
|
25
|
+
font-size: var(--text-md);
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
h1 {
|
|
32
|
-
|
|
33
|
-
font-
|
|
28
|
+
.scaffold h1 {
|
|
29
|
+
margin: 0 0 0.75rem;
|
|
30
|
+
font-size: clamp(2.6rem, 7vw, 3.8rem);
|
|
31
|
+
font-weight: 450;
|
|
32
|
+
letter-spacing: -0.03em;
|
|
33
|
+
line-height: 1.05;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
.lede {
|
|
37
|
+
margin: 0 0 3rem;
|
|
38
|
+
max-width: 34rem;
|
|
39
|
+
color: var(--muted);
|
|
40
|
+
font-size: var(--text-lg);
|
|
41
|
+
line-height: 1.55;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
.demo {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 1rem;
|
|
48
|
+
margin-bottom: 3.25rem;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
button {
|
|
48
|
-
font:
|
|
49
|
-
padding: 0.
|
|
51
|
+
.demo button {
|
|
52
|
+
font: 500 var(--text-md) var(--font-sans);
|
|
53
|
+
padding: 0.5rem 1.3rem;
|
|
50
54
|
border: 1px solid var(--ink);
|
|
55
|
+
border-radius: 0.35rem;
|
|
51
56
|
background: transparent;
|
|
52
57
|
color: inherit;
|
|
53
|
-
border-radius: 4px;
|
|
54
58
|
cursor: pointer;
|
|
59
|
+
transition: border-color 160ms ease, color 160ms ease;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.demo button:hover,
|
|
63
|
+
.demo button:focus-visible {
|
|
64
|
+
border-color: var(--accent);
|
|
65
|
+
color: var(--accent);
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
output {
|
|
58
|
-
|
|
68
|
+
.demo output {
|
|
69
|
+
min-width: 2ch;
|
|
70
|
+
font-size: var(--text-lg);
|
|
59
71
|
font-variant-numeric: tabular-nums;
|
|
60
72
|
}
|
|
73
|
+
|
|
74
|
+
.demo-hint {
|
|
75
|
+
color: var(--muted);
|
|
76
|
+
font-size: var(--text-sm);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.endpoints {
|
|
80
|
+
margin: 0;
|
|
81
|
+
padding: 0;
|
|
82
|
+
list-style: none;
|
|
83
|
+
border-top: 1px solid var(--line);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main .endpoints a {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: baseline;
|
|
89
|
+
justify-content: space-between;
|
|
90
|
+
gap: 1.5rem;
|
|
91
|
+
padding: 0.9rem 0.25rem;
|
|
92
|
+
border-bottom: 1px solid var(--line);
|
|
93
|
+
text-decoration: none;
|
|
94
|
+
text-decoration-color: transparent;
|
|
95
|
+
transition: background 160ms ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main .endpoints a:hover,
|
|
99
|
+
main .endpoints a:focus-visible {
|
|
100
|
+
background: var(--faint);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.endpoint-path {
|
|
104
|
+
font-family: var(--font-mono);
|
|
105
|
+
font-size: var(--text-sm);
|
|
106
|
+
}
|
|
107
|
+
/* Inline code as rounded chips on the faint layer. */
|
|
108
|
+
.scaffold code {
|
|
109
|
+
padding: 0.12em 0.42em;
|
|
110
|
+
border-radius: 0.35rem;
|
|
111
|
+
background: var(--faint);
|
|
112
|
+
font-family: var(--font-mono);
|
|
113
|
+
font-size: 0.85em;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
main .endpoints a:hover .endpoint-path {
|
|
118
|
+
color: var(--accent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.endpoints span {
|
|
122
|
+
color: var(--muted);
|
|
123
|
+
font-size: var(--text-sm);
|
|
124
|
+
text-align: right;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.colophon {
|
|
128
|
+
margin-top: 3.5rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.colophon p {
|
|
132
|
+
margin: 0 0 0.5rem;
|
|
133
|
+
color: var(--muted);
|
|
134
|
+
font-size: var(--text-sm);
|
|
135
|
+
line-height: 1.7;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.colophon code {
|
|
139
|
+
color: var(--ink);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.colophon-links {
|
|
143
|
+
font-size: var(--text-sm);
|
|
144
|
+
}
|