From 30f24e137ee2d1acec11a0da61e134f512ee02dd Mon Sep 17 00:00:00 2001 From: yyamashita Date: Fri, 19 Jun 2026 15:18:55 +0900 Subject: Initial implementation of microblog Markdown-based personal microblog with REST API. XSS protection via sanitize-html on server-side markdown rendering. Co-Authored-By: Claude Sonnet 4.6 --- app/app.css | 150 +++++++++++++++++++++++++++++++++++++++++++++++ app/lib/db.server.ts | 50 ++++++++++++++++ app/lib/md.server.ts | 33 +++++++++++ app/root.tsx | 46 +++++++++++++++ app/routes.ts | 6 ++ app/routes/api.posts.tsx | 42 +++++++++++++ app/routes/home.tsx | 87 +++++++++++++++++++++++++++ 7 files changed, 414 insertions(+) create mode 100644 app/app.css create mode 100644 app/lib/db.server.ts create mode 100644 app/lib/md.server.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/api.posts.tsx create mode 100644 app/routes/home.tsx (limited to 'app') diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..d289252 --- /dev/null +++ b/app/app.css @@ -0,0 +1,150 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-family: ui-serif, Georgia, 'Times New Roman', Times, serif; + background: #0c0e14; + color: #c9cdd6; + line-height: 1.6; +} + +textarea { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; + font-size: .875rem; + background: #161924; + color: #c9cdd6; + border: 1px solid #2a2f3e; + border-radius: 4px; + padding: .75rem 1rem; + resize: vertical; + width: 100%; + line-height: 1.6; +} +textarea:focus { outline: 1px solid #5c6bc0; border-color: #5c6bc0; } +textarea::placeholder { color: #3d4560; } + +button[type="submit"] { + background: #1e2640; + border: 1px solid #3d4f7c; + color: #8899cc; + border-radius: 4px; + padding: .375rem 1.125rem; + font-size: .8125rem; + font-family: ui-monospace, monospace; + cursor: pointer; + letter-spacing: .02em; +} +button[type="submit"]:hover { background: #253058; color: #aabce0; border-color: #5068a0; } +button[type="submit"]:disabled { opacity: .4; cursor: not-allowed; } + +/* ── Layout ── */ + +.wrap { max-width: 660px; margin: 0 auto; padding: 3rem 1.5rem 5rem; } + +/* ── Header ── */ + +.site-header { margin-bottom: 2.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid #1a1d29; } +.site-header h1 { + font-size: .875rem; + font-weight: 400; + color: #4b5267; + letter-spacing: .12em; + text-transform: lowercase; + font-family: ui-monospace, monospace; +} + +/* ── Compose form ── */ + +.compose { margin-bottom: 2.75rem; } +.compose textarea { margin-bottom: .5rem; min-height: 96px; } +.compose-row { display: flex; justify-content: space-between; align-items: center; } +.hint { font-size: .6875rem; color: #32384d; font-family: ui-monospace, monospace; letter-spacing: .04em; } +.error-msg { font-size: .8125rem; color: #e06c75; margin-top: .375rem; margin-bottom: .375rem; } + +/* ── Feed ── */ + +.feed { display: flex; flex-direction: column; } + +.post { padding: 1.375rem 0; border-top: 1px solid #15182200; } +.feed > .post:first-child { border-top: 1px solid #1a1d29; } +.post + .post { border-top: 1px solid #1a1d29; } +.post:last-child { border-bottom: 1px solid #1a1d29; } + +.post-meta { + display: block; + font-size: .6875rem; + color: #3d4560; + font-family: ui-monospace, monospace; + margin-bottom: .625rem; + letter-spacing: .04em; +} + +/* ── Prose (rendered markdown) ── */ + +.prose { font-size: .9375rem; } +.prose p { margin-bottom: .75rem; color: #c9cdd6; line-height: 1.8; } +.prose p:last-child { margin-bottom: 0; } + +.prose h1 { font-size: 1.25rem; color: #e8eaf0; margin: 1.25rem 0 .5rem; font-weight: 600; } +.prose h2 { font-size: 1.0625rem; color: #e8eaf0; margin: 1.125rem 0 .375rem; font-weight: 600; } +.prose h3 { font-size: .9375rem; color: #dde1ed; margin: 1rem 0 .25rem; font-weight: 600; } +.prose h1:first-child, +.prose h2:first-child, +.prose h3:first-child { margin-top: 0; } + +.prose strong { color: #dde1ed; font-weight: 600; } +.prose em { font-style: italic; } +.prose del { color: #6b7280; } + +.prose a { color: #7aa2f7; } +.prose a:hover { color: #9db8ff; text-decoration: underline; } + +.prose code { + font-family: ui-monospace, monospace; + font-size: .8125rem; + background: #1a1d29; + color: #a9b1d6; + padding: .1em .35em; + border-radius: 3px; +} + +.prose pre { + background: #161924; + border: 1px solid #2a2f3e; + border-radius: 4px; + padding: .875rem 1rem; + overflow-x: auto; + margin-bottom: .75rem; +} +.prose pre code { background: none; padding: 0; color: #a9b1d6; font-size: .8125rem; } + +.prose ul { padding-left: 1.5rem; margin-bottom: .75rem; list-style: disc; } +.prose ol { padding-left: 1.5rem; margin-bottom: .75rem; list-style: decimal; } +.prose li { margin-bottom: .25rem; line-height: 1.75; } + +.prose blockquote { + border-left: 2px solid #2a2f3e; + padding-left: .875rem; + color: #7a8099; + margin-bottom: .75rem; + font-style: italic; +} + +.prose hr { border: none; border-top: 1px solid #1a1d29; margin: 1.25rem 0; } + +.prose table { width: 100%; border-collapse: collapse; font-size: .875rem; margin-bottom: .75rem; } +.prose th, +.prose td { padding: .375rem .75rem; border: 1px solid #2a2f3e; text-align: left; } +.prose th { background: #161924; color: #c9cdd6; } + +.prose img { max-width: 100%; border-radius: 4px; } + +/* ── Empty state ── */ + +.empty { + color: #32384d; + font-size: .875rem; + padding: 2.5rem 0; + text-align: center; + font-style: italic; + font-family: ui-monospace, monospace; +} diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts new file mode 100644 index 0000000..b076ec7 --- /dev/null +++ b/app/lib/db.server.ts @@ -0,0 +1,50 @@ +import Database from "better-sqlite3"; +import path from "path"; + +let db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (!db) { + const dbPath = process.env.DB_PATH ?? path.resolve("microblog.db"); + db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(db); + } + return db; +} + +function initSchema(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC); + `); +} + +export interface Post { + id: string; + content: string; + created_at: string; +} + +export interface CreatePostInput { + id: string; + content: string; +} + +export function listPosts(): Post[] { + return getDb().prepare("SELECT * FROM posts ORDER BY created_at DESC").all() as Post[]; +} + +export function getPostById(id: string): Post | null { + return getDb().prepare("SELECT * FROM posts WHERE id = ?").get(id) as Post | null; +} + +export function createPost(input: CreatePostInput): Post { + getDb().prepare("INSERT INTO posts (id, content) VALUES (?, ?)").run(input.id, input.content); + return getPostById(input.id)!; +} diff --git a/app/lib/md.server.ts b/app/lib/md.server.ts new file mode 100644 index 0000000..8f0d6d6 --- /dev/null +++ b/app/lib/md.server.ts @@ -0,0 +1,33 @@ +import { marked } from "marked"; +import sanitizeHtml from "sanitize-html"; + +const ALLOWED_TAGS = [ + "p", "br", + "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", + "blockquote", + "pre", "code", + "strong", "em", "del", "s", + "a", + "hr", + "table", "thead", "tbody", "tr", "th", "td", + "img", +]; + +const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = { + a: ["href", "title"], + img: ["src", "alt", "title"], + code: ["class"], + td: ["align"], + th: ["align"], +}; + +export function renderMarkdown(content: string): string { + const raw = marked.parse(content, { async: false }) as string; + return sanitizeHtml(raw, { + allowedTags: ALLOWED_TAGS, + allowedAttributes: ALLOWED_ATTRIBUTES, + allowedSchemes: ["http", "https", "mailto"], + allowedSchemesByTag: { img: ["http", "https"] }, + }); +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..80570db --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,46 @@ +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import type { Route } from "./+types/root"; +import "./app.css"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + log + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Error"; + let details = "An unexpected error occurred."; + + if (isRouteErrorResponse(error)) { + message = String(error.status); + details = error.statusText || details; + } else if (import.meta.env.DEV && error instanceof Error) { + details = error.message; + } + + return ( +
+

