From 8dbfc078da773df9771da296de83444e23c5b7dc Mon Sep 17 00:00:00 2001 From: Jesse Ditson Date: Thu, 10 Sep 2026 15:17:41 -0700 Subject: [PATCH 1/2] take a swing at adding comments to the updates section via val town --- build.mjs | 8 + define.d.ts | 2 + pages/update.liquid | 64 ++++++ pages/updates.liquid | 34 +++- src/comments.ts | 464 +++++++++++++++++++++++++++++++++++++++++++ src/updates.ts | 4 + style/updates.css | 308 ++++++++++++++++++++++++++++ valtown/README.md | 52 +++++ valtown/comments.ts | 378 +++++++++++++++++++++++++++++++++++ 9 files changed, 1305 insertions(+), 9 deletions(-) create mode 100644 src/comments.ts create mode 100644 valtown/README.md create mode 100644 valtown/comments.ts diff --git a/build.mjs b/build.mjs index 020bb3d..0b6feef 100755 --- a/build.mjs +++ b/build.mjs @@ -22,6 +22,13 @@ const EDITOR_URL = dev : staging ? "https://editor.archival-staging.dev" : "https://editor.archival.dev"; +// The Val Town val in valtown/comments.ts. Override with COMMENTS_URL to point +// a build at a val of your own. +const COMMENTS_URL = + process.env.COMMENTS_URL ?? + (dev || staging + ? "https://jesseditson-archival_comments_staging.web.val.run" + : "https://jesseditson-archival_comments.web.val.run"); const TURNSTILE_SITE_KEY = dev ? "1x00000000000000000000AA" : staging @@ -43,6 +50,7 @@ const ctx = await esbuild.context({ EDITOR_URL: `"${EDITOR_URL}"`, API_URL: `"${API_URL}"`, TURNSTILE_SITE_KEY: `"${TURNSTILE_SITE_KEY}"`, + COMMENTS_URL: `"${COMMENTS_URL}"`, }, format: "esm", target: "es2022", diff --git a/define.d.ts b/define.d.ts index 134a943..08bea67 100644 --- a/define.d.ts +++ b/define.d.ts @@ -6,3 +6,5 @@ declare const DEV: boolean; declare const EDITOR_URL: string; declare const API_URL: string; declare const TURNSTILE_SITE_KEY: string; +/** Origin of the Val Town val that stores comments on updates. */ +declare const COMMENTS_URL: string; diff --git a/pages/update.liquid b/pages/update.liquid index 3105a69..ca108b9 100644 --- a/pages/update.liquid +++ b/pages/update.liquid @@ -6,6 +6,7 @@ %} {% assign sorted_updates = objects.update | sort: "publish_date" | reverse %} +{% assign update_slug = update.path | split: "/" | last %}
@@ -25,6 +26,69 @@ {{ update.body }}
+ {% comment %} + The thread itself is fetched from the comments val once this section + nears the viewport, so an update that is only read costs no request. + {% endcomment %} +
+

Comments

+

Add something we missed, ask a question, or disagree. Just be cool.

+ +

Loading comments…

