diff --git a/build.mjs b/build.mjs
index 020bb3d..a1cf162 100755
--- a/build.mjs
+++ b/build.mjs
@@ -22,11 +22,21 @@ 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://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: {
@@ -43,6 +53,8 @@ const ctx = await esbuild.context({
EDITOR_URL: `"${EDITOR_URL}"`,
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 134a943..f572f6e 100644
--- a/define.d.ts
+++ b/define.d.ts
@@ -6,3 +6,6 @@ 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;
+declare const COMMENTS_TURNSTILE_SITE_KEY: string;
diff --git a/pages/update.liquid b/pages/update.liquid
index 3105a69..c761f0b 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,65 @@
{{ update.body }}
+
+
{% 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..23d1c69
--- /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: COMMENTS_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.");
+ }
+}
Comments
+Add your thoughts
+ +Loading comments…
+ ++ + + + +