web: composer-key + text-formatting helpers, run all JS suites
ui.js (shouldSend) and format.js (tokenizeText, formatBytes), each with tests; npm test now runs all three suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+182
−1
package.json+1 −1test/format.test.mjs+79 −0test/ui.test.mjs+38 −0web/static/format.js+54 −0web/static/ui.js+10 −0
diff --git a/package.json b/package.json| index 4bafcfb..6117162 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs" |
| } |
| } |
diff --git a/test/format.test.mjs b/test/format.test.mjs| new file mode 100644 |
| index 0000000..1eee543 |
| --- /dev/null |
| +++ b/test/format.test.mjs |
| @@ -0,0 +1,79 @@ |
| +// Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { tokenizeText, formatBytes } from "../web/static/format.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +// Reconstructing the token texts must always equal the input (no data lost). |
| +function roundtrip(text) { |
| + return tokenizeText(text) |
| + .map((t) => t.text) |
| + .join(""); |
| +} |
| + |
| +await test("plain text has no links and round-trips", () => { |
| + const toks = tokenizeText("just a normal message"); |
| + assert.equal(toks.length, 1); |
| + assert.equal(toks[0].href, undefined); |
| + assert.equal(roundtrip("just a normal message"), "just a normal message"); |
| +}); |
| + |
| +await test("https URL becomes a link", () => { |
| + const toks = tokenizeText("see https://example.com/page now"); |
| + const link = toks.find((t) => t.href); |
| + assert.equal(link.text, "https://example.com/page"); |
| + assert.equal(link.href, "https://example.com/page"); |
| +}); |
| + |
| +await test("bare www. gets an https href", () => { |
| + const link = tokenizeText("go to www.example.com").find((t) => t.href); |
| + assert.equal(link.text, "www.example.com"); |
| + assert.equal(link.href, "https://www.example.com"); |
| +}); |
| + |
| +await test("trailing punctuation is not part of the link", () => { |
| + const toks = tokenizeText("look at https://example.com."); |
| + const link = toks.find((t) => t.href); |
| + assert.equal(link.href, "https://example.com"); |
| + assert.equal(roundtrip("look at https://example.com."), "look at https://example.com."); |
| +}); |
| + |
| +await test("a URL in parentheses keeps the paren out of the link", () => { |
| + const toks = tokenizeText("(https://example.com)"); |
| + const link = toks.find((t) => t.href); |
| + assert.equal(link.href, "https://example.com"); |
| + assert.equal(roundtrip("(https://example.com)"), "(https://example.com)"); |
| +}); |
| + |
| +await test("javascript: scheme is NOT linkified", () => { |
| + const toks = tokenizeText("javascript:alert(1)"); |
| + assert.ok(!toks.some((t) => t.href)); |
| +}); |
| + |
| +await test("multiple links in one message", () => { |
| + const links = tokenizeText("a https://one.com b http://two.com c").filter((t) => t.href); |
| + assert.equal(links.length, 2); |
| +}); |
| + |
| +await test("newlines and text are preserved around links", () => { |
| + const input = "line1\nhttps://x.com\nline3"; |
| + assert.equal(roundtrip(input), input); |
| +}); |
| + |
| +await test("formatBytes is human readable", () => { |
| + assert.equal(formatBytes(500), "500 B"); |
| + assert.equal(formatBytes(1536), "1.5 KB"); |
| + assert.equal(formatBytes(5 * 1024 * 1024), "5.0 MB"); |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| new file mode 100644 |
| index 0000000..e058042 |
| --- /dev/null |
| +++ b/test/ui.test.mjs |
| @@ -0,0 +1,38 @@ |
| +// Tests for composer key handling. node test/ui.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { shouldSend } from "../web/static/ui.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +await test("desktop: Enter sends", () => { |
| + assert.equal(shouldSend({ key: "Enter", shiftKey: false }, false), true); |
| +}); |
| + |
| +await test("desktop: Shift+Enter is a newline (does not send)", () => { |
| + assert.equal(shouldSend({ key: "Enter", shiftKey: true }, false), false); |
| +}); |
| + |
| +await test("mobile: Enter is a newline (does not send)", () => { |
| + assert.equal(shouldSend({ key: "Enter", shiftKey: false }, true), false); |
| +}); |
| + |
| +await test("mobile: Shift+Enter is a newline (does not send)", () => { |
| + assert.equal(shouldSend({ key: "Enter", shiftKey: true }, true), false); |
| +}); |
| + |
| +await test("other keys never send", () => { |
| + assert.equal(shouldSend({ key: "a", shiftKey: false }, false), false); |
| + assert.equal(shouldSend({ key: "Tab", shiftKey: false }, false), false); |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/format.js b/web/static/format.js| new file mode 100644 |
| index 0000000..08908a2 |
| --- /dev/null |
| +++ b/web/static/format.js |
| @@ -0,0 +1,54 @@ |
| +// Text formatting helpers. Pure functions so they can be unit-tested in Node; |
| +// the DOM rendering that consumes these lives in chat.js. |
| + |
| +// Matches http(s):// URLs and bare www. hosts. Deliberately narrow: only these |
| +// become links, so things like "javascript:" or "mailto:" are never linkified. |
| +const URL_RE = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi; |
| + |
| +// Trailing punctuation that's almost certainly sentence punctuation, not URL. |
| +const TRAILING = /[.,!?;:'")\]}]+$/; |
| + |
| +// tokenizeText splits a message into an ordered list of tokens: |
| +// { text } → plain text (render as a text node) |
| +// { text, href } → a link (render as an <a>, href is always http(s)://) |
| +// Splitting (rather than building HTML) keeps rendering XSS-safe: callers only |
| +// ever create text nodes and anchors with a validated href. |
| +export function tokenizeText(text) { |
| + text = text == null ? "" : String(text); |
| + const tokens = []; |
| + let last = 0; |
| + for (const m of text.matchAll(URL_RE)) { |
| + const match = m[0]; |
| + const offset = m.index; |
| + if (offset > last) tokens.push({ text: text.slice(last, offset) }); |
| + |
| + let url = match; |
| + let trail = ""; |
| + const tm = url.match(TRAILING); |
| + if (tm) { |
| + trail = tm[0]; |
| + url = url.slice(0, url.length - trail.length); |
| + } |
| + const href = /^https?:\/\//i.test(url) ? url : "https://" + url; |
| + tokens.push({ text: url, href }); |
| + if (trail) tokens.push({ text: trail }); |
| + last = offset + match.length; |
| + } |
| + if (last < text.length) tokens.push({ text: text.slice(last) }); |
| + if (tokens.length === 0) tokens.push({ text: "" }); |
| + return tokens; |
| +} |
| + |
| +// Human-readable file size, e.g. 1536 → "1.5 KB". |
| +export function formatBytes(n) { |
| + if (!Number.isFinite(n) || n < 0) return ""; |
| + if (n < 1024) return n + " B"; |
| + const units = ["KB", "MB", "GB"]; |
| + let v = n / 1024; |
| + let i = 0; |
| + while (v >= 1024 && i < units.length - 1) { |
| + v /= 1024; |
| + i++; |
| + } |
| + return (v < 10 ? v.toFixed(1) : Math.round(v)) + " " + units[i]; |
| +} |
diff --git a/web/static/ui.js b/web/static/ui.js| new file mode 100644 |
| index 0000000..c4e9c66 |
| --- /dev/null |
| +++ b/web/static/ui.js |
| @@ -0,0 +1,10 @@ |
| +// Small, framework-free UI helpers, kept here so they can be unit-tested in Node. |
| + |
| +// shouldSend decides whether a keydown in the composer should send the message. |
| +// |
| +// Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline. |
| +// Mobile/touch (coarse pointer): Enter always inserts a newline; you send with |
| +// the on-screen button instead. `coarse` is matchMedia('(pointer: coarse)'). |
| +export function shouldSend(e, coarse) { |
| + return e.key === "Enter" && !e.shiftKey && !coarse; |
| +} |