+ +
    + + + + +
    + {% comment %} Newer and older by publish date. The list is newest-first, so the entry before this one is the newer of the two. diff --git a/pages/updates.liquid b/pages/updates.liquid index 35e8051..c108297 100644 --- a/pages/updates.liquid +++ b/pages/updates.liquid @@ -45,15 +45,31 @@ - - Read more → - + {% endfor %} diff --git a/src/comments.ts b/src/comments.ts new file mode 100644 index 0000000..91fb4ca --- /dev/null +++ b/src/comments.ts @@ -0,0 +1,464 @@ +// Comments for the updates feed and the update detail pages, backed by the Val +// Town val in valtown/comments.ts. Counts on the feed come from one batched +// request; a thread is only fetched once its section nears the viewport. + +interface Comment { + id: number; + parentId: number | null; + author: string; + body: string; + role: "reader" | "team"; + createdAt: string; + replies?: Comment[]; +} + +interface TurnstileApi { + render( + el: HTMLElement, + options: { + sitekey: string; + action?: string; + execution?: "render" | "execute"; + appearance?: "always" | "execute" | "interaction-only"; + callback: (token: string) => void; + "error-callback"?: () => void; + "expired-callback"?: () => void; + }, + ): string; + execute(container: HTMLElement | string): void; + reset(widgetId: string): void; +} + +const TURNSTILE_ACTION = "update-comment"; +const NAME_KEY = "archival-comment-name"; +const TOKEN_KEY = "archival-comment-token"; + +const turnstile = () => + (window as unknown as { turnstile?: TurnstileApi }).turnstile; + +const store = { + get(key: string) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Private browsing. The field just won't be remembered. + } + }, + remove(key: string) { + try { + localStorage.removeItem(key); + } catch { + // Nothing was stored to begin with. + } + }, +}; + +/** + * The team's posting token arrives as a query param once and lives in local + * storage after that, so an author replies from the same form everyone else + * uses. + */ +const adminToken = () => { + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get("comment_token"); + if (fromUrl) { + store.set(TOKEN_KEY, fromUrl); + url.searchParams.delete("comment_token"); + history.replaceState(null, "", url.toString()); + return fromUrl; + } + return store.get(TOKEN_KEY); +}; + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? "") + .join("") || "?"; + +/** Stable per name, so the same commenter keeps the same avatar colour. */ +const hue = (name: string) => { + let total = 0; + for (let i = 0; i < name.length; i += 1) { + total = (total * 31 + name.charCodeAt(i)) % 360; + } + return total; +}; + +const shortDate = (iso: string) => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const sameYear = date.getFullYear() === new Date().getFullYear(); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + }); +}; + +const plural = (count: number) => `${count} comment${count === 1 ? "" : "s"}`; + +const avatar = (name: string, extraClass = "") => { + const el = document.createElement("span"); + el.className = `comment-avatar ${extraClass}`.trim(); + el.style.setProperty("--comment-hue", String(hue(name))); + el.setAttribute("aria-hidden", "true"); + el.textContent = initials(name); + return el; +}; + +export const setupCommentCounts = () => { + const targets = Array.from( + document.querySelectorAll("[data-comment-count]"), + ); + if (!targets.length) return; + + const slugs = Array.from( + new Set(targets.map((el) => el.dataset.commentSlug ?? "").filter(Boolean)), + ); + if (!slugs.length) return; + + void (async () => { + try { + const response = await fetch( + `${COMMENTS_URL}/counts?slugs=${encodeURIComponent(slugs.join(","))}`, + ); + if (!response.ok) return; + const { counts } = (await response.json()) as { + counts: Record; + }; + targets.forEach((el) => { + const count = counts[el.dataset.commentSlug ?? ""] ?? 0; + if (!count) return; + el.textContent = plural(count); + el.hidden = false; + }); + } catch { + // The feed reads fine without counts. + } + })(); +}; + +export const setupCommentThread = () => { + const root = document.querySelector("[data-comments]"); + const slug = root?.dataset.commentsSlug; + if (!root || !slug) return; + + const list = root.querySelector("[data-comments-list]")!; + const status = root.querySelector("[data-comments-status]")!; + const total = root.querySelector("[data-comments-total]")!; + const form = root.querySelector("[data-comment-form]")!; + const nameField = form.querySelector( + "[data-comment-name]", + )!; + const bodyField = form.querySelector( + "[data-comment-body]", + )!; + const post = form.querySelector("[data-comment-post]")!; + const error = form.querySelector("[data-comment-error]")!; + const replyTo = form.querySelector("[data-comment-reply-to]")!; + const replyToName = form.querySelector( + "[data-comment-reply-name]", + )!; + const cancelReply = form.querySelector( + "[data-comment-reply-cancel]", + )!; + const challenge = form.querySelector( + "[data-comment-turnstile]", + )!; + const youAvatar = form.querySelector("[data-comment-you]")!; + + let token = adminToken(); + let count = 0; + let parentId: number | null = null; + let widgetId: string | null = null; + let challengeReady: Promise | null = null; + + form.hidden = false; + + const setCount = (next: number) => { + count = next; + total.textContent = count ? ` · ${count}` : ""; + }; + + const setYou = (name: string) => { + youAvatar.classList.toggle("is-empty", !name); + youAvatar.style.setProperty("--comment-hue", String(hue(name))); + youAvatar.textContent = name ? initials(name) : "?"; + }; + + const savedName = store.get(NAME_KEY); + if (savedName) { + nameField.value = savedName; + setYou(savedName); + } + nameField.addEventListener("input", () => setYou(nameField.value.trim())); + + const growBody = () => { + bodyField.style.height = "auto"; + bodyField.style.height = `${bodyField.scrollHeight}px`; + }; + bodyField.addEventListener("input", growBody); + + const renderComment = (comment: Comment, depth = 0) => { + const item = document.createElement("li"); + item.className = "comment"; + item.dataset.commentId = String(comment.id); + if (comment.role === "team") item.classList.add("is-team"); + + const head = document.createElement("div"); + head.className = "comment-head"; + head.appendChild(avatar(comment.author)); + + const author = document.createElement("span"); + author.className = "comment-author"; + author.textContent = comment.author; + head.appendChild(author); + + const time = document.createElement("time"); + time.className = "comment-date"; + time.dateTime = comment.createdAt; + time.title = new Date(comment.createdAt).toLocaleString(); + time.textContent = shortDate(comment.createdAt); + head.appendChild(time); + item.appendChild(head); + + const bubble = document.createElement("div"); + bubble.className = "comment-bubble"; + // textContent, so a comment can never introduce markup. + bubble.textContent = comment.body; + item.appendChild(bubble); + + if (depth === 0) { + const reply = document.createElement("button"); + reply.type = "button"; + reply.className = "comment-reply"; + reply.textContent = "Reply"; + reply.addEventListener("click", () => + startReply(comment.id, comment.author, item), + ); + item.appendChild(reply); + + const replies = document.createElement("ol"); + replies.className = "comment-replies"; + replies.dataset.commentReplies = ""; + comment.replies?.forEach((child) => + replies.appendChild(renderComment(child, depth + 1)), + ); + item.appendChild(replies); + } + + return item; + }; + + const startReply = (id: number, author: string, item: HTMLElement) => { + parentId = id; + replyToName.textContent = author; + replyTo.hidden = false; + (item.querySelector("[data-comment-replies]") ?? item).after(form); + form.classList.add("is-replying"); + bodyField.focus(); + }; + + const endReply = () => { + parentId = null; + replyTo.hidden = true; + form.classList.remove("is-replying"); + root.appendChild(form); + }; + cancelReply.addEventListener("click", endReply); + + const insert = (comment: Comment) => { + if (comment.parentId === null) { + list.appendChild(renderComment(comment)); + return; + } + const parent = list.querySelector( + `[data-comment-id="${comment.parentId}"] [data-comment-replies]`, + ); + if (parent) parent.appendChild(renderComment(comment, 1)); + else list.appendChild(renderComment(comment)); + }; + + const load = async () => { + try { + const response = await fetch( + `${COMMENTS_URL}/thread?slug=${encodeURIComponent(slug)}`, + ); + if (!response.ok) throw new Error(String(response.status)); + const thread = (await response.json()) as { + count: number; + comments: Comment[]; + }; + list.replaceChildren( + ...thread.comments.map((comment) => renderComment(comment)), + ); + setCount(thread.count); + status.textContent = thread.count ? "" : "No comments yet. Go first."; + } catch { + status.textContent = "Couldn't load the comments. Reload to try again."; + } + }; + + const send = async (turnstileToken: string | null) => { + try { + const response = await fetch(`${COMMENTS_URL}/comments`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + slug, + author: nameField.value.trim(), + body: bodyField.value, + parentId, + turnstileToken, + }), + }); + if (!response.ok) { + const { error: message } = (await response + .json() + .catch(() => ({ error: "" }))) as { error?: string }; + // A token the val no longer accepts would keep skipping the challenge + // on every retry, so drop it and let the next attempt take the widget. + if (token && response.status !== 429) { + store.remove(TOKEN_KEY); + token = null; + throw new Error("That token is no longer good. Try again."); + } + throw new Error(message || "That didn't post. Please try again."); + } + const { comment } = (await response.json()) as { comment: Comment }; + endReply(); + insert(comment); + setCount(count + 1); + status.textContent = ""; + store.set(NAME_KEY, nameField.value.trim()); + bodyField.value = ""; + growBody(); + ( + window as unknown as { umami?: { track: (event: string) => void } } + ).umami?.track("update-comment-post"); + } catch (thrown) { + error.textContent = + thrown instanceof Error + ? thrown.message + : "That didn't post. Please try again."; + } finally { + post.disabled = false; + if (widgetId) turnstile()?.reset(widgetId); + } + }; + + // The Turnstile script is only pulled in once someone starts writing, so a + // reader who never comments never loads it. + const prepareChallenge = () => { + challengeReady ??= new Promise((resolve, reject) => { + if (turnstile()) { + resolve(); + return; + } + ( + window as unknown as { onloadCommentsTurnstile?: () => void } + ).onloadCommentsTurnstile = () => resolve(); + const script = document.createElement("script"); + script.src = + "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadCommentsTurnstile&render=explicit"; + script.async = true; + script.defer = true; + script.addEventListener("error", () => reject(new Error("turnstile"))); + document.head.appendChild(script); + }).then(() => { + widgetId ??= + turnstile()?.render(challenge, { + sitekey: TURNSTILE_SITE_KEY, + action: TURNSTILE_ACTION, + execution: "execute", + appearance: "interaction-only", + callback: (value) => void send(value), + "error-callback": () => { + error.textContent = "The challenge failed. Please try again."; + post.disabled = false; + }, + "expired-callback": () => { + error.textContent = "The challenge expired. Please try again."; + post.disabled = false; + }, + }) ?? null; + }); + return challengeReady; + }; + if (!token) { + bodyField.addEventListener( + "focus", + () => void prepareChallenge().catch(() => {}), + { + once: true, + }, + ); + } + + form.addEventListener("submit", (e) => { + e.preventDefault(); + error.textContent = ""; + if (!nameField.value.trim() || !bodyField.value.trim()) { + error.textContent = "A name and a comment, please."; + return; + } + post.disabled = true; + if (token) { + void send(null); + return; + } + prepareChallenge() + .then(() => { + if (!widgetId) throw new Error("no widget"); + turnstile()?.execute(challenge); + }) + .catch(() => { + error.textContent = "Couldn't load the challenge. Please reload."; + post.disabled = false; + }); + }); + + bodyField.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + form.requestSubmit(); + } + }); + + let loaded = false; + const loadOnce = () => { + if (loaded) return; + loaded = true; + void load(); + }; + + if ( + window.location.hash === "#comments" || + !("IntersectionObserver" in window) + ) { + loadOnce(); + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + observer.disconnect(); + loadOnce(); + }, + { rootMargin: "400px 0px" }, + ); + observer.observe(root); +}; diff --git a/src/updates.ts b/src/updates.ts index a786962..d9a4bef 100644 --- a/src/updates.ts +++ b/src/updates.ts @@ -1,6 +1,8 @@ // Behavior for the updates index and the update detail pages. Loaded only on // those pages (see the area check in layout/theme.liquid). +import { setupCommentCounts, setupCommentThread } from "./comments"; + /** * Reveal "Read more" only on cards whose body is actually cut off. The markup * ships clamped so the link is present without scripting; this releases the @@ -368,4 +370,6 @@ window.addEventListener("load", () => { setupVideoPlayers(); setupLightbox(); setupTimeline(); + setupCommentCounts(); + setupCommentThread(); }); diff --git a/style/updates.css b/style/updates.css index 0757ecd..5824ab5 100644 --- a/style/updates.css +++ b/style/updates.css @@ -644,6 +644,305 @@ body.update-lightbox-open { color: var(--color-secondary-interactive); } +/* Comments */ + +.update-card-footer { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1.25rem; +} + +.update-more { + margin-top: 0; +} + +.update-comment-count { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.5); + text-decoration: none; +} + +/* An author display rule outranks the user agent's [hidden], so a count the + script left unfilled needs this to stay out of the card. */ +.update-comment-count[hidden] { + display: none; +} + +.update-comment-count::before { + content: ""; + width: 0.9rem; + height: 0.9rem; + background: currentColor; + /* Speech bubble. */ + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.5c3.6 0 6.5 2.4 6.5 5.4S11.6 12.3 8 12.3c-.6 0-1.2-.1-1.7-.2l-3.6 1.9.9-2.9C2.3 10.1 1.5 8.6 1.5 6.9c0-3 2.9-5.4 6.5-5.4Z'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.update-comment-count:hover { + color: rgba(255, 255, 255, 0.85); +} + +.update-comments { + margin-top: 4rem; + padding-top: 2.5rem; + border-top: 1px solid var(--color-transparent-border); + scroll-margin-top: 7rem; +} + +.comments-title { + font-family: "Build", system-ui, sans-serif; + font-size: 1.5rem; + font-weight: 700; + font-feature-settings: "ss01" on; + margin: 0; +} + +.comments-count { + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.5); +} + +.comments-prompt { + margin: 0.5rem 0 2rem; + color: var(--color-typography-secondary); +} + +.comments-status { + margin: 0 0 1.5rem; + font-size: 0.9375rem; + color: rgba(255, 255, 255, 0.45); +} + +.comments-status:empty { + display: none; +} + +.comments-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.comment-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; +} + +.comment-avatar { + width: 1.75rem; + height: 1.75rem; + flex: none; + display: grid; + place-items: center; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #fff; + background: hsl(var(--comment-hue, 0) 45% 38%); +} + +.comment-author { + font-size: 0.9375rem; + font-weight: 700; +} + +.comment-date { + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.45); +} + +.comment-bubble { + display: inline-block; + max-width: 100%; + padding: 0.75rem 1.1rem; + border-radius: 1.1rem; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.9375rem; + line-height: 1.6; + /* Comments arrive as plain text, and the line breaks in them are the only + formatting a commenter gets. */ + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* A reply from the team, mirrored to the other side of the thread. */ +.comment.is-team > .comment-head { + flex-direction: row-reverse; +} + +.comment.is-team { + text-align: right; +} + +.comment.is-team > .comment-bubble { + text-align: left; + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.2); +} + +.comment-reply { + display: block; + margin-top: 0.5rem; + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.45); + transition: color 0.2s ease-out; +} + +.comment-reply:hover { + color: var(--color-secondary-interactive); +} + +.comment-replies { + list-style: none; + margin: 1.25rem 0 0 1.5rem; + padding: 0 0 0 1.5rem; + border-left: 1px solid rgba(255, 255, 255, 0.12); + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.comment-replies:empty { + display: none; +} + +/* Composer */ + +.comment-form { + margin-top: 2.5rem; +} + +.comment-form.is-replying { + margin: 1.25rem 0 0 3rem; +} + +.comment-form-identity { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.75rem; +} + +.comment-avatar--you { + background: hsl(var(--comment-hue, 0) 45% 38%); +} + +.comment-avatar.is-empty { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.45); +} + +.comment-name { + flex: 1; + min-width: 0; + max-width: 14rem; + padding: 0.35rem 0; + font-size: 0.9375rem; + font-weight: 700; + color: inherit; + background: none; + border: none; + border-bottom: 1px solid transparent; +} + +.comment-name::placeholder { + font-weight: 400; + color: rgba(255, 255, 255, 0.4); +} + +.comment-name:focus { + outline: none; + border-bottom-color: var(--color-transparent-border); +} + +.comment-replying { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.5); +} + +.comment-replying span { + font-weight: 700; + color: rgba(255, 255, 255, 0.8); +} + +.comment-reply-cancel { + margin-left: 0.5rem; + color: var(--color-secondary-interactive); +} + +.comment-form-send { + display: flex; + align-items: flex-end; + gap: 0.75rem; +} + +.comment-input { + flex: 1; + min-width: 0; + max-height: 12rem; + padding: 0.75rem 1.1rem; + border-radius: 1.1rem; + border: 1px solid var(--color-transparent-border); + background: rgba(255, 255, 255, 0.05); + color: inherit; + font: inherit; + font-size: 0.9375rem; + line-height: 1.6; + resize: none; +} + +.comment-input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.comment-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.4); +} + +.comment-post { + flex: none; + padding: 0.75rem 1.25rem; + font-weight: 700; + color: var(--color-secondary-interactive); +} + +.comment-post:disabled { + opacity: 0.5; +} + +.comment-turnstile { + display: flex; + justify-content: flex-end; +} + +.comment-turnstile:empty { + display: none; +} + +.comment-error { + margin: 0.75rem 0 0; + font-size: 0.8125rem; + color: var(--color-primary-cta-highlight); +} + +.comment-error:empty { + display: none; +} + @media (max-width: 1100px) { .updates-layout { grid-template-columns: minmax(0, 1fr); @@ -679,6 +978,15 @@ body.update-lightbox-open { flex-direction: column; } + .comment-replies { + margin-left: 0; + padding-left: 1rem; + } + + .comment-form.is-replying { + margin-left: 1rem; + } + .update-pager-link--end { text-align: left; margin-left: 0; diff --git a/valtown/README.md b/valtown/README.md new file mode 100644 index 0000000..3281d3e --- /dev/null +++ b/valtown/README.md @@ -0,0 +1,52 @@ +# Comments val + +`comments.ts` is the backend for comments on `/updates` and each update page. It +runs on [Val Town](https://val.town) against Val Town's SQLite, and the site +talks to it directly from the browser. + +## Deploy + +1. Create an **HTTP val** on Val Town and paste in `comments.ts`. +2. Set these environment variables on the val: + + | Name | Required | Purpose | + | ---------------------- | -------- | -------------------------------------------------------------- | + | `TURNSTILE_SECRET_KEY` | yes | Secret for the Turnstile widget the comment form renders. | + | `COMMENTS_ADMIN_TOKEN` | no | Lets you post as the team and hide comments. | + | `IP_HASH_SALT` | no | Salt for the stored IP hash used by the rate limit. | + | `ALLOWED_ORIGINS` | no | Comma-separated CORS allowlist. Defaults to the archival.dev origins. | + +3. Copy the val's URL (`https://-.web.val.run`) into `COMMENTS_URL` + in `build.mjs`, for both the staging and production branches. + +The table is created on the first request, so there is no migration step. + +## Endpoints + +| Method | Path | Purpose | +| ------ | ----------------------- | ---------------------------------------------------------- | +| `GET` | `/counts?slugs=a,b,c` | Comment totals for the feed. Omit `slugs` for every slug. | +| `GET` | `/thread?slug=a` | One update's comments, replies nested under their parent. | +| `POST` | `/comments` | `{ slug, author, body, parentId?, turnstileToken }`. | +| `POST` | `/moderate` | `{ id, hidden }` with the admin token. | + +Bodies are stored and returned as plain text. The client renders them as text +nodes, so nothing a commenter writes is ever parsed as markup. + +## Posting as the team + +A comment posted with `Authorization: Bearer $COMMENTS_ADMIN_TOKEN` is stored +with the `team` role and renders as the highlighted reply on the right. To get +that token into your browser, open any update with `?comment_token=` — +the page stores it and strips it from the URL. + +## Hiding a comment + +```sh +curl -X POST https://-.web.val.run/moderate \ + -H "Authorization: Bearer $COMMENTS_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id": 12, "hidden": true}' +``` + +Hidden comments drop out of both the thread and the feed count. diff --git a/valtown/comments.ts b/valtown/comments.ts new file mode 100644 index 0000000..ea5edb1 --- /dev/null +++ b/valtown/comments.ts @@ -0,0 +1,378 @@ +// Comments for archival.dev updates, running as a Val Town HTTP val against +// Val Town's SQLite. See README.md in this directory for deployment. +// +// GET /counts?slugs=a,b,c -> { counts: { slug: number } } +// GET /thread?slug=a -> { slug, count, comments: [...] } +// POST /comments -> { comment } +// POST /moderate -> { ok: true } (admin token) + +import { sqlite } from "https://esm.town/v/std/sqlite"; + +const TABLE = "archival_comments_v1"; +// Hiding a comment hides the replies hanging off it too, so a thread never +// keeps answers to something nobody can read. +const VISIBLE_JOIN = `LEFT JOIN ${TABLE} p ON p.id = c.parent_id + WHERE c.hidden = 0 AND (c.parent_id IS NULL OR p.hidden = 0)`; + +const MAX_AUTHOR = 60; +const MAX_BODY = 2000; +const MAX_SLUGS_PER_REQUEST = 200; +// Turnstile already stops the drive-by case. This is the ceiling on what one +// network can do after clearing it. +const RATE_LIMIT_COMMENTS = 5; +const RATE_LIMIT_MINUTES = 10; + +const DEFAULT_ORIGINS = [ + "https://archival.dev", + "https://www.archival.dev", + "https://archival-staging.dev", + "https://www.archival-staging.dev", +]; + +type Role = "reader" | "team"; + +interface CommentRow { + id: number; + slug: string; + parent_id: number | null; + author: string; + body: string; + role: Role; + created_at: string; +} + +interface Comment { + id: number; + parentId: number | null; + author: string; + body: string; + role: Role; + createdAt: string; + replies?: Comment[]; +} + +let ready: Promise | null = null; + +const init = () => { + ready ??= (async () => { + await sqlite.execute(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + parent_id INTEGER REFERENCES ${TABLE}(id), + author TEXT NOT NULL, + body TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'reader', + created_at TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0, + ip_hash TEXT + ) + `); + await sqlite.execute( + `CREATE INDEX IF NOT EXISTS ${TABLE}_slug ON ${TABLE} (slug, id)`, + ); + })(); + return ready; +}; + +interface ResultSet { + columns: string[]; + rows: unknown[]; +} + +const toObjects = (result: ResultSet): T[] => + result.rows.map((row) => + Array.isArray(row) + ? (Object.fromEntries( + result.columns.map((column, i) => [column, row[i]]), + ) as T) + : (row as T), + ); + +const allowedOrigins = () => { + const configured = Deno.env.get("ALLOWED_ORIGINS"); + return configured + ? configured.split(",").map((origin) => origin.trim()) + : DEFAULT_ORIGINS; +}; + +const corsHeaders = (request: Request): Record => { + const origin = request.headers.get("origin") ?? ""; + const allowed = + allowedOrigins().includes(origin) || + /^https?:\/\/localhost(:\d+)?$/.test(origin) || + /^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin); + return { + "access-control-allow-origin": allowed ? origin : DEFAULT_ORIGINS[0], + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "Content-Type, Authorization", + "access-control-max-age": "86400", + vary: "Origin", + }; +}; + +const json = (request: Request, body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + ...corsHeaders(request), + }, + }); + +const fail = (request: Request, status: number, error: string) => + json(request, { error }, status); + +const isSlug = (value: unknown): value is string => + typeof value === "string" && /^[a-z0-9][a-z0-9._-]{0,120}$/.test(value); + +/** Collapses runs of whitespace but keeps paragraph breaks. */ +const cleanBody = (value: string) => + value + .replace(/\r\n/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + +const hashIp = async (request: Request) => { + const ip = + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown"; + const salt = Deno.env.get("IP_HASH_SALT") ?? "archival-comments"; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${salt}:${ip}`), + ); + return Array.from(new Uint8Array(digest)) + .slice(0, 16) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; + +const verifyTurnstile = async (token: string, request: Request) => { + const secret = Deno.env.get("TURNSTILE_SECRET_KEY"); + if (!secret) return false; + const form = new FormData(); + form.append("secret", secret); + form.append("response", token); + const ip = request.headers.get("cf-connecting-ip"); + if (ip) form.append("remoteip", ip); + const response = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { method: "POST", body: form }, + ); + const result = (await response.json()) as { success?: boolean }; + return result.success === true; +}; + +const isAdmin = (request: Request) => { + const token = Deno.env.get("COMMENTS_ADMIN_TOKEN"); + if (!token) return false; + const header = request.headers.get("authorization") ?? ""; + const presented = header.replace(/^Bearer\s+/i, ""); + if (presented.length !== token.length) return false; + // Constant time, so a wrong token leaks nothing about the right one. + let diff = 0; + for (let i = 0; i < token.length; i += 1) { + diff |= token.charCodeAt(i) ^ presented.charCodeAt(i); + } + return diff === 0; +}; + +/** Top-level comments first, each with its replies in posting order. */ +const nest = (rows: CommentRow[]): Comment[] => { + const byId = new Map(); + const roots: Comment[] = []; + rows.forEach((row) => { + byId.set(row.id, { + id: row.id, + parentId: row.parent_id, + author: row.author, + body: row.body, + role: row.role, + createdAt: row.created_at, + replies: [], + }); + }); + rows.forEach((row) => { + const comment = byId.get(row.id)!; + const parent = row.parent_id === null ? null : byId.get(row.parent_id); + if (parent) parent.replies!.push(comment); + else roots.push(comment); + }); + return roots; +}; + +const readCounts = async (request: Request, url: URL) => { + const requested = (url.searchParams.get("slugs") ?? "") + .split(",") + .map((slug) => slug.trim()) + .filter(Boolean); + if (requested.length > MAX_SLUGS_PER_REQUEST) { + return fail(request, 400, "Too many slugs."); + } + if (requested.some((slug) => !isSlug(slug))) { + return fail(request, 400, "Bad slug."); + } + + const result = requested.length + ? await sqlite.execute({ + sql: `SELECT c.slug AS slug, COUNT(*) AS total FROM ${TABLE} c ${VISIBLE_JOIN} + AND c.slug IN (${requested.map(() => "?").join(",")}) + GROUP BY c.slug`, + args: requested, + }) + : await sqlite.execute( + `SELECT c.slug AS slug, COUNT(*) AS total FROM ${TABLE} c ${VISIBLE_JOIN} GROUP BY c.slug`, + ); + + const counts: Record = {}; + requested.forEach((slug) => { + counts[slug] = 0; + }); + toObjects<{ slug: string; total: number }>(result as ResultSet).forEach( + (row) => { + counts[row.slug] = Number(row.total); + }, + ); + return json(request, { counts }); +}; + +const readThread = async (request: Request, url: URL) => { + const slug = url.searchParams.get("slug"); + if (!isSlug(slug)) return fail(request, 400, "Bad slug."); + + const result = await sqlite.execute({ + sql: `SELECT c.id AS id, c.slug AS slug, c.parent_id AS parent_id, + c.author AS author, c.body AS body, c.role AS role, + c.created_at AS created_at + FROM ${TABLE} c ${VISIBLE_JOIN} AND c.slug = ? ORDER BY c.id ASC`, + args: [slug], + }); + const rows = toObjects(result as ResultSet); + return json(request, { slug, count: rows.length, comments: nest(rows) }); +}; + +const postComment = async (request: Request) => { + let payload: Record; + try { + payload = (await request.json()) as Record; + } catch { + return fail(request, 400, "Expected JSON."); + } + + const { slug, author, body, parentId, turnstileToken } = payload; + if (!isSlug(slug)) return fail(request, 400, "Bad slug."); + + const cleanAuthor = typeof author === "string" ? author.trim() : ""; + const cleanText = typeof body === "string" ? cleanBody(body) : ""; + if (!cleanAuthor || cleanAuthor.length > MAX_AUTHOR) { + return fail(request, 400, "Add a name, up to 60 characters."); + } + if (!cleanText || cleanText.length > MAX_BODY) { + return fail(request, 400, "Add a comment, up to 2000 characters."); + } + + const admin = isAdmin(request); + const role: Role = admin ? "team" : "reader"; + if (!admin) { + if (typeof turnstileToken !== "string" || !turnstileToken) { + return fail(request, 400, "Missing challenge."); + } + if (!(await verifyTurnstile(turnstileToken, request))) { + return fail(request, 403, "The challenge didn't pass."); + } + } + + const ipHash = await hashIp(request); + if (!admin) { + const recent = await sqlite.execute({ + sql: `SELECT COUNT(*) AS total FROM ${TABLE} + WHERE ip_hash = ? AND role != 'team' AND created_at > datetime('now', ?)`, + args: [ipHash, `-${RATE_LIMIT_MINUTES} minutes`], + }); + const [{ total }] = toObjects<{ total: number }>(recent as ResultSet); + if (Number(total) >= RATE_LIMIT_COMMENTS) { + return fail(request, 429, "That's a lot of comments. Try again shortly."); + } + } + + // Threads are one level deep: a reply to a reply joins the same thread, so + // the conversation stays readable on a phone. + let parent: number | null = null; + if (typeof parentId === "number" && Number.isInteger(parentId)) { + const found = await sqlite.execute({ + sql: `SELECT id, parent_id FROM ${TABLE} WHERE id = ? AND slug = ? AND hidden = 0`, + args: [parentId, slug], + }); + const rows = toObjects<{ id: number; parent_id: number | null }>( + found as ResultSet, + ); + if (!rows.length) return fail(request, 404, "That comment is gone."); + parent = rows[0].parent_id === null ? rows[0].id : rows[0].parent_id; + } + + const createdAt = new Date().toISOString(); + const inserted = await sqlite.execute({ + sql: `INSERT INTO ${TABLE} (slug, parent_id, author, body, role, created_at, ip_hash) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [slug, parent, cleanAuthor, cleanText, role, createdAt, ipHash], + }); + + const comment: Comment = { + id: Number( + (inserted as { lastInsertRowid?: number | bigint }).lastInsertRowid ?? 0, + ), + parentId: parent, + author: cleanAuthor, + body: cleanText, + role, + createdAt, + }; + return json(request, { comment }, 201); +}; + +const moderate = async (request: Request) => { + if (!isAdmin(request)) return fail(request, 401, "Nope."); + const { id, hidden } = (await request.json()) as { + id?: number; + hidden?: boolean; + }; + if (typeof id !== "number") return fail(request, 400, "Which comment?"); + await sqlite.execute({ + sql: `UPDATE ${TABLE} SET hidden = ? WHERE id = ?`, + args: [hidden === false ? 0 : 1, id], + }); + return json(request, { ok: true }); +}; + +export default async function (request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders(request) }); + } + + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + + try { + await init(); + if (request.method === "GET" && path === "/counts") { + return await readCounts(request, url); + } + if (request.method === "GET" && path === "/thread") { + return await readThread(request, url); + } + if (request.method === "POST" && path === "/comments") { + return await postComment(request); + } + if (request.method === "POST" && path === "/moderate") { + return await moderate(request); + } + return fail(request, 404, "No such endpoint."); + } catch (error) { + console.error(error); + return fail(request, 500, "Something went wrong."); + } +} From b23ad18e3dcf3a50f49361d164858eb77a7710e5 Mon Sep 17 00:00:00 2001 From: Jesse Ditson Date: Tue, 15 Sep 2026 09:27:14 -0700 Subject: [PATCH 2/2] updates from review --- build.mjs | 8 ++++++-- define.d.ts | 1 + pages/update.liquid | 8 ++------ src/comments.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/build.mjs b/build.mjs index 0b6feef..a1cf162 100755 --- a/build.mjs +++ b/build.mjs @@ -27,13 +27,16 @@ const EDITOR_URL = dev const COMMENTS_URL = process.env.COMMENTS_URL ?? (dev || staging - ? "https://jesseditson-archival_comments_staging.web.val.run" - : "https://jesseditson-archival_comments.web.val.run"); + ? "https://archival-update-comments.web.val.run" + : "https://archival-update-comments.web.val.run"); const TURNSTILE_SITE_KEY = dev ? "1x00000000000000000000AA" : staging ? "0x4AAAAAAEZffDvzgSX4xffU" : "0x4AAAAAAEZfb1ZNwVKPq0Eq"; +const COMMENTS_TURNSTILE_SITE_KEY = dev + ? "1x00000000000000000000AA" + : "0x4AAAAAAE2bl5C1gBUENCco"; const ctx = await esbuild.context({ entryPoints: { @@ -51,6 +54,7 @@ const ctx = await esbuild.context({ API_URL: `"${API_URL}"`, TURNSTILE_SITE_KEY: `"${TURNSTILE_SITE_KEY}"`, COMMENTS_URL: `"${COMMENTS_URL}"`, + COMMENTS_TURNSTILE_SITE_KEY: `"${COMMENTS_TURNSTILE_SITE_KEY}"`, }, format: "esm", target: "es2022", diff --git a/define.d.ts b/define.d.ts index 08bea67..f572f6e 100644 --- a/define.d.ts +++ b/define.d.ts @@ -8,3 +8,4 @@ declare const API_URL: string; declare const TURNSTILE_SITE_KEY: string; /** Origin of the Val Town val that stores comments on updates. */ declare const COMMENTS_URL: string; +declare const COMMENTS_TURNSTILE_SITE_KEY: string; diff --git a/pages/update.liquid b/pages/update.liquid index ca108b9..c761f0b 100644 --- a/pages/update.liquid +++ b/pages/update.liquid @@ -26,13 +26,9 @@ {{ update.body }} - {% comment %} - The thread itself is fetched from the comments val once this section - nears the viewport, so an update that is only read costs no request. - {% endcomment %}

    Comments

    -

    Add something we missed, ask a question, or disagree. Just be cool.

    +

    Add your thoughts

    Loading comments…

    @@ -85,7 +81,7 @@
    diff --git a/src/comments.ts b/src/comments.ts index 91fb4ca..23d1c69 100644 --- a/src/comments.ts +++ b/src/comments.ts @@ -381,7 +381,7 @@ export const setupCommentThread = () => { }).then(() => { widgetId ??= turnstile()?.render(challenge, { - sitekey: TURNSTILE_SITE_KEY, + sitekey: COMMENTS_TURNSTILE_SITE_KEY, action: TURNSTILE_ACTION, execution: "execute", appearance: "interaction-only",