From 4ce28cbf48762b89d6a524bee8da77f961a12777 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 14:45:31 -0400 Subject: [PATCH 1/2] fix: Count only readable words in post reading time --- app/components/post.tsx | 7 +------ app/lib/reading-time.test.ts | 24 ++++++++++++++++++++++++ app/lib/reading-time.ts | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 app/lib/reading-time.test.ts create mode 100644 app/lib/reading-time.ts 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..3beb578 --- /dev/null +++ b/app/lib/reading-time.test.ts @@ -0,0 +1,24 @@ +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 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..d97d156 --- /dev/null +++ b/app/lib/reading-time.ts @@ -0,0 +1,25 @@ +const WORDS_PER_MINUTE = 230; + +/** + * Strips everything a reader doesn't read (embedded SVG/HTML, image embeds, + * link URLs) from markdown so only visible words count toward reading time. + */ +const readableText = (content: string): string => { + return content + .replace(/<(svg|math|style|script)\b[\s\S]*?<\/\1>/gi, " ") + .replace(//g, " ") + .replace(/<[^>]+>/g, " ") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, " ") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .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" : ""); +}; From 98bdef78196d974f1cd9fc5cec2ece5560326e68 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:10:31 +0000 Subject: [PATCH 2/2] fix: Count reading time from rendered text, not raw markdown Stripping /