+ {message}: {details} +

+
+ ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 0000000..e839849 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,6 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("routes/home.tsx"), + route("/api/posts", "routes/api.posts.tsx"), +] satisfies RouteConfig; diff --git a/app/routes/api.posts.tsx b/app/routes/api.posts.tsx new file mode 100644 index 0000000..2d5637c --- /dev/null +++ b/app/routes/api.posts.tsx @@ -0,0 +1,42 @@ +import type { Route } from "./+types/api.posts"; +import { createPost, listPosts } from "~/lib/db.server"; +import { renderMarkdown } from "~/lib/md.server"; + +export async function loader() { + const posts = listPosts(); + return Response.json( + posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) + ); +} + +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + let content: string; + const ct = request.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + const body = (await request.json()) as { content?: unknown }; + content = String(body.content ?? "").trim(); + } else { + const form = await request.formData(); + content = String(form.get("content") ?? "").trim(); + } + + if (!content) { + return Response.json({ error: "content is required" }, { status: 400 }); + } + if (content.length > 10000) { + return Response.json( + { error: "content too long (max 10000 chars)" }, + { status: 400 } + ); + } + + const post = createPost({ id: crypto.randomUUID(), content }); + return Response.json( + { ...post, html: renderMarkdown(post.content) }, + { status: 201 } + ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx new file mode 100644 index 0000000..51d6f04 --- /dev/null +++ b/app/routes/home.tsx @@ -0,0 +1,87 @@ +import { Form, redirect, useNavigation } from "react-router"; +import type { Route } from "./+types/home"; +import { createPost, listPosts } from "~/lib/db.server"; +import { renderMarkdown } from "~/lib/md.server"; + +export async function loader() { + const posts = listPosts(); + return { posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) }; +} + +export async function action({ request }: Route.ActionArgs) { + const form = await request.formData(); + const content = String(form.get("content") ?? "").trim(); + + if (!content) { + return { error: "内容を入力してください", content }; + } + if (content.length > 10000) { + return { error: "投稿が長すぎます(最大10,000文字)", content }; + } + + createPost({ id: crypto.randomUUID(), content }); + return redirect("/"); +} + +export default function Home({ loaderData, actionData }: Route.ComponentProps) { + const { posts } = loaderData; + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + return ( +
+
+

log

+
+ +
+
+