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
304 changes: 292 additions & 12 deletions app/d/[slug]/CommentsShell.tsx

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
allThreads,
} from "@/lib/docs/comments";
import { detectServerTheme } from "@/lib/docs/theme";
import { extractSections } from "@/lib/docs/sections";
import CommentsShell from "./CommentsShell";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -96,6 +97,11 @@ export default async function ViewerPage({ params, searchParams }: Props) {
const anchoredReactions = threadData.anchored_reactions ?? [];
const title = doc.title || doc.slug;

// Section deeplinks: the ordered heading list + stable fragment ids, derived
// from the stored HTML. The shell forwards it to the overlay, which assigns the
// ids to headings in document order and paints the gutter link icon.
const sections = extractSections(doc.html);

// Adaptive chrome (variant D): coarsely detect a DARK doc from the stored
// HTML's unconditional html/body background so the shell renders themed at SSR —
// CommentsShell uses it as the initial theme to avoid a light→dark flash before the
Expand All @@ -121,6 +127,7 @@ export default async function ViewerPage({ params, searchParams }: Props) {
initialThreads={threadData.threads}
initialDocReactions={docReactions}
initialAnchoredReactions={anchoredReactions}
initialSections={sections}
version={doc.version}
initialTheme={serverTheme}
/>
Expand Down
35 changes: 35 additions & 0 deletions lib/docs/deeplink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { fragmentFor, parseHash } from "@/lib/docs/deeplink";

describe("deeplink codec", () => {
it("routes comment / section / empty hashes", () => {
expect(parseHash("#comment-42")).toEqual({ kind: "comment", id: 42 });
expect(parseHash("comment-42")).toEqual({ kind: "comment", id: 42 }); // leading # optional
expect(parseHash("#setup")).toEqual({ kind: "section", id: "setup" });
expect(parseHash("#")).toEqual({ kind: "none" });
expect(parseHash("")).toEqual({ kind: "none" });
});

it("builds comment fragments without encoding and section fragments with it", () => {
expect(fragmentFor({ kind: "comment", id: 7 })).toBe("comment-7");
expect(fragmentFor({ kind: "section", id: "setup" })).toBe("setup");
expect(fragmentFor({ kind: "section", id: "my heading" })).toBe("my%20heading");
});

it("round-trips section ids with spaces, unicode, and reserved characters", () => {
for (const id of ["setup", "my heading", "café", "日本語", "a/b?c#d", "100%", "section-comment-3"]) {
const t = parseHash("#" + fragmentFor({ kind: "section", id }));
expect(t).toEqual({ kind: "section", id });
}
});

it("does not throw on malformed percent-encoding — treats it as a literal section id", () => {
expect(parseHash("#foo%")).toEqual({ kind: "section", id: "foo%" });
expect(parseHash("#%E0%A4%A")).toEqual({ kind: "section", id: "%E0%A4%A" });
});

it("a comment-<digits> hash is a comment, but comment-word is a section", () => {
expect(parseHash("#comment-1")).toEqual({ kind: "comment", id: 1 });
expect(parseHash("#comment-intro")).toEqual({ kind: "section", id: "comment-intro" });
});
});
38 changes: 38 additions & 0 deletions lib/docs/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Deeplink fragment contract — the ONE place the copy side and the read side agree
// on how a section/comment id maps to a URL #fragment. copyLink builds the fragment
// with fragmentFor(); the hash router reads it with parseHash(). Keeping both on one
// codec is what makes permalinks round-trip: encode-on-write must mirror
// decode-on-read, and the routing (comment vs section) must match how ids are
// minted (see lib/docs/sections.ts, which never mints a comment-<n> section id, so
// the comment branch here can never swallow a section link).

export type HashTarget =
| { kind: "comment"; id: number }
| { kind: "section"; id: string }
| { kind: "none" };

// The #fragment for a copyable permalink. Comment permalinks are comment-<id>
// (ASCII, no encoding needed); a section id is author- or slug-derived and may hold
// Unicode, spaces, or reserved characters, so it's percent-encoded — parseHash
// decodes it back.
export function fragmentFor(target: { kind: "comment"; id: number } | { kind: "section"; id: string }): string {
return target.kind === "comment" ? `comment-${target.id}` : encodeURIComponent(target.id);
}

// Parse a URL hash (with or without the leading '#') into a navigation target.
// Decodes percent-encoding; malformed encoding (a lone '%', a truncated escape in a
// pasted link) is treated as a literal section id rather than throwing. An empty
// fragment is "none" (no selection).
export function parseHash(hash: string): HashTarget {
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
let h: string;
try {
h = decodeURIComponent(raw);
} catch {
h = raw;
}
if (!h) return { kind: "none" };
const m = /^comment-(\d+)$/.exec(h);
if (m) return { kind: "comment", id: Number(m[1]) };
return { kind: "section", id: h };
}
118 changes: 112 additions & 6 deletions lib/docs/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ export const OVERLAY_SCRIPT = String.raw`
var byKey = {}; // key -> { item, segEls:[], chipEls:[] }
var activeKey = null; // hover-highlighted key (transient)
var focusKey = null; // focused (pinned) key
var pendingFocusScroll = null; // jh:focus scroll owed for a key not painted yet; applied on next paint()
var lastClickKeys = null; // covering set of the last focus click (for cycle)
var lastClickPos = -1; // doc-text offset of the last focus click (cycle reset on move)
var sections = []; // ordered [{id, level, text}] from jh:sections
var secById = {}; // section id -> heading element (for scrollToSection)
var pendingSection = null; // a scrollToSection requested before sections were applied

function send(msg){ try { parent.postMessage(msg, "*"); } catch(e){} }
function esc(s){ return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
Expand Down Expand Up @@ -263,8 +267,9 @@ export const OVERLAY_SCRIPT = String.raw`
var n = walker.currentNode;
var p = n.parentNode;
if (p && (p.nodeName === "SCRIPT" || p.nodeName === "STYLE")) continue;
// skip text inside our own chips so reaction counts never become anchorable text
if (p && p.closest && p.closest("[data-jh-chip]")) continue;
// skip text inside our own chips / section-anchors so injected UI never
// becomes anchorable text
if (p && p.closest && p.closest("[data-jh-chip],[data-jh-sec-anchor]")) continue;
nodes.push({ node: n, start: full.length });
full += n.nodeValue;
}
Expand Down Expand Up @@ -403,6 +408,83 @@ export const OVERLAY_SCRIPT = String.raw`
(document.head||document.documentElement).appendChild(st);
}

// ---- section deeplinks (heading gutter link icon + scroll-to) ----
// The shell sends the server's ordered section list; we assign each id to the
// heading at the same document-order index (server + DOM agree on order), set
// scroll-margin so a scrolled-to heading isn't flush to the top, and inject a
// hover-reveal link icon in the left gutter. Clicking it asks the shell to copy
// the permalink — clipboard is unreliable from this opaque-origin sandbox, so
// the shell (real origin) does the write. Idempotent: re-running updates in place.
var LINK_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';

function ensureSectionStyle(){
if (document.getElementById("jh-sec-style")) return;
var st = document.createElement("style"); st.id = "jh-sec-style";
st.textContent =
"h1,h2,h3,h4,h5,h6{scroll-margin-top:1.5rem}"
// Inline (not absolute) so it rides the heading's first text line and stays
// vertically centered regardless of the doc's own top padding/border/margin;
// the negative-left + right margins net to zero width so text doesn't shift.
+ "a.jh-sec-anchor{display:inline-flex;align-items:center;justify-content:center;"
+ "width:1em;height:1em;margin:0 .35em 0 -1.35em;vertical-align:middle;opacity:0;"
+ "text-decoration:none;color:currentColor;transition:opacity .12s}"
+ "h1:hover>a.jh-sec-anchor,h2:hover>a.jh-sec-anchor,h3:hover>a.jh-sec-anchor,"
+ "h4:hover>a.jh-sec-anchor,h5:hover>a.jh-sec-anchor,h6:hover>a.jh-sec-anchor{opacity:.5}"
+ "a.jh-sec-anchor:hover,a.jh-sec-anchor:focus{opacity:.9;outline:none}"
+ "@keyframes jh-sec-flash{0%,100%{background-color:transparent}18%{background-color:rgba(245,197,24,.35)}}"
+ ".jh-sec-target{animation:jh-sec-flash 1.6s ease;border-radius:3px}";
(document.head||document.documentElement).appendChild(st);
}

function addSectionAnchor(h, id){
var ex = h.querySelector(":scope > a.jh-sec-anchor");
if (ex){ ex.setAttribute("data-jh-sec", id); ex.setAttribute("href", "#"+id); return; }
var a = document.createElement("a");
a.className = "jh-sec-anchor";
a.setAttribute("data-jh-sec-anchor", "1");
a.setAttribute("data-jh-sec", id);
a.setAttribute("href", "#"+id);
a.setAttribute("aria-label", "Copy link to section");
a.innerHTML = LINK_SVG;
a.addEventListener("click", function(ev){
ev.preventDefault(); ev.stopPropagation();
send({type:"jh:copyLink", id: a.getAttribute("data-jh-sec")});
});
h.insertBefore(a, h.firstChild);
}

function applySections(){
try {
ensureSectionStyle();
var heads = document.querySelectorAll("h1,h2,h3,h4,h5,h6");
secById = {};
var n = Math.min(sections.length, heads.length);
for (var i=0;i<n;i++){
var s = sections[i]; if (!s || !s.id) continue;
var h = heads[i];
h.id = s.id;
secById[s.id] = h;
addSectionAnchor(h, s.id);
}
// Consume a scroll requested before sections existed: resolve it against this
// freshly-applied set and clear it either way, so it can't be retried on a
// LATER applySections (reload / jh:sections update) and scroll to an abandoned
// section after the user has since navigated elsewhere.
if (pendingSection != null){
var ps = pendingSection; pendingSection = null;
if (secById[ps]) scrollToSection(ps);
}
} catch(e){}
}

function scrollToSection(id){
var h = secById[id];
if (!h){ pendingSection = id; return; } // sections not applied yet — honor after applySections
pendingSection = null;
Comment thread
cursor[bot] marked this conversation as resolved.
try { h.scrollIntoView({block:"start", behavior:"smooth"}); } catch(e){ try { h.scrollIntoView(); } catch(e2){} }
try { h.classList.add("jh-sec-target"); setTimeout(function(){ h.classList.remove("jh-sec-target"); }, 1600); } catch(e){}
}

// ---- segment painting (the B14 core) ----
function paint(){
ensureStyle();
Expand Down Expand Up @@ -516,6 +598,17 @@ export const OVERLAY_SCRIPT = String.raw`
if (forcedScheme) markForcedText();
}

