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 = ``;
+ 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(`\n\n[a link](${url})\n\n${words(228)}`)).toBe("1 minute");
+ });
+
+ test("counts tags mentioned in code as text", async () => {
+ const content = `Use \`\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" : "");
+};