A lone link's preview is looked up where the server filed it

The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.

- grammar.json gains a urls section: what the server finds in a body, and the
  link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
  LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
  as it does the tint.

Fixes #5399. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:30:32 -04:00
co-authored by Claude Opus 5.5
parent 3766d59b2e
commit 1c4bf56058
7 changed files with 99 additions and 13 deletions
+4 -7
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import { rendersInline } from "../notes/attachments";
import { loneUrl } from "../notes/links";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -84,18 +85,14 @@ const PREVIEW_LINES = 8;
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
// gets a compact strip at the bottom instead. What counts as lone is notes/links.ts.
const isLoneUrl = computed(() => loneUrl(props.note.body) !== null && !props.note.items.length);
/** The preview for a lone-URL note — null while it is still being fetched, or if it
* could never be fetched at all. */
const loneUrlPreview = computed(() => {
if (!isLoneUrl.value) return null;
const url = props.note.body.trim();
const url = loneUrl(props.note.body);
return props.note.previews.find((p) => p.url === url) ?? null;
});
+7
View File
@@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import fixture from "../../../core/testdata/grammar.json";
import { DERIVED_TINT_KEYS, derivedTint, resolveLabelColor, tintHash } from "./colors";
import { parseInline, parseTaskLine, renderTaskLine } from "./markdown";
import { loneUrl } from "./links";
import { RECURRENCES } from "./recurrence";
describe("checklist lines", () => {
@@ -59,3 +60,9 @@ describe("recurrences", () => {
expect(RECURRENCES.map((r) => r.value)).toEqual(fixture.recurrences);
});
});
describe("links", () => {
it.each(fixture.urls)("lone link in $body", ({ body, lone }) => {
expect(loneUrl(body)).toBe(lone);
});
});
+17
View File
@@ -0,0 +1,17 @@
// A note that is nothing but a link renders as a link card.
//
// The characters a link runs over, and the punctuation trimmed off its end, are the
// server's (unfurl_queue.detect_urls): the server files the preview under the trimmed
// link, so the card has to look it up under the same one (#5399). Android's
// LinkPreviewRow holds the same rule, and all three run the `urls` cases in
// core/testdata/grammar.json.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
/** The link a lone-link note's preview is filed under, or null for any other body. */
export function loneUrl(body: string): string | null {
const match = LONE_URL_RE.exec(body);
if (!match) return null;
return match[1].replace(/[.,;:!?]+$/, "") || null;
}