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
11 changes: 11 additions & 0 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ 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 (lib/docs/reading-time). 0 = no prose to
// read, and the bar leaves the slot out entirely.
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 @@ -167,6 +170,7 @@ export default function CommentsShell(props: Props) {
docId,
me,
initialSections,
readMinutes,
} = props;
const [bookmarked, setBookmarked] = useState(props.bookmarked);
const bookmarkPending = useRef(false);
Expand Down Expand Up @@ -924,6 +928,11 @@ 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 ? (
<span className="jh-readtime" title={`Estimated read time: ${readMinutes} minute${readMinutes === 1 ? "" : "s"}`}>
{readMinutes} min read
</span>
) : null}
<ThemeToggle mode={mode} onChange={chooseMode} />
{signedIn ? (
<button
Expand Down Expand Up @@ -1713,6 +1722,8 @@ const RAIL_CSS = `
.jh-stage[data-rail="open"] .jh-rail { transform: translateX(0); }
.jh-railclose { display: inline-block; }
.jh-scrim { display: block; }
/* The bar is already tight at this width; the read time is the first thing to go. */
.jh-readtime { display: none; }
}
`;

Expand Down
6 changes: 6 additions & 0 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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 @@ -109,6 +110,10 @@ 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 @@ -138,6 +143,7 @@ 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: 40 additions & 0 deletions lib/docs/reading-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { estimateReadMinutes } 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);
});

it("never rounds a doc with prose down to zero", () => {
expect(estimateReadMinutes("<p>hello there</p>")).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("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("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);
});
});
43 changes: 43 additions & 0 deletions lib/docs/reading-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Estimated read time for the viewer chrome bar — a pure function of the stored
// HTML, computed at SSR alongside extractSections. Nothing is stored and nothing
// is recomputed client-side: an inline edit changes the estimate on the next load,
// the same as the title and the section list.

import { htmlToText } from "@/lib/docs/anchor";

// 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;
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.
*/
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;
return Math.ceil(words / WORDS_PER_MINUTE + cjk / CJK_PER_MINUTE);
}
Loading