diff --git a/app/components/post.tsx b/app/components/post.tsx index 9442819..a3a2351 100644 --- a/app/components/post.tsx +++ b/app/components/post.tsx @@ -13,6 +13,7 @@ import DraftNote from "./draft-note"; import Link from "./link"; import SendTestEmailButton from "./send-test-email-button"; import { getDisplayName } from "~/lib/user"; +import readingTime from "~/lib/reading-time"; const URL_REGEXP = new RegExp( /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, @@ -160,9 +161,3 @@ const CommaSeparated = ({ children }) => { } return children; }; - -const readingTime = (content: string, plural: boolean = true): string => { - const time = Math.ceil(content.length / 2000); - if (time > 60) return time / 60 + " hour" + (plural && time / 60 > 1 ? "s" : ""); - return time + " minute" + (plural && time > 1 ? "s" : ""); -}; diff --git a/app/lib/reading-time.test.ts b/app/lib/reading-time.test.ts new file mode 100644 index 0000000..78c977d --- /dev/null +++ b/app/lib/reading-time.test.ts @@ -0,0 +1,33 @@ +import readingTime from "./reading-time"; + +const words = (count: number) => Array.from({ length: count }, () => "word").join(" "); + +describe("readingTime", () => { + test("ignores inline svg markup", async () => { + const svg = `label`; + expect(readingTime(`${words(230)}\n\n${svg}`)).toBe("1 minute"); + }); + + test("ignores image embeds and link urls", async () => { + const url = `https://example.com/${"a/".repeat(2000)}`; + expect(readingTime(`![diagram](${url})\n\n[a link](${url})\n\n${words(228)}`)).toBe("1 minute"); + }); + + test("counts tags mentioned in code as text", async () => { + const content = `Use \`\` for diagrams.\n\n${words(230)}\n\n\`\`\`html\n\n\`\`\``; + expect(readingTime(content)).toBe("2 minutes"); + }); + + test("ignores html comments", async () => { + expect(readingTime(`\n\n${words(230)}`)).toBe("1 minute"); + }); + + test("counts words at 230 per minute", async () => { + expect(readingTime(words(231))).toBe("2 minutes"); + expect(readingTime(words(231), false)).toBe("2 minute"); + }); + + test("rounds long posts to whole hours", async () => { + expect(readingTime(words(230 * 90))).toBe("2 hours"); + }); +}); diff --git a/app/lib/reading-time.ts b/app/lib/reading-time.ts new file mode 100644 index 0000000..f282802 --- /dev/null +++ b/app/lib/reading-time.ts @@ -0,0 +1,28 @@ +import { marked } from "marked"; +import DOMPurify from "isomorphic-dompurify"; +const { sanitize } = DOMPurify; + +const WORDS_PER_MINUTE = 230; + +/** + * Renders the markdown and keeps only the text a reader sees, so embedded + * SVG/math/scripts, image embeds, and link URLs don't count toward reading + * time. Rendering first means tags mentioned inside code stay as plain text. + */ +const readableText = (content: string): string => { + const dom = sanitize(marked.parse(content), { + FORBID_TAGS: ["svg", "math", "style", "script"], + RETURN_DOM: true, + }); + return (dom.textContent || "").replace(/https?:\/\/\S+/g, " "); +}; + +export default (content: string, plural: boolean = true): string => { + const words = readableText(content).match(/[\p{L}\p{N}]+/gu)?.length ?? 0; + const minutes = Math.max(1, Math.ceil(words / WORDS_PER_MINUTE)); + if (minutes < 60) return minutes + " minute" + (plural && minutes > 1 ? "s" : ""); + + const hours = Math.round(minutes / 60); + + return hours + " hour" + (plural && hours > 1 ? "s" : ""); +};