From 077081eda3e9b42b399d11ca932d591e26bda5ba Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:41:02 +0200 Subject: [PATCH] feat(frontend): a link in an answer is a link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration questions are free text, and the useful thing people put in one is an address — their repo, their portfolio, the paper they want to work from. Every reader of those answers had to select the text and copy it by hand, on their own profile, on an organizer's view of one person, and on the page listing what the whole cohort said. Answers now print through one component that splits the text into the runs that are addresses and the runs that are not, and renders the first as anchors. It produces segments rather than HTML, so nothing here goes near `{@html}` and an answer cannot carry markup onto the page; only `http`/`https` is ever recognised, so a `javascript:` string is never a candidate for an href in the first place. The link text is the address as typed, which is what stops an answer displaying one host while pointing at another. Not routed through the markdown pipeline on purpose: nobody told participants their answer is markdown, and running it as such would reformat answers that were never meant as markup. --- .../components/hackathon/AnswerText.svelte | 39 ++++++ .../components/hackathon/AnswerText.test.ts | 59 +++++++++ .../frontend/src/lib/utils/linkify.test.ts | 101 ++++++++++++++++ components/frontend/src/lib/utils/linkify.ts | 113 ++++++++++++++++++ .../forms/registration/answers/+page.svelte | 16 +-- .../participants/[participantId]/+page.svelte | 12 +- .../manage/[participantId]/+page.svelte | 11 +- 7 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 components/frontend/src/lib/components/hackathon/AnswerText.svelte create mode 100644 components/frontend/src/lib/components/hackathon/AnswerText.test.ts create mode 100644 components/frontend/src/lib/utils/linkify.test.ts create mode 100644 components/frontend/src/lib/utils/linkify.ts 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 @@