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
28 changes: 14 additions & 14 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/
import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown";
import type { Section } from "@/lib/docs/sections";
import { fragmentFor, parseHash } from "@/lib/docs/deeplink";
import { readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";
import { readMinutesFor, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";
import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit";

// CommentsShell — the THIRD React surface (birthday.md "Production
Expand Down Expand Up @@ -94,10 +94,6 @@ type Props = {
// Ordered heading list + stable fragment ids for section deeplinks (from
// lib/docs/sections extractSections). Forwarded to the overlay as jh:sections.
initialSections: Section[];
// Estimated minutes to read the doc, from the stored HTML (lib/docs/reading-time).
// 0 = no prose to read, and the bar leaves the chip out entirely. The overlay
// refines this to the VISIBLE text once the doc has settled (jh:readtime).
readMinutes: number;
version: number;
// Coarse SSR theme (from the stored HTML's unconditional html/body bg). Present
// only when the server is confident the doc is dark — gives the shell a dark
Expand Down Expand Up @@ -173,8 +169,11 @@ export default function CommentsShell(props: Props) {
me,
initialSections,
} = props;
// Seeded from the SSR full-text estimate, refined by the overlay's jh:readtime.
const [readMinutes, setReadMinutes] = useState(props.readMinutes);
// Null until the overlay reports what's VISIBLE (jh:readtime). No chip before that
// and none at all if the overlay never runs: a number counted from the stored HTML
// would include hidden tab panels, so showing one first would mean rendering a
// figure we know to be wrong and then correcting it.
const [readMinutes, setReadMinutes] = useState<number | null>(null);
const [bookmarked, setBookmarked] = useState(props.bookmarked);
const bookmarkPending = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
Expand Down Expand Up @@ -469,11 +468,12 @@ export default function CommentsShell(props: Props) {
}
break;
case "jh:readtime":
// The overlay measured the text actually VISIBLE on load (hidden tab panels
// and closed <details> excluded), which the server's regex over the stored
// HTML can't see. Replaces the SSR estimate; a doc whose overlay never runs
// keeps it.
if (typeof d.minutes === "number" && d.minutes >= 0) setReadMinutes(Math.round(d.minutes));
// Word/CJK counts of the text the overlay found VISIBLE. Re-sent as the doc
// settles (a script-built tab UI hides its panels late), so the latest one
// always wins.
if (typeof d.words === "number" && typeof d.cjk === "number") {
setReadMinutes(readMinutesFor(d.words, d.cjk));
}
break;
case "jh:selection":
if ((canComment || canReact) && d.anchor && d.anchor.exact) {
Expand Down Expand Up @@ -938,7 +938,7 @@ export default function CommentsShell(props: Props) {
{title}
</span>
<span style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}>
{readMinutes > 0 ? (
{readMinutes != null && readMinutes > 0 ? (
<span className="jh-readtime" data-level={readTimeLevel(readMinutes)} title={readTimeTitle(readMinutes)}>
{readMinutes} min read
</span>
Expand Down Expand Up @@ -1720,7 +1720,7 @@ const RAIL_CSS = `
(4px radius, hairline border, 4/8px padding). Borders are var()s because dark needs
different ones (paletteVars). The transition repeats CHROME_TRANSITION's values —
that const is declared below this string, so it can't be interpolated — and covers
the bucket change when the overlay refines the estimate. */
a bucket change when a late overlay report moves the estimate. */
.jh-readtime { font: inherit; font-size: 12px; line-height: 1.6; padding: 2px 8px; border-radius: 4px; border: 0.5px solid; transition: background-color .22s ease, color .22s ease, border-color .22s ease; }
.jh-readtime[data-level="ok"] { background: #81b300; color: #212225; border-color: #81b300; }
.jh-readtime[data-level="warn"] { background: #e1dccf; color: #212225; border-color: var(--jh-read-warn-border, #d0d2d9); }
Expand Down
6 changes: 0 additions & 6 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
} from "@/lib/docs/comments";
import { detectServerTheme } from "@/lib/docs/theme";
import { extractSections } from "@/lib/docs/sections";
import { estimateReadMinutes } from "@/lib/docs/reading-time";
import CommentsShell from "./CommentsShell";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -110,10 +109,6 @@ export default async function ViewerPage({ params, searchParams }: Props) {
// ids to headings in document order and paints the gutter link icon.
const sections = extractSections(doc.html);

// Estimated read time for the bar — derived from the stored HTML, like the
// section list. 0 for a doc with no prose (the bar omits it).
const readMinutes = estimateReadMinutes(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 Down Expand Up @@ -143,7 +138,6 @@ export default async function ViewerPage({ params, searchParams }: Props) {
initialDocReactions={docReactions}
initialAnchoredReactions={anchoredReactions}
initialSections={sections}
readMinutes={readMinutes}
version={doc.version}
initialTheme={serverTheme}
/>
Expand Down
40 changes: 20 additions & 20 deletions lib/docs/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
// { type:"jh:focus", key, keys } (a segment was clicked: focused key + full covering set)
// { type:"jh:hlHover", id } / { type:"jh:hlHoverOut" }
// { type:"jh:reactionToggle", anchor:{exact,prefix,suffix}, emoji } (chip click)
// { type:"jh:readtime", minutes } (read time of the VISIBLE text)
// { type:"jh:readtime", words, cjk } (counts of the VISIBLE text)
// { type:"jh:edit", changes:[{before, after}] } (a block was edited)
// { type:"jh:editRejected", reason } (edit changed structure; not sent)
//
Expand Down Expand Up @@ -163,19 +163,16 @@ export const OVERLAY_SCRIPT = String.raw`
}

// ---- visible read time (jh:readtime) ----
// The server's estimate counts every word in the stored HTML. Only here, with a
// laid-out DOM, can we tell what the reader actually SEES: a tab panel hidden by a
// class defined in a <style> block, a closed <details>, a dialog that hasn't been
// opened. We prune hidden subtrees, count the text that survives, and post the
// minutes for the shell's chip.
//
// Rate constants MUST match lib/docs/reading-time.ts (server code this stringified
// script cannot import) — same duplication as the reaction sig comment above.
var RT_WPM = 200, RT_CJK_PER_MIN = 500;
// The chip's whole number comes from here: only with a laid-out DOM can we tell what
// the reader actually SEES — a tab panel hidden by a class defined in a <style>
// block, a closed <details>, a dialog that hasn't been opened. We prune hidden
// subtrees and post the raw counts; the shell turns them into minutes (the rate and
// the thresholds live in lib/docs/reading-time.ts, which this stringified script
// cannot import — so we send counts rather than duplicating the arithmetic).
var RT_CJK = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/gu;
var RT_WORDISH = /[\p{L}\p{N}]/u;
// Non-prose (the server masks the same set), plus our own injected UI: a reaction
// chip's "👍 2" and a section anchor's link glyph are chrome, not the doc.
// Non-prose, plus our own injected UI: a reaction chip's "👍 2" and a section
// anchor's link glyph are chrome, not the doc.
var RT_SKIP = { SCRIPT:1, STYLE:1, TEMPLATE:1, NOSCRIPT:1, SVG:1, CANVAS:1 };
function rtHidden(el){
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return true;
Expand Down Expand Up @@ -210,9 +207,10 @@ export const OVERLAY_SCRIPT = String.raw`
var text = parts.join(" ");
var cjk = (text.match(RT_CJK) || []).length;
var words = 0, toks = text.replace(RT_CJK, " ").split(/\s+/);
// A token counts as a word only if it carries a letter or a digit, so bullets,
// rules and stray punctuation don't inflate the count.
for (var i=0;i<toks.length;i++) if (RT_WORDISH.test(toks[i])) words++;
var minutes = (words === 0 && cjk === 0) ? 0 : Math.ceil(words/RT_WPM + cjk/RT_CJK_PER_MIN);
send({ type:"jh:readtime", minutes: minutes });
send({ type:"jh:readtime", words: words, cjk: cjk });
} catch(e){}
}

Expand Down Expand Up @@ -1207,12 +1205,14 @@ export const OVERLAY_SCRIPT = String.raw`
// late-applied CSS and for any host that never sends jh:themeMode.
authoredTheme = sampleAuthored();
try { ensureStyle(); if (document.documentElement) document.documentElement.classList.toggle("jh-dark", effectiveDark()); } catch(e){}
// Read time rides the theme sample's load + settle ticks, plus one later tick of its
// own: measured too early, a doc whose tab UI is built by script still has every
// panel visible and we would just re-report the server's full-text number. Each
// report supersedes the last, so a doc that only settles late still converges.
window.addEventListener("load", function(){ sampleTheme(); reportReadTime(); });
setTimeout(function(){ sampleTheme(); reportReadTime(); }, 400);
window.addEventListener("load", sampleTheme);
setTimeout(sampleTheme, 400);
// Read time deliberately does NOT ride those ticks. The shell shows no chip until our
// first report, so a count taken while a script-built tab UI still has every panel
// visible would put up a number we'd then have to correct — worse than one that
// arrives a beat late. Report once the doc has settled, then once more for a doc that
// rearranges itself very late; an unchanged count is a no-op for the shell.
setTimeout(reportReadTime, 1500);
setTimeout(reportReadTime, 5000);
})();
`;
41 changes: 14 additions & 27 deletions lib/docs/reading-time.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
import { describe, it, expect } from "vitest";
import { estimateReadMinutes, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";
import { readMinutesFor, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";

const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(" ");

describe("estimateReadMinutes", () => {
it("counts visible words at 200 wpm, rounding up", () => {
expect(estimateReadMinutes(`<p>${words(200)}</p>`)).toBe(1);
expect(estimateReadMinutes(`<p>${words(201)}</p>`)).toBe(2);
expect(estimateReadMinutes(`<p>${words(1000)}</p>`)).toBe(5);
describe("readMinutesFor", () => {
it("counts words at 200 wpm, rounding up", () => {
expect(readMinutesFor(200, 0)).toBe(1);
expect(readMinutesFor(201, 0)).toBe(2);
expect(readMinutesFor(1000, 0)).toBe(5);
});

it("never rounds a doc with prose down to zero", () => {
expect(estimateReadMinutes("<p>hello there</p>")).toBe(1);
expect(readMinutesFor(2, 0)).toBe(1);
});

it("returns 0 when there is nothing to read", () => {
expect(estimateReadMinutes("")).toBe(0);
expect(estimateReadMinutes('<img src="x.png"><hr><p>— • —</p>')).toBe(0);
it("charges CJK characters at a per-character rate", () => {
expect(readMinutesFor(0, 500)).toBe(1);
expect(readMinutesFor(0, 2000)).toBe(4);
});

it("ignores markup, comments, and non-prose blocks", () => {
const html = `
<!-- ${words(500)} -->
<style>body { color: red }</style>
<script>const x = "${words(500)}";</script>
<svg viewBox="0 0 4 4"><path d="M1 1 L2 2 L3 3 L4 4 Z"/></svg>
<p class="${words(500)}">${words(200)}</p>`;
expect(estimateReadMinutes(html)).toBe(1);
it("adds the two rates for a mixed doc", () => {
expect(readMinutesFor(200, 500)).toBe(2);
});

it("decodes entities rather than counting them as words", () => {
expect(estimateReadMinutes("<p>Tom &amp; Jerry</p>")).toBe(1);
});

it("counts CJK characters individually (no spaces to tokenize on)", () => {
expect(estimateReadMinutes(`<p>${"字".repeat(500)}</p>`)).toBe(1);
expect(estimateReadMinutes(`<p>${"字".repeat(2000)}</p>`)).toBe(4);
it("returns 0 when there is nothing to read", () => {
expect(readMinutesFor(0, 0)).toBe(0);
});
});

Expand Down
59 changes: 19 additions & 40 deletions lib/docs/reading-time.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,29 @@
// Estimated read time for the viewer chrome bar — a pure function of the stored
// HTML, computed at SSR alongside extractSections. Nothing is stored: an inline
// edit changes the estimate on the next load, the same as the title and the
// section list.
// Read time for the viewer chrome bar.
//
// The SSR number counts every word in the stored HTML. The overlay re-measures
// what is actually VISIBLE on first load (hidden tab panels, closed <details>)
// and posts jh:readtime; the shell prefers that. This module stays the definition
// of the rate and the thresholds — the overlay duplicates the rate constants
// because it is stringified browser JS and cannot import server code.

import { htmlToText } from "@/lib/docs/anchor";
// The count itself comes from the overlay: only inside the sandboxed iframe, against
// a laid-out DOM, can we tell what the reader actually SEES on first load — a tab
// panel hidden by a class in a <style> block, a closed <details>, an unopened dialog.
// A server-side pass over the stored HTML would count all of it, so there is no
// server estimate at all; the chip appears once the overlay reports (jh:readtime),
// and a doc whose overlay never runs simply has no chip.
//
// The overlay walks the DOM and posts raw {words, cjk}; everything downstream of that
// — the rate, the rounding, the thresholds — lives here, where it is testable and
// shared by the shell.

// 200 wpm is the conventional prose estimate (Medium-style read times use the
// same ballpark). Deliberately coarse — the bar says "~how long is this", not a
// measurement.
const WORDS_PER_MINUTE = 200;

// CJK scripts are written without spaces, so whitespace tokens undercount them by
// an order of magnitude. Count those characters individually instead, charged at a
// per-character rate.
const CJK_RE = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/gu;
// CJK scripts are written without spaces, so whitespace tokens undercount them by an
// order of magnitude. The overlay counts those characters individually and we charge
// them at a per-character rate.
const CJK_PER_MINUTE = 500;

// A token counts as a word only if it carries a letter or a digit, so bullets,
// rules and stray punctuation don't inflate the count.
const WORDISH = /[\p{L}\p{N}]/u;

/**
* Minutes to read the document, rounded up (so any prose at all is at least "1
* min read"). Returns 0 when there is no prose — an image-only or empty doc — and
* the bar then shows nothing rather than claiming a minute.
* Minutes to read, rounded up (so any prose at all is at least "1 min read"). Returns
* 0 when there is nothing to read — an image-only doc — and the bar then shows no chip
* rather than claiming a minute.
*/
export function estimateReadMinutes(html: string): number {
// Same masking as extractSections, plus <svg>: none of it is prose the reader
// works through, and an inlined icon set or chart is easily thousands of
// "words" of path data.
const masked = html
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<(script|style|template|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
const text = htmlToText(masked);
const cjk = (text.match(CJK_RE) ?? []).length;
const words = text
.replace(CJK_RE, " ")
.split(/\s+/)
.filter((t) => WORDISH.test(t)).length;
if (words === 0 && cjk === 0) return 0;
export function readMinutesFor(words: number, cjk: number): number {
if (words <= 0 && cjk <= 0) return 0;
return Math.ceil(words / WORDS_PER_MINUTE + cjk / CJK_PER_MINUTE);
}

Expand Down
Loading