Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions components/frontend/src/lib/components/hackathon/AnswerText.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { linkify } from '$lib/utils/linkify';

/** One answer as the backend sent it: a tick-box is a bool, the rest text. */
let { value }: { value: string | boolean } = $props();

const segments = $derived(typeof value === 'string' ? linkify(value) : []);
</script>

<!--
Every place an answer's value is printed goes through here, so there is one
answer to "what does an answer look like on a page" rather than one per view.

The link colour and underline match `.markdown-body a` in the theme, because a
link is a link wherever a reader meets one. The `target`/`rel` pair is the same
policy the markdown sanitizer applies in its `afterSanitizeAttributes` hook —
restated here rather than shared, since this path deliberately never builds an
HTML string for DOMPurify to clean. Nothing below is `{@html}`: Svelte escapes
the text segments, and the only strings that reach an `href` are ones that
matched an http(s) pattern.

The markup is packed tight on purpose. Whitespace between these tags would be
rendered, putting a space in the middle of somebody's sentence wherever a link
starts or ends.
-->
<!-- eslint-disable svelte/no-navigation-without-resolve -- an answer's address is
always off-site; resolve() is for this app's own routes. Disabled over the
whole template because the anchor cannot carry a line of its own: a comment
between these tags is fine, but the newline before it is not. -->
{#if typeof value === 'boolean'}{value
? 'Yes'
: 'No'}{:else}{#each segments as segment, i (i)}{#if segment.kind === 'link'}<a
href={segment.href}
target="_blank"
rel="noopener noreferrer"
class="break-all text-accent-ink underline underline-offset-[0.15em]"
>{segment.href}</a
>{:else}{segment.value}{/if}{/each}{/if}
<!-- eslint-enable svelte/no-navigation-without-resolve -->
Original file line number Diff line number Diff line change
@@ -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 = "<b>bold</b> and https://example.dev"
const { container } = render(AnswerText, { props: { value: answer } })

expect(container.textContent).toBe(answer)
expect(container.querySelector("b")).toBeNull()
})
})
101 changes: 101 additions & 0 deletions components/frontend/src/lib/utils/linkify.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>")).toEqual([])
expect(links("file:///etc/passwd")).toEqual([])
})

it("leaves markup in an answer as the text it is", () => {
const answer = "<script>alert(1)</script> 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)
})
})
113 changes: 113 additions & 0 deletions components/frontend/src/lib/utils/linkify.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { ")": "(", "]": "[", "}": "{" }

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
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AnswerText from '$lib/components/hackathon/AnswerText.svelte';
import { questionKindLabel } from '$lib/utils/question';
import type { PageData } from './$types';

let { data }: { data: PageData } = $props();

// A tick-box answer is a boolean and has to be said in words; text and enum
// answers are already the words. Yes/No matches the tally above the list, so
// the two halves of a tick-box question read the same way.
function spoken(value: string | boolean): string {
if (typeof value === 'boolean') return value ? 'Yes' : 'No';

return value;
}

const people = $derived(
data.rosterSize === 1 ? '1 participant' : `${data.rosterSize} participants`
);
Expand Down Expand Up @@ -122,7 +114,9 @@
<span class="text-ink-2 {answer.departed ? 'italic' : ''}">
{answer.name}
</span>
<span class="text-ink">{spoken(answer.value)}</span>
<span class="min-w-0 break-words text-ink">
<AnswerText value={answer.value} />
</span>
</li>
{/each}
</ul>
Expand All @@ -142,7 +136,7 @@
answer is a sentence somebody wrote. Wrapped so a
pasted paragraph does not stretch the card. -->
<p class="prose m-0 text-sm break-words text-ink">
{spoken(answer.value)}
<AnswerText value={answer.value} />
</p>
</li>
{/each}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AnswerText from '$lib/components/hackathon/AnswerText.svelte';
import type { PageData } from './$types';

let { data }: { data: PageData } = $props();
Expand Down Expand Up @@ -152,12 +153,11 @@
{answer.label}
</dt>
<dd class="m-0 flex flex-wrap items-center gap-2">
<span class="text-body leading-snug text-ink">
{#if typeof answer.value === 'boolean'}
{answer.value ? 'Yes' : 'No'}
{:else}
{answer.value}
{/if}
<!-- `min-w-0` so a pasted address wraps: a flex item
will not shrink below its content, and one long
URL would otherwise widen the whole row. -->
<span class="min-w-0 break-words text-body leading-snug text-ink">
<AnswerText value={answer.value} />
</span>
<!-- Only on your own profile: on anyone else's every
entry is shared, so a chip on all of them says
Expand Down
Loading
Loading