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 @@