escape jsx strings

AuthorKonata <konata@posteo.jp>
Date
Commit29b661e78340456621106b5c63b44c4e5960a944
Parent97defc1
42 files changed, 643 insertions(+), 211 deletions(-)
Mbun.lock
@@ -24,6 +24,7 @@
2424 },
2525 "devDependencies": {
2626 "@biomejs/biome": "^2.4.7",
27+ "@kitajs/ts-html-plugin": "^4.1.4",
2728 "@simplewebauthn/browser": "^13.3.0",
2829 "@types/argon2": "^0.15.4",
2930 "@types/bun": "latest",
@@ -144,6 +145,8 @@
144145
145146 "@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="],
146147
148+ "@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="],
149+
147150 "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
148151
149152 "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
@@ -218,6 +221,10 @@
218221
219222 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
220223
224+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
225+
226+ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
227+
221228 "argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
222229
223230 "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
@@ -234,10 +241,14 @@
234241
235242 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
236243
244+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
245+
237246 "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
238247
239248 "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
240249
250+ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
251+
241252 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
242253
243254 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -270,8 +281,12 @@
270281
271282 "elysia": ["elysia@1.4.27", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="],
272283
284+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
285+
273286 "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
274287
288+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
289+
275290 "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
276291
277292 "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -280,6 +295,10 @@
280295
281296 "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
282297
298+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
299+
300+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
301+
283302 "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
284303
285304 "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
@@ -416,8 +435,12 @@
416435
417436 "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
418437
438+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
439+
419440 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
420441
442+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
443+
421444 "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
422445
423446 "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -474,10 +497,18 @@
474497
475498 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
476499
500+ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
501+
477502 "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
478503
479504 "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
480505
506+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
507+
508+ "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
509+
510+ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
511+
481512 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
482513
483514 "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
Mpackage.json
@@ -19,6 +19,7 @@
1919 "private": true,
2020 "devDependencies": {
2121 "@biomejs/biome": "^2.4.7",
22+ "@kitajs/ts-html-plugin": "^4.1.4",
2223 "@simplewebauthn/browser": "^13.3.0",
2324 "@types/argon2": "^0.15.4",
2425 "@types/bun": "latest",
Msrc/routes/repos.tsx
@@ -1,4 +1,4 @@
1-import { readFileSync, rmSync } from "node:fs";
1+import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { fileTypeFromBuffer } from "file-type";
@@ -20,7 +20,12 @@ import { db } from "../db/index.ts";
2020 import { contentDisposition } from "../lib/contentDisposition.ts";
2121 import { redirect } from "../lib/redirect.ts";
2222 import { requireAdmin, resolveSession } from "../middleware/session.ts";
23-import { git, repoPath, type TreeEntry } from "../services/git.ts";
23+import {
24+ git,
25+ invalidateRefCache,
26+ repoPath,
27+ type TreeEntry,
28+} from "../services/git.ts";
2429 import {
2530 hasBinaryContent,
2631 prepareDiff,
@@ -1057,6 +1062,90 @@ export const repoRoutes = new Elysia()
10571062 return new Response(null, { status: 302, headers: { Location: "/" } });
10581063 })
10591064
1065+ .post(
1066+ "/:repo/settings/rename",
1067+ async ({ params, body, cookie }) => {
1068+ const user = await resolveSession(cookie.session.value);
1069+ const deny = requireAdmin(user);
1070+ if (deny) return deny;
1071+ const repo = await getRepo(params.repo, true);
1072+ if (!repo) return new Response("Not found", { status: 404 });
1073+
1074+ const oldName = repo.name;
1075+ const newName = body.new_name?.trim() ?? "";
1076+ const confirm = body.confirm_name?.trim() ?? "";
1077+ const back = (msg: string) =>
1078+ redirect(
1079+ `/${oldName}/settings?error=${encodeURIComponent(msg)}`,
1080+ );
1081+
1082+ if (confirm !== oldName) {
1083+ return back("Confirmation did not match the current name.");
1084+ }
1085+ if (newName === oldName) {
1086+ return back("New name is the same as the current name.");
1087+ }
1088+ if (newName.toLowerCase() === oldName.toLowerCase()) {
1089+ return back("Case-only renames are not supported.");
1090+ }
1091+ if (!VALID_REPO_NAME_RE.test(newName)) {
1092+ return back("Invalid repository name.");
1093+ }
1094+
1095+ const clash = await db
1096+ .selectFrom("repositories")
1097+ .select("id")
1098+ .where("name", "=", newName)
1099+ .executeTakeFirst();
1100+ if (clash) return back("Repository name already taken.");
1101+
1102+ const fromPath = repoPath(oldName);
1103+ const toPath = repoPath(newName);
1104+ if (existsSync(toPath)) {
1105+ return back(
1106+ "A directory for that name already exists on disk.",
1107+ );
1108+ }
1109+
1110+ try {
1111+ renameSync(fromPath, toPath);
1112+ } catch (err) {
1113+ console.error(
1114+ `rename ${fromPath} -> ${toPath} failed`,
1115+ err,
1116+ );
1117+ return back("Failed to rename repository on disk.");
1118+ }
1119+
1120+ try {
1121+ await db
1122+ .updateTable("repositories")
1123+ .set({ name: newName })
1124+ .where("id", "=", repo.id)
1125+ .execute();
1126+ } catch (err) {
1127+ console.error("db rename failed; rolling back disk", err);
1128+ try {
1129+ renameSync(toPath, fromPath);
1130+ } catch (rb) {
1131+ console.error("rollback rename failed", rb);
1132+ }
1133+ return back("Failed to update repository record.");
1134+ }
1135+
1136+ invalidateRefCache(oldName);
1137+ return redirect(
1138+ `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
1139+ );
1140+ },
1141+ {
1142+ body: t.Object({
1143+ new_name: t.String(),
1144+ confirm_name: t.String(),
1145+ }),
1146+ },
1147+ )
1148+
10601149 .post(
10611150 "/:repo/settings/labels",
10621151 async ({ params, body, cookie }) => {
Msrc/views/CommentThread.tsx
@@ -45,7 +45,9 @@ export function CommentThread({
4545 version={comment.author_avatar_version}
4646 size={24}
4747 />
48- <strong>{displayName(comment.author_username)}</strong>
48+ <strong safe>
49+ {displayName(comment.author_username)}
50+ </strong>
4951 <div class="timeline-author-right">
5052 {canEditComment(comment) && (
5153 <details class="inline-edit-details">
@@ -76,6 +78,7 @@ export function CommentThread({
7678 name="edit_body"
7779 rows="6"
7880 maxlength={config.MAX_TEXT_BODY_BYTES}
81+ safe
7982 >
8083 {comment.body}
8184 </textarea>
@@ -92,7 +95,7 @@ export function CommentThread({
9295 </div>
9396 )}
9497 <div class="timeline-body markdown-body">
95- {comment.bodyHtml}
98+ {comment.bodyHtml as "safe"}
9699 </div>
97100 <ReactionBar
98101 reactions={commentReactions.get(comment.id) ?? []}
@@ -103,7 +106,7 @@ export function CommentThread({
103106 </div>
104107 ))}
105108
106- {user && (
109+ {!!user && (
107110 <div class="timeline-item timeline-item-new">
108111 <h3 class="section-title">Add a comment</h3>
109112 <form method="POST" action={`${baseUrl}/comments`}>
Msrc/views/DateWithEdited.tsx
@@ -8,12 +8,18 @@ export function DateWithEdited({
88 editedAt: string | null;
99 }) {
1010 const label = formatDate(date);
11- if (!editedAt) return <time datetime={date}>{label}</time>;
11+ if (!editedAt)
12+ return (
13+ <time datetime={date} safe>
14+ {label}
15+ </time>
16+ );
1217 return (
1318 <time
1419 datetime={date}
1520 class="edited-indicator"
1621 title={`Edited ${formatDateTime(editedAt)}`}
22+ safe
1723 >
1824 *{label}
1925 </time>
Msrc/views/DiffView.tsx
@@ -57,7 +57,7 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
5757 <details open>
5858 <summary class="file-nav-dir-toggle">
5959 <span class="file-nav-toggle-icon">▾</span>
60- {name}/
60+ <span safe>{name}/</span>
6161 </summary>
6262 {renderFileTree(node.children)}
6363 </details>
@@ -71,10 +71,13 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
7171 >
7272 <span
7373 class={`file-nav-status file-status-${node.file.status}`}
74+ safe
7475 >
7576 {statusLetter(node.file.status)}
7677 </span>
77- <span class="file-nav-name">{name}</span>
78+ <span class="file-nav-name" safe>
79+ {name}
80+ </span>
7881 <span class="file-nav-stat">
7982 {node.file.isBinary ? (
8083 <span class="nav-binary">
@@ -148,7 +151,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
148151 <>
149152 {files.length > 0 && (
150153 <div class="commit-stats-bar">
151- <span class="commit-stats-text">{changedStr}</span>
154+ <span class="commit-stats-text" safe>{changedStr}</span>
152155 </div>
153156 )}
154157
@@ -185,15 +188,16 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
185188 </span>
186189 <span
187190 class={`diff-status-badge diff-status-${f.status}`}
191+ safe
188192 >
189193 {sl}
190194 </span>
191- <span class="diff-file-path mono">
195+ <span class="diff-file-path mono" safe>
192196 {displayPath}
193197 </span>
194198 {f.status === "renamed" &&
195199 f.oldPath !== f.newPath && (
196- <span class="diff-rename-arrow">
200+ <span class="diff-rename-arrow" safe>
197201 ← {f.oldPath}
198202 </span>
199203 )}
@@ -239,11 +243,12 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
239243 )}
240244 {f.status !== "deleted" && (
241245 <>
242- {sha && (
246+ {!!sha && (
243247 <a
244248 href={`/${repo.name}/blob/${sha}/${displayPath}`}
245249 class="btn btn-xs btn-secondary"
246250 title={`View file at ${sha.slice(0, 7)}`}
251+ safe
247252 >
248253 @ {sha.slice(0, 7)}
249254 </a>
@@ -252,6 +257,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
252257 href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
253258 class="btn btn-xs btn-secondary"
254259 title={`View file at ${repo.default_branch}`}
260+ safe
255261 >
256262 @ {repo.default_branch}
257263 </a>
@@ -278,6 +284,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
278284 <th
279285 colspan="4"
280286 class="diff-hunk-header"
287+ safe
281288 >
282289 {
283290 hunk.header
@@ -310,7 +317,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
310317 </td>
311318 <td class="diff-code">
312319 {
313- row.html
320+ row.html as "safe"
314321 }
315322 </td>
316323 </tr>
Msrc/views/EditableTitle.tsx
@@ -25,7 +25,9 @@ export function EditableTitle({
2525 />
2626 )}
2727 <div class="title-with-edit">
28- <h2 class="issue-detail-title">{title}</h2>
28+ <h2 class="issue-detail-title" safe>
29+ {title}
30+ </h2>
2931 {canEdit && (
3032 <>
3133 <div class="title-edit-form-area">
Msrc/views/LabelBadges.tsx
@@ -25,6 +25,7 @@ export function LabelBadges({
2525 <span
2626 class="label-badge"
2727 style={`background:${label.color};color:${labelTextColor(label.color)}`}
28+ safe
2829 >
2930 {label.name}
3031 </span>
@@ -58,7 +59,7 @@ export function LabelBadges({
5859 >
5960 <select name="label_id" class="label-select">
6061 {available.map((label) => (
61- <option value={String(label.id)}>
62+ <option value={String(label.id)} safe>
6263 {label.name}
6364 </option>
6465 ))}
Msrc/views/ReactionBar.tsx
@@ -25,26 +25,30 @@ export function ReactionBar({
2525 const all = [...ALLOWED_REACTIONS];
2626 return (
2727 <div class="reaction-bar">
28- {reactions.map((r) => (
29- <form method="POST" action={postUrl} class="reaction-form">
30- {commentId != null && (
31- <input
32- type="hidden"
33- name="comment_id"
34- value={String(commentId)}
35- />
36- )}
37- <input type="hidden" name="emoji" value={r.emoji} />
38- <button
39- type="submit"
40- class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
41- disabled={!user}
42- >
43- {r.emoji} {r.count}
44- </button>
45- </form>
46- ))}
47- {user && (
28+ {reactions.map((r) => {
29+ const unsafeEmoji = r.emoji;
30+ return (
31+ <form method="POST" action={postUrl} class="reaction-form">
32+ {commentId != null && (
33+ <input
34+ type="hidden"
35+ name="comment_id"
36+ value={String(commentId)}
37+ />
38+ )}
39+ <input type="hidden" name="emoji" value={r.emoji} />
40+ <button
41+ type="submit"
42+ class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
43+ disabled={!user}
44+ safe
45+ >
46+ {unsafeEmoji + " " + r.count}
47+ </button>
48+ </form>
49+ );
50+ })}
51+ {!!user && (
4852 <details class="reaction-picker">
4953 <summary class="reaction-add-btn">+</summary>
5054 <div class="reaction-picker-dropdown">
@@ -71,6 +75,7 @@ export function ReactionBar({
7175 <button
7276 type="submit"
7377 class="reaction-picker-btn"
78+ safe
7479 >
7580 {e}
7681 </button>
Msrc/views/Settings.tsx
@@ -57,8 +57,16 @@ export function Settings({
5757 <div class="container container-narrow">
5858 <h1 class="page-title">Settings</h1>
5959
60- {successMsg && <p class="form-success">{successMsg}</p>}
61- {error && <p class="form-error">{error}</p>}
60+ {!!successMsg && (
61+ <p class="form-success" safe>
62+ {successMsg}
63+ </p>
64+ )}
65+ {!!error && (
66+ <p class="form-error" safe>
67+ {error}
68+ </p>
69+ )}
6270
6371 {/* Avatar */}
6472 <div class="form-card">
@@ -240,7 +248,7 @@ export function Settings({
240248 <div class="passkey-list">
241249 {passkeys.map((pk) => (
242250 <div class="passkey-item">
243- <span class="passkey-date">
251+ <span class="passkey-date" safe>
244252 Added {formatDate(pk.created_at)}
245253 </span>
246254 <form
@@ -300,13 +308,13 @@ export function Settings({
300308 {sshKeys.map((key) => (
301309 <div class="passkey-item">
302310 <div class="ssh-key-info">
303- <span class="ssh-key-name">
311+ <span class="ssh-key-name" safe>
304312 {key.name}
305313 </span>
306- <span class="passkey-date ssh-key-fingerprint">
314+ <span class="passkey-date ssh-key-fingerprint" safe>
307315 {key.fingerprint}
308316 </span>
309- <span class="passkey-date">
317+ <span class="passkey-date" safe>
310318 Added {formatDate(key.created_at)}
311319 </span>
312320 </div>
@@ -409,17 +417,17 @@ export function Settings({
409417 <li class="queue-item">
410418 <div class="queue-item-meta">
411419 <div class="queue-item-header">
412- <strong>
420+ <strong safe>
413421 {u.username}
414422 </strong>
415- <span class="queue-item-date">
423+ <span class="queue-item-date" safe>
416424 {formatDateTime(
417425 u.created_at,
418426 )}
419427 </span>
420428 </div>
421- {u.register_application && (
422- <p class="queue-item-answer">
429+ {!!u.register_application && (
430+ <p class="queue-item-answer" safe>
423431 {u.register_application}
424432 </p>
425433 )}
Msrc/views/auth/Login.tsx
@@ -9,7 +9,11 @@ export function Login({ error }: LoginProps) {
99 <Layout user={null} title="Sign in">
1010 <div class="auth-container">
1111 <h1 class="page-title">Sign in</h1>
12- {error && <p class="form-error">{error}</p>}
12+ {!!error && (
13+ <p class="form-error" safe>
14+ {error}
15+ </p>
16+ )}
1317 <form method="POST" action="/login" class="auth-form">
1418 <div class="form-group">
1519 <label for="username">Username</label>
Msrc/views/auth/Register.tsx
@@ -29,7 +29,11 @@ export function Register({ error, question, pending }: RegisterProps) {
2929 <Layout user={null} title="Register">
3030 <div class="auth-container">
3131 <h1 class="page-title">Create account</h1>
32- {error && <p class="form-error">{error}</p>}
32+ {!!error && (
33+ <p class="form-error" safe>
34+ {error}
35+ </p>
36+ )}
3337 <form method="POST" action="/register" class="auth-form">
3438 <div class="form-group">
3539 <label for="username">Username</label>
@@ -44,9 +48,11 @@ export function Register({ error, question, pending }: RegisterProps) {
4448 title="Letters, numbers, hyphens and underscores only"
4549 />
4650 </div>
47- {question && (
51+ {!!question && (
4852 <div class="form-group">
49- <label for="application">{question}</label>
53+ <label for="application" safe>
54+ {question}
55+ </label>
5056 <textarea
5157 id="application"
5258 name="application"
Msrc/views/ci/CiHistory.tsx
@@ -90,9 +90,9 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
9090 {CI_VARIABLES.map(([name, desc]) => (
9191 <>
9292 <dt>
93- <code>{name}</code>
93+ <code safe>{name}</code>
9494 </dt>
95- <dd>{desc}</dd>
95+ <dd safe>{desc}</dd>
9696 </>
9797 ))}
9898 </dl>
@@ -100,7 +100,7 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
100100 <div class="ci-help-section">
101101 <h4 class="ci-help-section-title">Status badge</h4>
102102 <p class="ci-help-badge-desc">Embed in your README:</p>
103- <code class="ci-help-badge-code">{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
103+ <code class="ci-help-badge-code" safe>{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
104104 <h4
105105 class="ci-help-section-title"
106106 style="margin-top: var(--space-4)"
@@ -117,9 +117,9 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
117117 ].map(([k, v]) => (
118118 <>
119119 <dt>
120- <code>{k}</code>
120+ <code safe>{k}</code>
121121 </dt>
122- <dd>{v}</dd>
122+ <dd safe>{v}</dd>
123123 </>
124124 ))}
125125 </dl>
@@ -145,13 +145,9 @@ export function CiHistory({
145145 );
146146 return (
147147 <Layout user={user} title={`Pipelines — ${repo.name}`}>
148- {
149- (isRunning ? (
150- <meta http-equiv="refresh" content="4" />
151- ) : (
152- ""
153- )) as unknown as JSX.Element
154- }
148+ {isRunning ? (
149+ <meta http-equiv="refresh" content="4" />
150+ ) : null}
155151 <div class="container">
156152 <RepoHeader repo={repo} />
157153 <RepoNav repo={repo} active="ci" user={user} />
@@ -228,28 +224,27 @@ export function CiHistory({
228224 </span>
229225 </a>
230226 <div class="release-item-meta">
231- {run.commit_sha && (
232- <code class="ci-sha">
227+ {!!run.commit_sha && (
228+ <code class="ci-sha" safe>
233229 {run.commit_sha.slice(0, 8)}
234230 </code>
235231 )}
236- {run.commit_branch && (
237- <span class="badge">
232+ {!!run.commit_branch && (
233+ <span class="badge" safe>
238234 {run.commit_branch}
239235 </span>
240236 )}
241- {run.commit_tag && (
242- <span class="badge">
237+ {!!run.commit_tag && (
238+ <span class="badge" safe>
243239 {run.commit_tag}
244240 </span>
245241 )}
246- <span class="text-muted">
242+ <span class="text-muted" safe>
247243 {run.trigger_source}
248244 </span>
249- {run.triggered_by_username && (
250- <span class="text-muted">
251- by{" "}
252- {run.triggered_by_username}
245+ {!!run.triggered_by_username && (
246+ <span class="text-muted" safe>
247+ by {run.triggered_by_username}
253248 </span>
254249 )}
255250 {run.artifact_count > 0 && (
@@ -261,9 +256,9 @@ export function CiHistory({
261256 : ""}
262257 </span>
263258 )}
264- {run.started_at &&
265- run.finished_at && (
266- <span class="text-muted">
259+ {!!run.started_at &&
260+ !!run.finished_at && (
261+ <span class="text-muted" safe>
267262 {duration(
268263 run.started_at,
269264 run.finished_at,
@@ -273,7 +268,7 @@ export function CiHistory({
273268 </div>
274269 </div>
275270 <div class="release-item-date">
276- <time datetime={run.created_at}>
271+ <time datetime={run.created_at} safe>
277272 {formatDateTime(run.created_at)}
278273 </time>
279274 </div>
Msrc/views/ci/CiRunDetail.tsx
@@ -87,13 +87,9 @@ export function CiRunDetail({
8787
8888 return (
8989 <Layout user={user} title={`Pipeline #${displayId} — ${repo.name}`}>
90- {
91- (isActive && autoRefresh ? (
92- <meta http-equiv="refresh" content="3" />
93- ) : (
94- ""
95- )) as unknown as JSX.Element
96- }
90+ {isActive && autoRefresh ? (
91+ <meta http-equiv="refresh" content="3" />
92+ ) : null}
9793 <div class="container">
9894 <RepoHeader repo={repo} />
9995 <RepoNav repo={repo} active="ci" user={user} />
@@ -108,38 +104,40 @@ export function CiRunDetail({
108104 Pipeline #{displayId}
109105 </h2>
110106 <div class="release-item-meta">
111- {run.commit_sha && (
112- <code class="ci-sha">
107+ {!!run.commit_sha && (
108+ <code class="ci-sha" safe>
113109 {run.commit_sha.slice(0, 8)}
114110 </code>
115111 )}
116- {run.commit_branch && (
112+ {!!run.commit_branch && (
117113 <a
118114 href={`/${repo.name}/tree/${run.commit_branch}`}
119115 class="badge"
116+ safe
120117 >
121118 {run.commit_branch}
122119 </a>
123120 )}
124- {run.commit_tag && (
121+ {!!run.commit_tag && (
125122 <a
126123 href={`/${repo.name}/tree/${run.commit_tag}`}
127124 class="badge"
125+ safe
128126 >
129127 {run.commit_tag}
130128 </a>
131129 )}
132- <span class="text-muted">
130+ <span class="text-muted" safe>
133131 triggered by {run.trigger_source}
134- {run.triggered_by_username &&
132+ {!!run.triggered_by_username &&
135133 ` (${run.triggered_by_username})`}
136134 </span>
137- {run.started_at && run.finished_at && (
138- <span class="text-muted">
135+ {!!run.started_at && !!run.finished_at && (
136+ <span class="text-muted" safe>
139137 {duration(run.started_at, run.finished_at)}
140138 </span>
141139 )}
142- <time datetime={run.created_at} class="text-muted">
140+ <time datetime={run.created_at} class="text-muted" safe>
143141 {formatDateTime(run.created_at)}
144142 </time>
145143 </div>
@@ -194,9 +192,9 @@ export function CiRunDetail({
194192 {Object.entries(variableOverrides).map(([k, v]) => (
195193 <>
196194 <dt>
197- <code>{k}</code>
195+ <code safe>{k}</code>
198196 </dt>
199- <dd>{v}</dd>
197+ <dd safe>{v}</dd>
200198 </>
201199 ))}
202200 </dl>
@@ -221,11 +219,11 @@ export function CiRunDetail({
221219 >
222220 <summary class="ci-step-summary">
223221 <CiStatusPill status={step.status} />
224- <span class="ci-step-name">
222+ <span class="ci-step-name" safe>
225223 {step.name}
226224 </span>
227- {step.started_at && step.finished_at && (
228- <span class="ci-step-duration text-muted">
225+ {!!step.started_at && !!step.finished_at && (
226+ <span class="ci-step-duration text-muted" safe>
229227 {duration(
230228 step.started_at,
231229 step.finished_at,
@@ -234,7 +232,9 @@ export function CiRunDetail({
234232 )}
235233 </summary>
236234 {step.log ? (
237- <pre class="ci-step-log">{step.log}</pre>
235+ <pre class="ci-step-log" safe>
236+ {step.log}
237+ </pre>
238238 ) : step.status === "running" ? (
239239 <div class="ci-step-running-indicator text-muted">
240240 Running…
@@ -254,10 +254,11 @@ export function CiRunDetail({
254254 <a
255255 href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
256256 class="ci-artifact-name"
257+ safe
257258 >
258259 {artifact.filename}
259260 </a>
260- <span class="ci-artifact-size text-muted">
261+ <span class="ci-artifact-size text-muted" safe>
261262 {formatBytes(artifact.size)}
262263 </span>
263264 </li>
Msrc/views/ci/CiStatusPill.tsx
@@ -16,7 +16,7 @@ interface CiStatusPillProps {
1616 export function CiStatusPill({ status, title }: CiStatusPillProps) {
1717 const cls = statusStyles[status] ?? "ci-status-pending";
1818 return (
19- <span class={`ci-status-pill ${cls}`} title={title}>
19+ <span class={`ci-status-pill ${cls}`} title={title} safe>
2020 {status}
2121 </span>
2222 );
Msrc/views/issues/IssueDetail.tsx
@@ -71,7 +71,7 @@ export function IssueDetail({
7171 bodyFieldName="edit_body"
7272 bodyValue={issue.body}
7373 />
74- <span class={`issue-badge ${issue.status}`}>
74+ <span class={`issue-badge ${issue.status}`} safe>
7575 {issue.status}
7676 </span>
7777 {canEditIssue && (
@@ -144,7 +144,7 @@ export function IssueDetail({
144144 version={issue.author_avatar_version}
145145 size={24}
146146 />
147- <strong>
147+ <strong safe>
148148 {displayName(issue.author_username)}
149149 </strong>
150150 <div class="timeline-author-right">
@@ -187,6 +187,7 @@ export function IssueDetail({
187187 maxlength={
188188 config.MAX_TEXT_BODY_BYTES
189189 }
190+ safe
190191 >
191192 {issue.body}
192193 </textarea>
@@ -203,7 +204,7 @@ export function IssueDetail({
203204 </div>
204205 )}
205206 <div class="timeline-body markdown-body">
206- {bodyHtml || (
207+ {(bodyHtml as "safe") || (
207208 <em class="text-muted">
208209 No description provided.
209210 </em>
Msrc/views/issues/IssueList.tsx
@@ -115,6 +115,7 @@ export function IssueList({
115115 <span
116116 class="label-badge"
117117 style={`background:${label.color};color:${labelTextColor(label.color)}`}
118+ safe
118119 >
119120 {label.name}
120121 </span>
@@ -139,7 +140,7 @@ export function IssueList({
139140 </div>
140141 </details>
141142 )}
142- {user && (
143+ {!!user && (
143144 <a
144145 href={`/${repo.name}/issues/new`}
145146 class="btn btn-primary btn-sm"
@@ -157,6 +158,9 @@ export function IssueList({
157158 <ul class="issue-list">
158159 {issues.map((issue) => {
159160 const labels = labelsByIssueId.get(issue.id) ?? [];
161+ const unsafeAuthor = displayName(
162+ issue.author_username,
163+ );
160164 return (
161165 <li class="issue-item">
162166 <div class="issue-main">
@@ -166,6 +170,7 @@ export function IssueList({
166170 <a
167171 href={`/${repo.name}/issues/${issue.number}`}
168172 class="issue-title"
173+ safe
169174 >
170175 {issue.title}
171176 </a>
@@ -179,6 +184,7 @@ export function IssueList({
179184 <span
180185 class="label-badge"
181186 style={`background:${label.color};color:${labelTextColor(label.color)}`}
187+ safe
182188 >
183189 {label.name}
184190 </span>
@@ -193,11 +199,10 @@ export function IssueList({
193199 }
194200 size={20}
195201 />
196- <span class="issue-author">
197- opened by{" "}
198- {displayName(issue.author_username)}
202+ <span class="issue-author" safe>
203+ opened by {unsafeAuthor}
199204 </span>
200- <time datetime={issue.created_at}>
205+ <time datetime={issue.created_at} safe>
201206 {formatDate(issue.created_at)}
202207 </time>
203208 </div>
Msrc/views/issues/NewIssue.tsx
@@ -27,7 +27,11 @@ export function NewIssue({
2727 <RepoHeader repo={repo} />
2828 <RepoNav repo={repo} active="issues" user={user} />
2929 <h2 class="section-title">New issue</h2>
30- {error && <p class="form-error">{error}</p>}
30+ {!!error && (
31+ <p class="form-error" safe>
32+ {error}
33+ </p>
34+ )}
3135 <form
3236 method="POST"
3337 action={`/${repo.name}/issues`}
@@ -57,6 +61,7 @@ export function NewIssue({
5761 rows="10"
5862 maxlength={config.MAX_TEXT_BODY_BYTES}
5963 placeholder="Describe the issue..."
64+ safe
6065 >
6166 {template ?? ""}
6267 </textarea>
@@ -76,6 +81,7 @@ export function NewIssue({
7681 <span
7782 class="label-badge"
7883 style={`background:${label.color};color:${labelTextColor(label.color)}`}
84+ safe
7985 >
8086 {label.name}
8187 </span>
Msrc/views/layout.tsx
@@ -1,3 +1,4 @@
1+import type { Children } from "@kitajs/html";
12 import config from "../config.ts";
23 import { displayName } from "../lib/users.ts";
34 import type { SessionUser } from "../middleware/session.ts";
@@ -6,7 +7,7 @@ import { Avatar } from "./Avatar.tsx";
67 interface LayoutProps {
78 user: SessionUser | null;
89 title?: string;
9- children?: JSX.Element | JSX.Element[] | string | null;
10+ children: Children;
1011 }
1112
1213 export function Layout({ user, title, children }: LayoutProps) {
@@ -16,7 +17,7 @@ export function Layout({ user, title, children }: LayoutProps) {
1617 <head>
1718 <meta charset="UTF-8" />
1819 <meta name="viewport" content="width=500" />
19- <title>{pageTitle}</title>
20+ <title safe>{pageTitle}</title>
2021 <script src="/assets/theme.js"></script>
2122 <link
2223 rel="icon"
@@ -48,7 +49,9 @@ export function Layout({ user, title, children }: LayoutProps) {
4849 version={user.avatar_version}
4950 size={24}
5051 />
51- {displayName(user.username)}
52+ <span safe>
53+ {displayName(user.username)}
54+ </span>
5255 </a>
5356 <form
5457 method="POST"
@@ -81,7 +84,9 @@ export function Layout({ user, title, children }: LayoutProps) {
8184 </header>
8285 <main class="site-main">{children}</main>
8386 <footer class="site-footer">
84- <p>Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}</p>
87+ <p safe>
88+ Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}
89+ </p>
8590 </footer>
8691 </body>
8792 </html>
Msrc/views/patches/NewPatch.tsx
@@ -27,7 +27,11 @@ export function NewPatch({
2727 <RepoHeader repo={repo} />
2828 <RepoNav repo={repo} active="patches" user={user} />
2929 <h2 class="section-title">Upload patch</h2>
30- {error && <p class="form-error">{error}</p>}
30+ {!!error && (
31+ <p class="form-error" safe>
32+ {error}
33+ </p>
34+ )}
3135 <form
3236 method="POST"
3337 action={`/${repo.name}/patches`}
@@ -57,6 +61,7 @@ export function NewPatch({
5761 name="description"
5862 rows="5"
5963 maxlength={config.MAX_TEXT_BODY_BYTES}
64+ safe
6065 >
6166 {template ?? ""}
6267 </textarea>
@@ -88,6 +93,7 @@ export function NewPatch({
8893 <span
8994 class="label-badge"
9095 style={`background:${label.color};color:${labelTextColor(label.color)}`}
96+ safe
9197 >
9298 {label.name}
9399 </span>
Msrc/views/patches/PatchDetail.tsx
@@ -91,7 +91,7 @@ export function PatchDetail({
9191 bodyFieldName="edit_description"
9292 bodyValue={patch.description}
9393 />
94- <span class={`patch-badge ${patch.status}`}>
94+ <span class={`patch-badge ${patch.status}`} safe>
9595 {patch.status}
9696 </span>
9797 {canEdit && (
@@ -225,7 +225,7 @@ export function PatchDetail({
225225 version={patch.author_avatar_version}
226226 size={24}
227227 />
228- <strong>
228+ <strong safe>
229229 {displayName(patch.author_username)}
230230 </strong>
231231 <div class="timeline-author-right">
@@ -268,6 +268,7 @@ export function PatchDetail({
268268 maxlength={
269269 config.MAX_TEXT_BODY_BYTES
270270 }
271+ safe
271272 >
272273 {patch.description}
273274 </textarea>
@@ -284,7 +285,7 @@ export function PatchDetail({
284285 </div>
285286 )}
286287 <div class="timeline-body markdown-body">
287- {descriptionHtml || (
288+ {(descriptionHtml as "safe") || (
288289 <em class="text-muted">
289290 No description provided.
290291 </em>
@@ -298,7 +299,7 @@ export function PatchDetail({
298299 </div>
299300
300301 {/* Apply status */}
301- {applyResult && (
302+ {!!applyResult && (
302303 <div class="timeline-item">
303304 <div
304305 class={`apply-result apply-${applyResult.status}`}
@@ -315,8 +316,8 @@ export function PatchDetail({
315316 : "Has conflicts"}
316317 </span>
317318 </div>
318- {applyResult.output && (
319- <pre class="apply-output">
319+ {!!applyResult.output && (
320+ <pre class="apply-output" safe>
320321 {applyResult.output}
321322 </pre>
322323 )}
@@ -342,7 +343,7 @@ export function PatchDetail({
342343 ? escapeHtml(patchMeta.subject)
343344 : "No commit message"}
344345 </h2>
345- {patchMeta.body && (
346+ {!!patchMeta.body && (
346347 <pre class="commit-card-body">
347348 {escapeHtml(patchMeta.body)}
348349 </pre>
@@ -363,6 +364,7 @@ export function PatchDetail({
363364 <time
364365 class="commit-meta-value"
365366 datetime={patchMeta.date}
367+ safe
366368 >
367369 {formatDateTime(patchMeta.date)}
368370 </time>
Msrc/views/patches/PatchList.tsx
@@ -113,6 +113,7 @@ export function PatchList({
113113 <span
114114 class="label-badge"
115115 style={`background:${label.color};color:${labelTextColor(label.color)}`}
116+ safe
116117 >
117118 {label.name}
118119 </span>
@@ -137,7 +138,7 @@ export function PatchList({
137138 </div>
138139 </details>
139140 )}
140- {user && (
141+ {!!user && (
141142 <a
142143 href={`/${repo.name}/patches/new`}
143144 class="btn btn-primary btn-sm"
@@ -149,12 +150,15 @@ export function PatchList({
149150 </div>
150151 {patches.length === 0 ? (
151152 <div class="empty-state">
152- <p>No {status} patches.</p>
153+ <p safe>No {status} patches.</p>
153154 </div>
154155 ) : (
155156 <ul class="issue-list">
156157 {patches.map((patch) => {
157158 const labels = labelsByPatchId.get(patch.id) ?? [];
159+ const unsafeAuthor = displayName(
160+ patch.author_username,
161+ );
158162 return (
159163 <li class="issue-item">
160164 <div class="issue-main">
@@ -164,6 +168,7 @@ export function PatchList({
164168 <a
165169 href={`/${repo.name}/patches/${patch.number}`}
166170 class="issue-title"
171+ safe
167172 >
168173 {patch.title}
169174 </a>
@@ -177,6 +182,7 @@ export function PatchList({
177182 <span
178183 class="label-badge"
179184 style={`background:${label.color};color:${labelTextColor(label.color)}`}
185+ safe
180186 >
181187 {label.name}
182188 </span>
@@ -191,11 +197,8 @@ export function PatchList({
191197 }
192198 size={20}
193199 />
194- <span>
195- by{" "}
196- {displayName(patch.author_username)}
197- </span>
198- <time datetime={patch.created_at}>
200+ <span safe>by {unsafeAuthor}</span>
201+ <time datetime={patch.created_at} safe>
199202 {formatDate(patch.created_at)}
200203 </time>
201204 </div>
Msrc/views/releases/NewRelease.tsx
@@ -30,7 +30,11 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
3030 <RepoNav repo={repo} active="releases" user={user} />
3131 <div class="form-page">
3232 <h2 class="page-title">New Release</h2>
33- {error && <div class="form-error">{error}</div>}
33+ {!!error && (
34+ <div class="form-error" safe>
35+ {error}
36+ </div>
37+ )}
3438 <form
3539 method="post"
3640 action={`/${repo.name}/releases`}
@@ -115,6 +119,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
115119 class="form-input form-textarea"
116120 rows="8"
117121 maxlength={config.MAX_TEXT_BODY_BYTES}
122+ safe
118123 >
119124 {values?.notes ?? ""}
120125 </textarea>
Msrc/views/releases/ReleaseDetail.tsx
@@ -55,29 +55,31 @@ export function ReleaseDetail({
5555 <h2
5656 class="page-title"
5757 style={`view-transition-name: release-title-${release.id}`}
58+ safe
5859 >
5960 {release.name}
6061 </h2>
6162 </div>
6263 <div class="release-item-date">
63- {release.tag_name && (
64+ {!!release.tag_name && (
6465 <a
6566 href={`/${repo.name}/tree/${release.tag_name}`}
6667 class="badge"
68+ safe
6769 >
6870 {release.tag_name}
6971 </a>
7072 )}
71- <time datetime={release.created_at}>
73+ <time datetime={release.created_at} safe>
7274 {formatDate(release.created_at)}
7375 </time>
7476 </div>
7577 </div>
7678 </div>
7779
78- {notesHtml && (
80+ {!!notesHtml && (
7981 <div class="markdown-body release-notes">
80- {notesHtml}
82+ {notesHtml as "safe"}
8183 </div>
8284 )}
8385
@@ -90,10 +92,11 @@ export function ReleaseDetail({
9092 <a
9193 href={`/${repo.name}/releases/${release.id}/assets/${asset.filename}`}
9294 class="asset-name"
95+ safe
9396 >
9497 {asset.filename}
9598 </a>
96- <span class="asset-size">
99+ <span class="asset-size" safe>
97100 {formatBytes(asset.size)}
98101 </span>
99102 </li>
@@ -103,13 +106,14 @@ export function ReleaseDetail({
103106 <a
104107 href={`/${repo.name}/releases/${release.id}/source/${archive.filename}`}
105108 class="asset-name"
109+ safe
106110 >
107111 {archive.filename}
108112 </a>
109- <span class="asset-meta">
113+ <span class="asset-meta" safe>
110114 Source code ({archive.format})
111115 </span>
112- <span class="asset-size">
116+ <span class="asset-size" safe>
113117 {formatBytes(archive.size)}
114118 </span>
115119 </li>
@@ -130,7 +134,10 @@ export function ReleaseDetail({
130134 )
131135 .map(([format, ext]) => (
132136 <li class="asset-item asset-item-pending">
133- <span class="asset-name asset-name-pending">
137+ <span
138+ class="asset-name asset-name-pending"
139+ safe
140+ >
134141 {`${repo.name}-${release.tag_name}${ext}`}
135142 </span>
136143 <span class="asset-meta">
Msrc/views/releases/ReleaseList.tsx
@@ -22,10 +22,12 @@ function NotesPreview({ notes }: { notes: string }) {
2222 return (
2323 <details class="notes-expand">
2424 <summary class="notes-toggle">
25- <div class="notes-preview">{plaintext}</div>
25+ <div class="notes-preview" safe>
26+ {plaintext}
27+ </div>
2628 <span class="notes-toggle-label" />
2729 </summary>
28- <div class="notes-full markdown-body">{renderMarkdown(notes)}</div>
30+ <div class="notes-full markdown-body">{renderMarkdown(notes) as "safe"}</div>
2931 </details>
3032 );
3133 }
@@ -66,6 +68,7 @@ export function ReleaseList({
6668 href={`/${repo.name}/releases/${release.id}`}
6769 class="release-item-title"
6870 style={`view-transition-name: release-title-${release.id}`}
71+ safe
6972 >
7073 {release.name}
7174 </a>
@@ -85,20 +88,21 @@ export function ReleaseList({
8588 </div>
8689 </div>
8790 <div class="release-item-date">
88- {release.tag_name && (
91+ {!!release.tag_name && (
8992 <a
9093 href={`/${repo.name}/tree/${release.tag_name}`}
9194 class="badge"
95+ safe
9296 >
9397 {release.tag_name}
9498 </a>
9599 )}
96- <time datetime={release.created_at}>
100+ <time datetime={release.created_at} safe>
97101 {formatDate(release.created_at)}
98102 </time>
99103 </div>
100104 </div>
101- {release.notes && (
105+ {!!release.notes && (
102106 <div class="release-notes-section">
103107 <p class="release-notes-label">
104108 Release Notes
Msrc/views/repos/BranchList.tsx
@@ -32,8 +32,16 @@ export function BranchList({
3232 <div class="container">
3333 <RepoHeader repo={repo} />
3434 <RepoNav repo={repo} active="branches" user={user} />
35- {success && <p class="form-success">{success}</p>}
36- {error && <p class="form-error">{error}</p>}
35+ {!!success && (
36+ <p class="form-success" safe>
37+ {success}
38+ </p>
39+ )}
40+ {!!error && (
41+ <p class="form-error" safe>
42+ {error}
43+ </p>
44+ )}
3745 <div class="list-header">
3846 <h2 class="list-heading">Branches</h2>
3947 {user?.isAdmin && (
@@ -97,6 +105,7 @@ export function BranchList({
97105 <a
98106 href={`/${repo.name}/tree/${b.name}`}
99107 class="ref-name"
108+ safe
100109 >
101110 {b.name}
102111 </a>
@@ -105,28 +114,30 @@ export function BranchList({
105114 )}
106115 </div>
107116 <div class="ref-meta-row">
108- {b.authorName && (
109- <span class="ref-author">
117+ {!!b.authorName && (
118+ <span class="ref-author" safe>
110119 {b.authorName}
111120 </span>
112121 )}
113- {b.shortHash && (
122+ {!!b.shortHash && (
114123 <a
115124 href={`/${repo.name}/commit/${b.shortHash}`}
116125 class="ref-hash mono"
126+ safe
117127 >
118128 {b.shortHash}
119129 </a>
120130 )}
121- {b.subject && (
122- <span class="ref-subject">
131+ {!!b.subject && (
132+ <span class="ref-subject" safe>
123133 {b.subject}
124134 </span>
125135 )}
126- {b.date && (
136+ {!!b.date && (
127137 <time
128138 class="ref-date"
129139 datetime={b.date}
140+ safe
130141 >
131142 {formatDateTime(b.date)}
132143 </time>
@@ -174,7 +185,7 @@ export function BranchList({
174185 </summary>
175186 <div class="confirm-popup">
176187 Delete branch{" "}
177- <strong>
188+ <strong safe>
178189 {b.name}
179190 </strong>
180191 ?
Msrc/views/repos/BranchSelector.tsx
@@ -30,11 +30,11 @@ export function BranchSelector({
3030 class="branch-selector"
3131 >
3232 <input type="hidden" name="view" value={view} />
33- {path && <input type="hidden" name="path" value={path} />}
33+ {!!path && <input type="hidden" name="path" value={path} />}
3434 <span class="branch-selector-icon">⎇</span>
3535 <select name="rev" class="branch-select" data-autosubmit>
3636 {isDetached && (
37- <option value={currentRef} selected>
37+ <option value={currentRef} selected safe>
3838 {shortRef} (detached)
3939 </option>
4040 )}
@@ -43,6 +43,7 @@ export function BranchSelector({
4343 <option
4444 value={b}
4545 selected={b === currentRef ? true : undefined}
46+ safe
4647 >
4748 {b}
4849 </option>
@@ -54,6 +55,7 @@ export function BranchSelector({
5455 <option
5556 value={t}
5657 selected={t === currentRef ? true : undefined}
58+ safe
5759 >
5860 {t}
5961 </option>
Msrc/views/repos/CommitDetail.tsx
@@ -44,6 +44,7 @@ export function CommitDetail({
4444 href={`/${repo.name}/tree/${sha}`}
4545 class="btn btn-secondary btn-sm"
4646 title={`Browse tree at ${sha.slice(0, 7)}`}
47+ safe
4748 >
4849 @ {sha.slice(0, 7)}
4950 </a>
@@ -53,11 +54,14 @@ export function CommitDetail({
5354 <div class="commit-card">
5455 <h2
5556 class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}
57+ safe
5658 >
5759 {meta.subject || "No commit message"}
5860 </h2>
59- {meta.body && (
60- <pre class="commit-card-body">{meta.body}</pre>
61+ {!!meta.body && (
62+ <pre class="commit-card-body" safe>
63+ {meta.body}
64+ </pre>
6165 )}
6266 <div class="commit-card-meta">
6367 <div class="commit-card-meta-row">
@@ -71,6 +75,7 @@ export function CommitDetail({
7175 <time
7276 class="commit-meta-value"
7377 datetime={meta.date}
78+ safe
7479 >
7580 {formatDateTime(meta.date)}
7681 </time>
@@ -95,6 +100,7 @@ export function CommitDetail({
95100 <time
96101 class="commit-meta-value"
97102 datetime={meta.committerDate}
103+ safe
98104 >
99105 {formatDateTime(meta.committerDate)}
100106 </time>
@@ -103,7 +109,7 @@ export function CommitDetail({
103109 )}
104110 <div class="commit-card-meta-row">
105111 <span class="commit-meta-label">Commit</span>
106- <code class="commit-meta-value commit-sha-full mono">
112+ <code class="commit-meta-value commit-sha-full mono" safe>
107113 {meta.hash}
108114 </code>
109115 </div>
@@ -117,6 +123,7 @@ export function CommitDetail({
117123 <a
118124 href={`/${repo.name}/commit/${p}`}
119125 class="commit-hash mono"
126+ safe
120127 >
121128 {p.slice(0, 7)}
122129 </a>
@@ -145,7 +152,7 @@ export function CommitDetail({
145152
146153 {tooLarge !== undefined ? (
147154 <div class="file-download-notice">
148- <p>
155+ <p safe>
149156 Diff is too large to render inline (
150157 {(tooLarge / 1024 / 1024).toFixed(1)} MB). Browse
151158 individual files at the tree below.
Msrc/views/repos/CommitLog.tsx
@@ -66,11 +66,12 @@ export function CommitLog({
6666 <a
6767 href={`/${repo.name}/commit/${c.hash}`}
6868 class="commit-subject"
69+ safe
6970 >
7071 {c.subject}
7172 </a>
7273 <div class="commit-meta">
73- <span class="commit-author">
74+ <span class="commit-author" safe>
7475 {c.author}
7576 </span>
7677 <span class="commit-meta-right">
@@ -87,12 +88,14 @@ export function CommitLog({
8788 <a
8889 href={`/${repo.name}/commit/${c.hash}`}
8990 class="commit-hash mono"
91+ safe
9092 >
9193 {c.hash.slice(0, 7)}
9294 </a>
9395 <time
9496 class="commit-date"
9597 datetime={c.date}
98+ safe
9699 >
97100 {formatDateTime(c.date)}
98101 </time>
@@ -103,20 +106,20 @@ export function CommitLog({
103106 </ul>
104107 </>
105108 )}
106- {(newerUrl || olderUrl) && (
109+ {(!!newerUrl || !!olderUrl) && (
107110 <nav
108111 class="commit-cursor-nav"
109112 aria-label="Commit history navigation"
110113 >
111114 <div class="commit-cursor-prev">
112- {newerUrl && (
115+ {!!newerUrl && (
113116 <a href={newerUrl} class="pagination-btn">
114117 ← Newer
115118 </a>
116119 )}
117120 </div>
118121 <div class="commit-cursor-next">
119- {olderUrl && (
122+ {!!olderUrl && (
120123 <a href={olderUrl} class="pagination-btn">
121124 Older →
122125 </a>
Msrc/views/repos/FileBlob.tsx
@@ -35,7 +35,9 @@ export function FileBlob({
3535 <RepoHeader repo={repo} />
3636 <RepoNav repo={repo} active="code" user={user} />
3737 <div class="breadcrumb">
38- <a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
38+ <a href={`/${repo.name}/tree/${blobRef}`} safe>
39+ {repo.name}
40+ </a>
3941 {parts.map((part, i) => {
4042 const partPath = parts.slice(0, i + 1).join("/");
4143 const isLast = i === parts.length - 1;
@@ -43,12 +45,13 @@ export function FileBlob({
4345 <>
4446 <span class="breadcrumb-sep">/</span>
4547 {isLast ? (
46- <span class="breadcrumb-current">
48+ <span class="breadcrumb-current" safe>
4749 {part}
4850 </span>
4951 ) : (
5052 <a
5153 href={`/${repo.name}/tree/${blobRef}/${partPath}`}
54+ safe
5255 >
5356 {part}
5457 </a>
@@ -58,7 +61,9 @@ export function FileBlob({
5861 })}
5962 </div>
6063 <div class="file-blob-header">
61- <span class="file-blob-name">{filename}</span>
64+ <span class="file-blob-name" safe>
65+ {filename}
66+ </span>
6267 <div class="file-blob-actions file-blob-actions-wrap">
6368 <BranchSelector
6469 repoName={repo.name}
@@ -90,7 +95,7 @@ export function FileBlob({
9095 Delete
9196 </summary>
9297 <div class="confirm-popup">
93- Delete <strong>{filename}</strong>?
98+ Delete <strong safe>{filename}</strong>?
9499 <form
95100 method="POST"
96101 action={`/${repo.name}/delete-file/${blobRef}/${filePath}`}
@@ -119,9 +124,9 @@ export function FileBlob({
119124 </div>
120125 <div class="file-blob-body">
121126 {markdownHtml ? (
122- <div class="markdown-body">{markdownHtml}</div>
127+ <div class="markdown-body">{markdownHtml as "safe"}</div>
123128 ) : view.type === "inline" ? (
124- <div class="shiki-wrapper">{view.html}</div>
129+ <div class="shiki-wrapper">{view.html as "safe"}</div>
125130 ) : view.type === "media" ? (
126131 <div class="file-media">
127132 {view.mimeType.startsWith("image/") ? (
@@ -148,7 +153,7 @@ export function FileBlob({
148153 </div>
149154 ) : view.type === "binary" ? (
150155 <div class="file-download-notice">
151- <p>Binary file ({formatSize(view.size)})</p>
156+ <p safe>Binary file ({formatSize(view.size)})</p>
152157 <a
153158 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
154159 class="btn btn-primary"
@@ -158,7 +163,7 @@ export function FileBlob({
158163 </div>
159164 ) : (
160165 <div class="file-download-notice">
161- <p>
166+ <p safe>
162167 File too large to display inline (
163168 {formatSize(view.size)})
164169 </p>
Msrc/views/repos/FileEdit.tsx
@@ -32,7 +32,9 @@ export function FileEdit({
3232 <RepoHeader repo={repo} />
3333 <RepoNav repo={repo} active="code" user={user} />
3434 <div class="breadcrumb">
35- <a href={`/${repo.name}/tree/${editRef}`}>{repo.name}</a>
35+ <a href={`/${repo.name}/tree/${editRef}`} safe>
36+ {repo.name}
37+ </a>
3638 {parts.map((part, i) => {
3739 const partPath = parts.slice(0, i + 1).join("/");
3840 const isLast = i === parts.length - 1;
@@ -40,12 +42,13 @@ export function FileEdit({
4042 <>
4143 <span class="breadcrumb-sep">/</span>
4244 {isLast ? (
43- <span class="breadcrumb-current">
45+ <span class="breadcrumb-current" safe>
4446 {part}
4547 </span>
4648 ) : (
4749 <a
4850 href={`/${repo.name}/tree/${editRef}/${partPath}`}
51+ safe
4952 >
5053 {part}
5154 </a>
@@ -57,15 +60,19 @@ export function FileEdit({
5760 <p class="form-hint">
5861 WARNING: Line endings are normalized to LF (\n) on save.
5962 </p>
60- {(error || queryError) && (
61- <p class="form-error">{error ?? queryError}</p>
63+ {(!!error || !!queryError) && (
64+ <p class="form-error" safe>
65+ {error ?? queryError}
66+ </p>
6267 )}
6368 <form
6469 method="POST"
6570 action={`/${repo.name}/edit/${editRef}/${filePath}`}
6671 >
6772 <div class="file-blob-header">
68- <span class="file-blob-name">{filename}</span>
73+ <span class="file-blob-name" safe>
74+ {filename}
75+ </span>
6976 <div class="file-blob-actions">
7077 <a
7178 href={`/${repo.name}/blob/${editRef}/${filePath}`}
@@ -84,6 +91,7 @@ export function FileEdit({
8491 autocorrect="off"
8592 autocapitalize="off"
8693 {...{ autocomplete: "off" }}
94+ safe
8795 >
8896 {content}
8997 </textarea>
@@ -93,7 +101,8 @@ export function FileEdit({
93101 class="form-hint"
94102 style="margin-bottom: var(--space-4);"
95103 >
96- Committing directly to <strong>{editRef}</strong>
104+ Committing directly to{" "}
105+ <strong safe>{editRef}</strong>
97106 </p>
98107 <div class="form-group">
99108 <label for="file-path">File path</label>
@@ -113,6 +122,7 @@ export function FileEdit({
113122 name="message"
114123 rows="3"
115124 required
125+ safe
116126 >
117127 {`Edited ${filename}`}
118128 </textarea>
Msrc/views/repos/FileTree.tsx
@@ -57,9 +57,9 @@ export function FileTree({
5757 </a>
5858 )}
5959 </div>
60- {subpath && (
60+ {!!subpath && (
6161 <div class="breadcrumb">
62- <a href={`/${repo.name}/tree/${treeRef}`}>
62+ <a href={`/${repo.name}/tree/${treeRef}`} safe>
6363 {repo.name}
6464 </a>
6565 {parts.map((part, i) => {
@@ -69,6 +69,7 @@ export function FileTree({
6969 <span class="breadcrumb-sep">/</span>
7070 <a
7171 href={`/${repo.name}/tree/${treeRef}/${partPath}`}
72+ safe
7273 >
7374 {part}
7475 </a>
@@ -83,11 +84,11 @@ export function FileTree({
8384 subpath={subpath}
8485 entries={entries}
8586 />
86- {readmeHtml && (
87+ {!!readmeHtml && (
8788 <div class="readme-section">
8889 <div class="readme-header">
8990 <span>README</span>
90- {readmePath && (
91+ {!!readmePath && (
9192 <a
9293 href={`/${repo.name}/raw/${treeRef}/${readmePath}`}
9394 class="btn btn-sm"
@@ -96,7 +97,7 @@ export function FileTree({
9697 </a>
9798 )}
9899 </div>
99- <div class="markdown-body">{readmeHtml}</div>
100+ <div class="markdown-body">{readmeHtml as "safe"}</div>
100101 </div>
101102 )}
102103 </div>
Msrc/views/repos/FileTreeTable.tsx
@@ -27,7 +27,7 @@ export function FileTreeTable({
2727 return (
2828 <table class="file-tree">
2929 <tbody>
30- {subpath && (
30+ {!!subpath && (
3131 <tr class="file-tree-row file-tree-row-up">
3232 <td class="file-icon file-icon-dir">{DirIcon()}</td>
3333 <td class="file-name" colspan="2">
@@ -58,6 +58,7 @@ export function FileTreeTable({
5858 ? "file-name-dir"
5959 : undefined
6060 }
61+ safe
6162 >
6263 {entry.name}
6364 </a>
Msrc/views/repos/NewFileForm.tsx
@@ -29,7 +29,11 @@ export function NewFileForm({
2929 <div class="container">
3030 <RepoHeader repo={repo} />
3131 <RepoNav repo={repo} active="code" user={user} />
32- {error && <p class="form-error">{error}</p>}
32+ {!!error && (
33+ <p class="form-error" safe>
34+ {error}
35+ </p>
36+ )}
3337 <form
3438 method="POST"
3539 action={`/${repo.name}/new-file/${treeRef}`}
@@ -59,7 +63,7 @@ export function NewFileForm({
5963 class="form-hint"
6064 style="margin-bottom: var(--space-4);"
6165 >
62- Creating file on <strong>{treeRef}</strong>
66+ Creating file on <strong safe>{treeRef}</strong>
6367 </p>
6468 <div class="form-group">
6569 <label for="file-path">File path</label>
Msrc/views/repos/NewRepo.tsx
@@ -11,7 +11,11 @@ export function NewRepo({ user, error }: NewRepoProps) {
1111 <Layout user={user} title="New repository">
1212 <div class="container container-narrow">
1313 <h1 class="page-title">Create new repository</h1>
14- {error && <p class="form-error">{error}</p>}
14+ {!!error && (
15+ <p class="form-error" safe>
16+ {error}
17+ </p>
18+ )}
1519 <form method="POST" action="/new" class="form-card">
1620 <div class="form-group">
1721 <label for="name">Repository name</label>
Msrc/views/repos/RepoHeader.tsx
@@ -9,7 +9,9 @@ export function RepoHeader({ repo }: RepoHeaderProps) {
99 <div class="repo-header">
1010 <div class="repo-title-row">
1111 <h1 class="page-title">
12- <a href={`/${repo.name}`}>{repo.name}</a>
12+ <a href={`/${repo.name}`} safe>
13+ {repo.name}
14+ </a>
1315 </h1>
1416 {repo.is_private ? (
1517 <span class="badge badge-private">Private</span>
Msrc/views/repos/RepoHome.tsx
@@ -33,8 +33,10 @@ export function RepoHome({
3333 <Layout user={user} title={repo.name}>
3434 <div class="container">
3535 <RepoHeader repo={repo} />
36- {repo.description && (
37- <p class="repo-description">{repo.description}</p>
36+ {!!repo.description && (
37+ <p class="repo-description" safe>
38+ {repo.description}
39+ </p>
3840 )}
3941 <RepoNav repo={repo} active="code" user={user} />
4042 {!hasContent ? (
@@ -42,7 +44,7 @@ export function RepoHome({
4244 <h2>This repository is empty.</h2>
4345 <p>Push your first commit to get started:</p>
4446 <pre class="code-setup">
45- <code>{`git clone ${sshUrl(repo.name)}
47+ <code safe>{`git clone ${sshUrl(repo.name)}
4648 cd ${repo.name}
4749 echo "# ${repo.name}" > README.md
4850 git add .
@@ -109,11 +111,11 @@ git push origin main`}</code>
109111 subpath=""
110112 entries={entries}
111113 />
112- {readmeHtml && (
114+ {!!readmeHtml && (
113115 <div class="readme-section">
114116 <div class="readme-header">
115117 <span>README</span>
116- {readmePath && (
118+ {!!readmePath && (
117119 <a
118120 href={`/${repo.name}/raw/${repo.default_branch}/${readmePath}`}
119121 class="btn btn-sm"
@@ -122,7 +124,7 @@ git push origin main`}</code>
122124 </a>
123125 )}
124126 </div>
125- <div class="markdown-body">{readmeHtml}</div>
127+ <div class="markdown-body">{readmeHtml as "safe"}</div>
126128 </div>
127129 )}
128130 </>
Msrc/views/repos/RepoList.tsx
@@ -76,7 +76,8 @@ export function RepoList({
7676 <div class="empty-state">
7777 {search ? (
7878 <p>
79- No repositories match <strong>{search}</strong>.
79+ No repositories match{" "}
80+ <strong safe>{search}</strong>.
8081 </p>
8182 ) : (
8283 <p>No repositories yet.</p>
@@ -91,6 +92,7 @@ export function RepoList({
9192 <a
9293 href={`/${repo.name}`}
9394 class="repo-name"
95+ safe
9496 >
9597 {repo.name}
9698 </a>
@@ -105,8 +107,8 @@ export function RepoList({
105107 </span>
106108 ) : null}
107109 </div>
108- {repo.description && (
109- <p class="repo-description">
110+ {!!repo.description && (
111+ <p class="repo-description" safe>
110112 {repo.description}
111113 </p>
112114 )}
@@ -115,6 +117,7 @@ export function RepoList({
115117 <time
116118 class="repo-date"
117119 datetime={repo.created_at}
120+ safe
118121 >
119122 {formatDate(repo.created_at)}
120123 </time>
Msrc/views/repos/RepoSettings.tsx
@@ -36,8 +36,16 @@ export function RepoSettings({
3636 <div class="container">
3737 <RepoHeader repo={repo} />
3838 <RepoNav repo={repo} active="settings" user={user} />
39- {success && <p class="form-success">{success}</p>}
40- {error && <p class="form-error">{error}</p>}
39+ {!!success && (
40+ <p class="form-success" safe>
41+ {success}
42+ </p>
43+ )}
44+ {!!error && (
45+ <p class="form-error" safe>
46+ {error}
47+ </p>
48+ )}
4149 <form
4250 method="POST"
4351 action={`/${repo.name}/settings`}
@@ -69,6 +77,7 @@ export function RepoSettings({
6977 ? true
7078 : undefined
7179 }
80+ safe
7281 >
7382 {b}
7483 </option>
@@ -128,6 +137,7 @@ export function RepoSettings({
128137 name="issue_template"
129138 rows="8"
130139 placeholder="## Description&#10;&#10;## Steps to reproduce&#10;&#10;## Expected behavior"
140+ safe
131141 >
132142 {repo.issue_template ?? ""}
133143 </textarea>
@@ -145,6 +155,7 @@ export function RepoSettings({
145155 name="patch_template"
146156 rows="8"
147157 placeholder="## Summary&#10;&#10;## Testing"
158+ safe
148159 >
149160 {repo.patch_template ?? ""}
150161 </textarea>
@@ -163,7 +174,7 @@ export function RepoSettings({
163174 class="label-settings-swatch"
164175 style={`background:${label.color}`}
165176 />
166- <span class="label-settings-name">
177+ <span class="label-settings-name" safe>
167178 {label.name}
168179 </span>
169180 <form
@@ -230,10 +241,10 @@ export function RepoSettings({
230241 {secrets.map((secret) => (
231242 <div class="label-settings-item">
232243 <span class="label-settings-name">
233- <code>{secret.name}</code>
244+ <code safe>{secret.name}</code>
234245 </span>
235- {secret.description && (
236- <span class="text-muted">
246+ {!!secret.description && (
247+ <span class="text-muted" safe>
237248 {secret.description}
238249 </span>
239250 )}
@@ -293,6 +304,61 @@ export function RepoSettings({
293304 <div class="danger-zone">
294305 <h2 class="section-title danger-title">Danger zone</h2>
295306 <div class="form-card danger-card">
307+ <div class="danger-item">
308+ <div>
309+ <strong>Rename this repository</strong>
310+ <p class="text-muted">
311+ Changing the name breaks existing clone
312+ URLs and links to this repo. Collaborators
313+ will need to update their remotes.
314+ </p>
315+ </div>
316+ <details class="confirm-details">
317+ <summary class="btn btn-danger">
318+ Rename repository
319+ </summary>
320+ <div class="confirm-popup">
321+ <form
322+ method="POST"
323+ action={`/${repo.name}/settings/rename`}
324+ class="inline-form"
325+ >
326+ <div class="form-group">
327+ <label for="new_name">
328+ New name
329+ </label>
330+ <input
331+ id="new_name"
332+ name="new_name"
333+ type="text"
334+ required
335+ pattern="[A-Za-z0-9._\-]+"
336+ autocomplete="off"
337+ />
338+ </div>
339+ <div class="form-group">
340+ <label for="confirm_name">
341+ Type <code safe>{repo.name}</code>{" "}
342+ to confirm
343+ </label>
344+ <input
345+ id="confirm_name"
346+ name="confirm_name"
347+ type="text"
348+ required
349+ autocomplete="off"
350+ />
351+ </div>
352+ <button
353+ type="submit"
354+ class="btn btn-danger"
355+ >
356+ Rename repository
357+ </button>
358+ </form>
359+ </div>
360+ </details>
361+ </div>
296362 <div class="danger-item">
297363 <div>
298364 <strong>Delete this repository</strong>
@@ -305,7 +371,8 @@ export function RepoSettings({
305371 Delete repository
306372 </summary>
307373 <div class="confirm-popup">
308- Delete {repo.name}? This cannot be undone.
374+ Delete <span safe>{repo.name}</span>? This
375+ cannot be undone.
309376 <form
310377 method="POST"
311378 action={`/${repo.name}/settings/delete`}
Msrc/views/repos/TagList.tsx
@@ -37,8 +37,16 @@ export function TagList({
3737 <div class="container">
3838 <RepoHeader repo={repo} />
3939 <RepoNav repo={repo} active="tags" user={user} />
40- {success && <p class="form-success">{success}</p>}
41- {error && <p class="form-error">{error}</p>}
40+ {!!success && (
41+ <p class="form-success" safe>
42+ {success}
43+ </p>
44+ )}
45+ {!!error && (
46+ <p class="form-error" safe>
47+ {error}
48+ </p>
49+ )}
4250 <div class="list-header">
4351 <h2 class="list-heading">Tags</h2>
4452 {user?.isAdmin && (
@@ -117,6 +125,7 @@ export function TagList({
117125 <a
118126 href={`/${repo.name}/tree/${tag.name}`}
119127 class="ref-name"
128+ safe
120129 >
121130 {tag.name}
122131 </a>
@@ -130,28 +139,30 @@ export function TagList({
130139 )}
131140 </div>
132141 <div class="ref-meta-row">
133- {tag.taggerName && (
134- <span class="ref-author">
142+ {!!tag.taggerName && (
143+ <span class="ref-author" safe>
135144 {tag.taggerName}
136145 </span>
137146 )}
138- {tag.shortHash && (
147+ {!!tag.shortHash && (
139148 <a
140149 href={`/${repo.name}/commit/${tag.shortHash}`}
141150 class="ref-hash mono"
151+ safe
142152 >
143153 {tag.shortHash}
144154 </a>
145155 )}
146- {tag.subject && (
147- <span class="ref-subject">
156+ {!!tag.subject && (
157+ <span class="ref-subject" safe>
148158 {tag.subject}
149159 </span>
150160 )}
151- {tag.date && (
161+ {!!tag.date && (
152162 <time
153163 class="ref-date"
154164 datetime={tag.date}
165+ safe
155166 >
156167 {formatDateTime(tag.date)}
157168 </time>
@@ -166,7 +177,7 @@ export function TagList({
166177 {releaseId ? (
167178 <p class="confirm-warning">
168179 Tag{" "}
169- <strong>
180+ <strong safe>
170181 {tag.name}
171182 </strong>{" "}
172183 is linked to{" "}
@@ -184,7 +195,7 @@ export function TagList({
184195 ) : (
185196 <>
186197 Delete tag{" "}
187- <strong>
198+ <strong safe>
188199 {tag.name}
189200 </strong>
190201 ?
Mtests/e2e.settings.test.ts
@@ -338,6 +338,101 @@ describe('repository deletion', () => {
338338 });
339339 });
340340
341+// ─── Repository rename ───────────────────────────────────────────────────────
342+
343+describe('repository rename', () => {
344+ let adminCtx: BrowserContext;
345+
346+ beforeAll(async () => {
347+ adminCtx = await loggedInContext();
348+
349+ const page = await adminCtx.newPage();
350+ try {
351+ await page.goto(`${BASE}/new`);
352+ await page.fill('[name=name]', 'renameme-repo');
353+ await page.click('form[action="/new"] button[type=submit]');
354+ await page.waitForURL(`${BASE}/renameme-repo`);
355+
356+ await page.goto(`${BASE}/new`);
357+ await page.fill('[name=name]', 'rename-other');
358+ await page.click('form[action="/new"] button[type=submit]');
359+ await page.waitForURL(`${BASE}/rename-other`);
360+ } finally { await page.close(); }
361+ });
362+
363+ afterAll(async () => { await adminCtx.close(); });
364+
365+ test('rejects wrong confirmation', async () => {
366+ const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
367+ form: { new_name: 'whatever', confirm_name: 'wrong' },
368+ maxRedirects: 0,
369+ });
370+ expect(resp.status()).toBe(302);
371+ const loc = resp.headers()['location']!;
372+ expect(loc).toContain('/renameme-repo/settings?error=');
373+ expect(decodeURIComponent(loc)).toContain('Confirmation');
374+
375+ const check = await adminCtx.request.get(`${BASE}/renameme-repo`);
376+ expect(check.status()).toBe(200);
377+ });
378+
379+ test('rejects invalid name', async () => {
380+ const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
381+ form: { new_name: 'bad name', confirm_name: 'renameme-repo' },
382+ maxRedirects: 0,
383+ });
384+ expect(resp.status()).toBe(302);
385+ const loc = resp.headers()['location']!;
386+ expect(loc).toContain('/renameme-repo/settings?error=');
387+ expect(decodeURIComponent(loc)).toContain('Invalid');
388+ });
389+
390+ test('rejects no-op rename', async () => {
391+ const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
392+ form: { new_name: 'renameme-repo', confirm_name: 'renameme-repo' },
393+ maxRedirects: 0,
394+ });
395+ expect(resp.status()).toBe(302);
396+ expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name');
397+ });
398+
399+ test('rejects duplicate name', async () => {
400+ const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
401+ form: { new_name: 'rename-other', confirm_name: 'renameme-repo' },
402+ maxRedirects: 0,
403+ });
404+ expect(resp.status()).toBe(302);
405+ expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken');
406+ });
407+
408+ test('non-admin cannot rename', async () => {
409+ const aliceCtx = await loggedInContext('alice', 'password123');
410+ try {
411+ const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
412+ form: { new_name: 'hijack', confirm_name: 'renameme-repo' },
413+ maxRedirects: 0,
414+ });
415+ expect(resp.status()).toBe(403);
416+ } finally { await aliceCtx.close(); }
417+ });
418+
419+ test('admin can rename repository', async () => {
420+ const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
421+ form: { new_name: 'renamed-repo', confirm_name: 'renameme-repo' },
422+ maxRedirects: 0,
423+ });
424+ expect(resp.status()).toBe(302);
425+ const loc = resp.headers()['location']!;
426+ expect(loc).toContain('/renamed-repo/settings?success=');
427+
428+ const old = await adminCtx.request.get(`${BASE}/renameme-repo`);
429+ expect(old.status()).toBe(404);
430+
431+ const next = await adminCtx.request.get(`${BASE}/renamed-repo`);
432+ expect(next.status()).toBe(200);
433+ });
434+});
435+
341436 // ─── 404 handling ─────────────────────────────────────────────────────────────
342437
343438 describe('404 handling', () => {
Mtsconfig.json
@@ -7,6 +7,7 @@
77 "moduleDetection": "force",
88 "jsx": "react-jsx",
99 "jsxImportSource": "@kitajs/html",
10+ "plugins": [{ "name": "@kitajs/ts-html-plugin" }],
1011 "allowJs": true,
1112
1213 // Bundler mode