// A focus scroll owed for a key that wasn't painted at focus time (a resolved
// thread revealed on a permalink): resolve it against a fresh anchor set. Scroll
// if the segment now exists, and clear the request either way — after jh:anchors
// an absent key is orphaned, so it must not linger and fire on a later repaint.
function consumePendingFocus(){
if (pendingFocusScroll == null) return;
var rec = byKey[pendingFocusScroll];
if (rec && rec.segEls.length) scrollToKey(pendingFocusScroll);
pendingFocusScroll = null;
}

// ---- segment interaction (doc → rail focus model) ----
function attachSegHandlers(span, seg){
span.addEventListener("mouseenter", function(){
Expand Down Expand Up @@ -766,7 +859,7 @@ export const OVERLAY_SCRIPT = String.raw`

window.addEventListener("message", function(ev){
var d = ev.data; if (!d || typeof d !== "object") return;
if (d.type === "jh:anchors"){ anchors = Array.isArray(d.anchors) ? d.anchors : []; paint(); }
if (d.type === "jh:anchors"){ anchors = Array.isArray(d.anchors) ? d.anchors : []; paint(); consumePendingFocus(); }
else if (d.type === "jh:reactions"){
rxGroups = Array.isArray(d.groups) ? d.groups : [];
me = d.me || me;
Expand All @@ -780,11 +873,24 @@ export const OVERLAY_SCRIPT = String.raw`
setHover(key);
}
else if (d.type === "jh:focus"){
// rail → doc: focus a key (card clicked). null clears.
if (d.key == null) clearFocus();
else { var ck = byKey[d.key]; setFocus(d.key, ck ? coverKeysOf(d.key) : [d.key]); if (ck) scrollToKey(d.key); }
// rail → doc: focus a key (card clicked), or null to clear. Either way this
// supersedes a not-yet-resolved section scroll (a comment and a section are
// mutually exclusive selections), so cancel pendingSection.
pendingSection = null;
if (d.key == null) { pendingFocusScroll = null; clearFocus(); }
else {
var ck = byKey[d.key];
setFocus(d.key, ck ? coverKeysOf(d.key) : [d.key]);
// If the segment isn't painted yet (e.g. a resolved thread whose anchor
// arrives in the jh:anchors that FOLLOWS this focus, once showResolved
// flips), defer the scroll to the next paint instead of dropping it.
if (ck) { pendingFocusScroll = null; scrollToKey(d.key); }
else pendingFocusScroll = d.key;
}
}
else if (d.type === "jh:scrollTo"){ var sk = (typeof d.id === "number") ? "c:"+d.id : String(d.id); scrollToKey(sk); }
else if (d.type === "jh:sections"){ sections = Array.isArray(d.sections) ? d.sections : []; applySections(); }
else if (d.type === "jh:scrollToSection"){ scrollToSection(String(d.id)); }
else if (d.type === "jh:clearSelection"){ var s=window.getSelection(); if(s) s.removeAllRanges(); }
else if (d.type === "jh:themeMode"){
forcedScheme = (d.mode === "dark" || d.mode === "light") ? d.mode : null;
Expand Down
86 changes: 86 additions & 0 deletions lib/docs/sections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { extractSections } from "@/lib/docs/sections";

describe("extractSections", () => {
it("slugs heading text and records level", () => {
expect(extractSections("<h1>Hello World</h1>")).toEqual([
{ id: "hello-world", level: 1, text: "Hello World" },
]);
const levels = extractSections(
"<h1>a</h1><h2>b</h2><h3>c</h3><h4>d</h4><h5>e</h5><h6>f</h6>"
).map((s) => s.level);
expect(levels).toEqual([1, 2, 3, 4, 5, 6]);
});

it("uses the text content of nested inline markup", () => {
const [s] = extractSections("<h2>v0 API <code>shape</code></h2>");
expect(s.id).toBe("v0-api-shape");
expect(s.text).toBe("v0 API shape");
});

it("decodes entities and collapses punctuation to a single hyphen", () => {
expect(extractSections("<h2>Tom &amp; Jerry</h2>")[0].id).toBe("tom-jerry");
expect(extractSections("<h2> Multiple spaces </h2>")[0]).toEqual({
id: "multiple-spaces",
level: 2,
text: "Multiple spaces",
});
});

it("de-dupes repeated slugs in document order", () => {
expect(extractSections("<h2>Setup</h2><h3>Setup</h3><h2>Setup</h2>").map((s) => s.id)).toEqual([
"setup",
"setup-1",
"setup-2",
]);
});

it("falls back to section-N for empty slugs (emoji / punctuation only)", () => {
expect(extractSections("<h1>Intro</h1><h2>🎉</h2><h3>!!!</h3>").map((s) => s.id)).toEqual([
"intro",
"section-2",
"section-3",
]);
});

it("prefers an author-provided id and ignores other *id attributes", () => {
expect(extractSections('<h2 id="custom-anchor">Whatever</h2>')[0].id).toBe("custom-anchor");
expect(extractSections('<h2 data-id="x" id="real">Title</h2>')[0].id).toBe("real");
expect(extractSections("<h2>No id here</h2>")[0].id).toBe("no-id-here");
});

it("never mints an id in the reserved comment-<n> namespace", () => {
expect(extractSections("<h2>Comment 12</h2>")[0].id).toBe("section-comment-12");
// Author ids get the same guard, so a heading id can't hijack a #comment-<n>
// permalink; a non-colliding author id is still used verbatim.
expect(extractSections('<h2 id="comment-5">Title</h2>')[0].id).toBe("section-comment-5");
expect(extractSections('<h2 id="intro">Title</h2>')[0].id).toBe("intro");
});

it("keeps non-Latin headings instead of emptying them", () => {
const [s] = extractSections("<h2>日本語</h2>");
expect(s.id).toBe("日本語");
expect(s.level).toBe(2);
});

it("ignores headings inside comments, script, and style", () => {
const html =
"<!-- <h1>hidden</h1> --><style><h1>styled</h1></style>" +
"<script><h1>scripted</h1></script><h1>Real</h1>";
expect(extractSections(html).map((s) => s.id)).toEqual(["real"]);
});

it("ignores headings in template/noscript so ids don't shift vs the rendered DOM", () => {
// Neither reaches the overlay's document.querySelectorAll (template is inert;
// noscript is text while scripting is on), so counting them would misalign the
// id assigned to every later heading. Both real headings keep their clean ids.
const html =
"<h1>First</h1><template><h2>tpl only</h2></template>" +
"<noscript><h2>ns only</h2></noscript><h2>Second</h2>";
expect(extractSections(html).map((s) => s.id)).toEqual(["first", "second"]);
});

it("returns an empty list when there are no headings", () => {
expect(extractSections("<p>just a paragraph</p>")).toEqual([]);
});
});
Loading
Loading