/**
* Copyright (c) 2025 miwa
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface ReadingTimeOptions {
wordsPerMinute?: number;
imageTime?: number;
codeBlockTime?: number;
chineseKoreanReadingSpeed?: number;
}
export interface ReadingTimeResult {
minutes: number;
words: number;
images: number;
codeBlocks: number;
}
export function formatReadingTime(input: ReadingTimeResult) {
return `${input.words} words · ${
input.images ? `${input.images} images · ` : ""
}${
input.codeBlocks ? `${input.codeBlocks} code blocks · ` : ""
}${input.minutes} minutes`;
}
export function calculateReadingTime(
input: string,
options: ReadingTimeOptions = {},
): ReadingTimeResult {
const {
wordsPerMinute = 200,
imageTime = 12,
codeBlockTime = 15,
chineseKoreanReadingSpeed = 260,
} = options;
let images = 0, preBlocks = 0, codeBlocks = 0;
const text = input
.replace(/]*>/gi, () => (images++, ""))
.replace(/
]*>[\s\S]*?<\/pre>/gi, () => (preBlocks++, ""))
.replace(/]*>[\s\S]*?<\/code>/gi, () => (codeBlocks++, ""));
const cjkRegex = /[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
const cjkMatches = text.match(cjkRegex);
const cjkCharacters = cjkMatches?.length ?? 0;
const textWithoutCJK = cjkCharacters ? text.replace(cjkRegex, " ") : text;
const words = textWithoutCJK.trim().split(/\s+/).reduce(
(n, w) => n + (w && /\w/.test(w) ? 1 : 0),
0,
);
const totalMinutes = Math.ceil(
words / wordsPerMinute +
cjkCharacters / chineseKoreanReadingSpeed +
(images * imageTime + (preBlocks + codeBlocks) * codeBlockTime) / 60,
);
return {
minutes: Math.max(totalMinutes || 2, 2),
words: words + cjkCharacters,
images,
codeBlocks: preBlocks + codeBlocks,
};
}