diff --git a/components/frontend/src/lib/components/hackathon/AnswerText.svelte b/components/frontend/src/lib/components/hackathon/AnswerText.svelte
new file mode 100644
index 00000000..40a69fe2
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/AnswerText.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+{#if typeof value === 'boolean'}{value
+ ? 'Yes'
+ : 'No'}{:else}{#each segments as segment, i (i)}{#if segment.kind === 'link'}{segment.href}{:else}{segment.value}{/if}{/each}{/if}
+
diff --git a/components/frontend/src/lib/components/hackathon/AnswerText.test.ts b/components/frontend/src/lib/components/hackathon/AnswerText.test.ts
new file mode 100644
index 00000000..68b8b651
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/AnswerText.test.ts
@@ -0,0 +1,59 @@
+import { render, screen } from "@testing-library/svelte"
+import { describe, expect, it } from "vitest"
+
+import AnswerText from "./AnswerText.svelte"
+
+/*
+ * The splitting is covered in utils/linkify.test.ts. What is left to prove here
+ * is what only rendering can show: that a link opens away from the app without
+ * handing the opener over, and that packing the markup tight really does leave
+ * the sentence intact — a stray newline between the tags would put a space in
+ * the middle of somebody's answer, and no unit test of the splitter would see
+ * it.
+ */
+
+describe("AnswerText", () => {
+ it("says a tick-box answer in words", () => {
+ const { container } = render(AnswerText, { props: { value: true } })
+ expect(container.textContent).toBe("Yes")
+ })
+
+ it("says the other tick-box answer in words", () => {
+ const { container } = render(AnswerText, { props: { value: false } })
+ expect(container.textContent).toBe("No")
+ })
+
+ it("prints an answer with no address as it was written", () => {
+ const answer = "I have been to three of these."
+ const { container } = render(AnswerText, { props: { value: answer } })
+ expect(container.textContent).toBe(answer)
+ expect(container.querySelector("a")).toBeNull()
+ })
+
+ it("links an address and leaves the sentence around it unchanged", () => {
+ const answer = "mine is https://example.dev/me, come and look."
+ const { container } = render(AnswerText, { props: { value: answer } })
+
+ expect(container.textContent).toBe(answer)
+
+ const link = screen.getByRole("link")
+ expect(link).toHaveAttribute("href", "https://example.dev/me")
+ expect(link).toHaveTextContent("https://example.dev/me")
+ })
+
+ it("opens a link away from the app without handing over the opener", () => {
+ render(AnswerText, { props: { value: "https://example.dev" } })
+
+ const link = screen.getByRole("link")
+ expect(link).toHaveAttribute("target", "_blank")
+ expect(link).toHaveAttribute("rel", "noopener noreferrer")
+ })
+
+ it("puts markup in an answer on the page as text", () => {
+ const answer = "bold and https://example.dev"
+ const { container } = render(AnswerText, { props: { value: answer } })
+
+ expect(container.textContent).toBe(answer)
+ expect(container.querySelector("b")).toBeNull()
+ })
+})
diff --git a/components/frontend/src/lib/utils/linkify.test.ts b/components/frontend/src/lib/utils/linkify.test.ts
new file mode 100644
index 00000000..13f1dbd9
--- /dev/null
+++ b/components/frontend/src/lib/utils/linkify.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest"
+
+import { linkify } from "./linkify"
+
+/** The hrefs found, in order — most cases only care about these. */
+const links = (text: string) =>
+ linkify(text)
+ .filter((s) => s.kind === "link")
+ .map((s) => s.href)
+
+/** Everything a reader would see, links included, back as one string. */
+const spoken = (text: string) =>
+ linkify(text)
+ .map((s) => (s.kind === "link" ? s.href : s.value))
+ .join("")
+
+describe("linkify", () => {
+ it("leaves an answer with no address as one run of text", () => {
+ expect(linkify("I have been to three of these.")).toEqual([
+ { kind: "text", value: "I have been to three of these." },
+ ])
+ })
+
+ it("has nothing to say about an empty answer", () => {
+ expect(linkify("")).toEqual([])
+ })
+
+ it("finds an address on its own", () => {
+ expect(linkify("https://example.dev")).toEqual([
+ { kind: "link", href: "https://example.dev" },
+ ])
+ })
+
+ it("keeps the words on either side of an address", () => {
+ expect(linkify("see https://example.dev for more")).toEqual([
+ { kind: "text", value: "see " },
+ { kind: "link", href: "https://example.dev" },
+ { kind: "text", value: " for more" },
+ ])
+ })
+
+ it("finds every address in an answer", () => {
+ expect(links("https://a.dev and http://b.dev/x?y=1")).toEqual([
+ "https://a.dev",
+ "http://b.dev/x?y=1",
+ ])
+ })
+
+ it("leaves the full stop to the sentence", () => {
+ expect(linkify("mine is https://example.dev/me.")).toEqual([
+ { kind: "text", value: "mine is " },
+ { kind: "link", href: "https://example.dev/me" },
+ { kind: "text", value: "." },
+ ])
+ })
+
+ it("leaves a comma between two addresses to the sentence", () => {
+ expect(links("https://a.dev, https://b.dev")).toEqual([
+ "https://a.dev",
+ "https://b.dev",
+ ])
+ })
+
+ it("keeps parens the address opened and drops the ones it did not", () => {
+ expect(links("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([
+ "https://en.wikipedia.org/wiki/Foo_(bar)",
+ ])
+ })
+
+ it("drops a closing paren and the full stop after it", () => {
+ expect(links("(https://example.dev/a).")).toEqual(["https://example.dev/a"])
+ })
+
+ it("refuses a scheme that is not a way of fetching a page", () => {
+ // The whole point of the http(s)-only pattern: this is never a candidate,
+ // so it cannot be a link however the rest of the answer is written.
+ expect(links("javascript:alert(1)")).toEqual([])
+ expect(links("data:text/html,")).toEqual([])
+ expect(links("file:///etc/passwd")).toEqual([])
+ })
+
+ it("leaves markup in an answer as the text it is", () => {
+ const answer = " https://example.dev"
+ expect(links(answer)).toEqual(["https://example.dev"])
+ expect(spoken(answer)).toBe(answer)
+ })
+
+ it("does not take a scheme with no host for an address", () => {
+ expect(links("write https:// in front of it")).toEqual([])
+ })
+
+ it("does not mistake a filename for an address", () => {
+ expect(links("I mostly write node.js and some main.go")).toEqual([])
+ })
+
+ it("gives back every character it was handed", () => {
+ const answer =
+ "repo: https://github.com/me/x (see the README), site https://me.dev/. thanks!"
+ expect(spoken(answer)).toBe(answer)
+ })
+})
diff --git a/components/frontend/src/lib/utils/linkify.ts b/components/frontend/src/lib/utils/linkify.ts
new file mode 100644
index 00000000..ed450029
--- /dev/null
+++ b/components/frontend/src/lib/utils/linkify.ts
@@ -0,0 +1,113 @@
+// A registration answer is free text, and the commonest useful thing somebody
+// puts in one is an address — their repo, their portfolio, the paper they want
+// to work from. Until now every reader had to select it and copy it by hand.
+//
+// This splits an answer into the runs that are addresses and the runs that are
+// not, so a component can render the first as anchors and leave the rest as
+// text. It deliberately produces *segments* rather than HTML: nothing here can
+// end up inside `{@html}`, so an answer can never carry markup onto the page.
+// That is the whole reason it is not routed through the markdown pipeline in
+// `./markdown`, which is for text an organizer wrote knowing it was markdown.
+
+import { isHttpUrl } from "./url"
+
+export type AnswerSegment =
+ | { kind: "text"; value: string }
+ /** `href` is also the visible text: see `linkify` on why they never differ. */
+ | { kind: "link"; href: string }
+
+/**
+ * A run that might be an address. Only the two schemes that can be followed
+ * safely are recognised at all, which is what keeps a `javascript:` or `data:`
+ * string from ever reaching an `href` — it is not that such a string is
+ * rejected later, it is that it is never a candidate.
+ *
+ * Angle brackets and quotes end the run because they are how somebody encloses
+ * a URL rather than part of one.
+ */
+const URL_CANDIDATE = /https?:\/\/[^\s<>"'`]+/g
+
+/** Punctuation that ends the sentence rather than the address. */
+const SENTENCE_ENDINGS = ".,;:!?'\""
+
+/** A closer only belongs to the URL if the URL opened it. */
+const CLOSERS: Record = { ")": "(", "]": "[", "}": "{" }
+
+const occurrences = (text: string, char: string): number => {
+ let n = 0
+ for (const c of text) if (c === char) n++
+ return n
+}
+
+/**
+ * Gives back the trailing characters that belong to the sentence.
+ *
+ * `see https://x.dev/a.` should not link the full stop, and
+ * `(https://en.wikipedia.org/wiki/Foo_(bar))` should keep its inner parens and
+ * drop only the outer one — hence counting the pair rather than stripping every
+ * closer. Repeats until nothing more comes off, so `(https://x.dev/a).` loses
+ * both.
+ */
+function trimSentence(candidate: string): string {
+ let url = candidate
+
+ for (;;) {
+ const last = url.at(-1)
+ if (last === undefined) return url
+
+ if (SENTENCE_ENDINGS.includes(last)) {
+ url = url.slice(0, -1)
+ continue
+ }
+
+ const opener = CLOSERS[last]
+ if (opener && occurrences(url, last) > occurrences(url, opener)) {
+ url = url.slice(0, -1)
+ continue
+ }
+
+ return url
+ }
+}
+
+/**
+ * One answer, split into the parts that are addresses and the parts that are
+ * not. Text with no address in it comes back as a single text segment, which is
+ * the common case and renders exactly as it did before this existed.
+ *
+ * A link segment carries only its `href`, because the address as typed is also
+ * what is shown. That is not laziness: it means an answer cannot display one
+ * host while pointing at another, which is the trick a linkifier that accepted
+ * a label would hand to anybody filling in a form.
+ */
+export function linkify(text: string): AnswerSegment[] {
+ const segments: AnswerSegment[] = []
+ let cursor = 0
+
+ URL_CANDIDATE.lastIndex = 0
+ for (let match = URL_CANDIDATE.exec(text); match; ) {
+ const href = trimSentence(match[0])
+
+ // `https://` with nothing after it, or a run that trimmed down to one.
+ // Left as the text it is, and the scan resumes after it.
+ if (!isHttpUrl(href)) {
+ match = URL_CANDIDATE.exec(text)
+ continue
+ }
+
+ const before = text.slice(cursor, match.index)
+ if (before) segments.push({ kind: "text", value: before })
+ segments.push({ kind: "link", href })
+
+ // Past the address but not past the punctuation that followed it — that is
+ // still text somebody wrote, and the next address may be inside it.
+ cursor = match.index + href.length
+ URL_CANDIDATE.lastIndex = cursor
+ match = URL_CANDIDATE.exec(text)
+ }
+
+ const rest = text.slice(cursor)
+ if (rest) segments.push({ kind: "text", value: rest })
+
+ return segments
+}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/registration/answers/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/registration/answers/+page.svelte
index 49941148..0c0557e6 100644
--- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/registration/answers/+page.svelte
+++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/forms/registration/answers/+page.svelte
@@ -1,19 +1,11 @@