Skip to content

Commit 9f6360b

Browse files
refactor(content-blog): replace reading-time with Intl.Segmenter API (#11138)
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
1 parent c419d7e commit 9f6360b

File tree

13 files changed

+96
-69
lines changed

13 files changed

+96
-69
lines changed

packages/docusaurus-plugin-content-blog/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
"feed": "^4.2.2",
4444
"fs-extra": "^11.1.1",
4545
"lodash": "^4.17.21",
46-
"reading-time": "^1.5.0",
4746
"schema-dts": "^1.1.2",
4847
"srcset": "^4.0.0",
4948
"tslib": "^2.6.0",

packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/index.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ exports[`blog plugin process blog posts load content 2`] = `
150150
"title": "Another With Tag",
151151
},
152152
"permalink": "/blog/simple/slug/another",
153-
"readingTime": 0.015,
153+
"readingTime": 0.02,
154154
"source": "@site/blog/another-simple-slug-with-tags.md",
155155
"tags": [
156156
{

packages/docusaurus-plugin-content-blog/src/__tests__/feed.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
120120
xslt: {atom: null, rss: null},
121121
},
122122
readingTime: ({content, defaultReadingTime}) =>
123-
defaultReadingTime({content}),
123+
defaultReadingTime({content, locale: 'en'}),
124124
truncateMarker: /<!--\s*truncate\s*-->/,
125125
onInlineTags: 'ignore',
126126
onInlineAuthors: 'ignore',
@@ -164,7 +164,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
164164
xslt: {atom: null, rss: null},
165165
},
166166
readingTime: ({content, defaultReadingTime}) =>
167-
defaultReadingTime({content}),
167+
defaultReadingTime({content, locale: 'en'}),
168168
truncateMarker: /<!--\s*truncate\s*-->/,
169169
onInlineTags: 'ignore',
170170
onInlineAuthors: 'ignore',
@@ -220,7 +220,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
220220
xslt: {atom: null, rss: null},
221221
},
222222
readingTime: ({content, defaultReadingTime}) =>
223-
defaultReadingTime({content}),
223+
defaultReadingTime({content, locale: 'en'}),
224224
truncateMarker: /<!--\s*truncate\s*-->/,
225225
onInlineTags: 'ignore',
226226
onInlineAuthors: 'ignore',
@@ -267,7 +267,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
267267
xslt: {atom: null, rss: null},
268268
},
269269
readingTime: ({content, defaultReadingTime}) =>
270-
defaultReadingTime({content}),
270+
defaultReadingTime({content, locale: 'en'}),
271271
truncateMarker: /<!--\s*truncate\s*-->/,
272272
onInlineTags: 'ignore',
273273
onInlineAuthors: 'ignore',
@@ -314,7 +314,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
314314
xslt: {atom: null, rss: null},
315315
},
316316
readingTime: ({content, defaultReadingTime}) =>
317-
defaultReadingTime({content}),
317+
defaultReadingTime({content, locale: 'en'}),
318318
truncateMarker: /<!--\s*truncate\s*-->/,
319319
onInlineTags: 'ignore',
320320
onInlineAuthors: 'ignore',
@@ -360,7 +360,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
360360
xslt: true,
361361
},
362362
readingTime: ({content, defaultReadingTime}) =>
363-
defaultReadingTime({content}),
363+
defaultReadingTime({content, locale: 'en'}),
364364
truncateMarker: /<!--\s*truncate\s*-->/,
365365
onInlineTags: 'ignore',
366366
onInlineAuthors: 'ignore',
@@ -409,7 +409,7 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
409409
},
410410
},
411411
readingTime: ({content, defaultReadingTime}) =>
412-
defaultReadingTime({content}),
412+
defaultReadingTime({content, locale: 'en'}),
413413
truncateMarker: /<!--\s*truncate\s*-->/,
414414
onInlineTags: 'ignore',
415415
onInlineAuthors: 'ignore',

packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('blog plugin', () => {
211211
).toEqual({
212212
editUrl: `${BaseEditUrl}/blog/2018-12-14-Happy-First-Birthday-Slash.md`,
213213
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
214-
readingTime: 0.015,
214+
readingTime: 0.02,
215215
source: path.posix.join(
216216
'@site',
217217
path.posix.join('i18n', 'en', 'docusaurus-plugin-content-blog'),
@@ -276,7 +276,7 @@ describe('blog plugin', () => {
276276
}).toEqual({
277277
editUrl: `${BaseEditUrl}/blog/complex-slug.md`,
278278
permalink: '/blog/hey/my super path/héllô',
279-
readingTime: 0.015,
279+
readingTime: 0.02,
280280
source: path.posix.join('@site', PluginPath, 'complex-slug.md'),
281281
title: 'Complex Slug',
282282
description: `complex url slug`,
@@ -318,7 +318,7 @@ describe('blog plugin', () => {
318318
}).toEqual({
319319
editUrl: `${BaseEditUrl}/blog/simple-slug.md`,
320320
permalink: '/blog/simple/slug',
321-
readingTime: 0.015,
321+
readingTime: 0.02,
322322
source: path.posix.join('@site', PluginPath, 'simple-slug.md'),
323323
title: 'Simple Slug',
324324
description: `simple url slug`,

packages/docusaurus-plugin-content-blog/src/__tests__/readingTime.test.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,48 +9,46 @@ import {calculateReadingTime} from '../readingTime';
99

1010
describe('calculateReadingTime', () => {
1111
it('calculates reading time for empty content', () => {
12-
expect(calculateReadingTime('')).toBe(0);
12+
expect(calculateReadingTime('', 'en')).toBe(0);
1313
});
1414

1515
it('calculates reading time for short content', () => {
1616
const content = 'This is a short test content.';
17-
expect(calculateReadingTime(content)).toBe(0.03);
17+
expect(calculateReadingTime(content, 'en')).toBe(0.03);
1818
});
1919

2020
it('calculates reading time for long content', () => {
2121
const content = 'This is a test content. '.repeat(100);
22-
expect(calculateReadingTime(content)).toBe(2.5);
22+
expect(calculateReadingTime(content, 'en')).toBe(2.5);
2323
});
2424

2525
it('respects custom words per minute', () => {
2626
const content = 'This is a test content. '.repeat(100);
27-
expect(calculateReadingTime(content, {wordsPerMinute: 100})).toBe(5);
27+
expect(calculateReadingTime(content, 'en', {wordsPerMinute: 100})).toBe(5);
2828
});
2929

3030
it('handles content with special characters', () => {
3131
const content = 'Hello! How are you? This is a test...';
32-
expect(calculateReadingTime(content)).toBe(0.04);
32+
expect(calculateReadingTime(content, 'en')).toBe(0.04);
3333
});
3434

3535
it('handles content with multiple lines', () => {
36-
const content = `This is line 1.
37-
This is line 2.
38-
This is line 3.`;
39-
expect(calculateReadingTime(content)).toBe(0.06);
36+
const content = `This is line 1.\n This is line 2.\n This is line 3.`;
37+
expect(calculateReadingTime(content, 'en')).toBe(0.06);
4038
});
4139

4240
it('handles content with HTML tags', () => {
4341
const content = '<p>This is a <strong>test</strong> content.</p>';
44-
expect(calculateReadingTime(content)).toBe(0.025);
42+
expect(calculateReadingTime(content, 'en')).toBe(0.05);
4543
});
4644

4745
it('handles content with markdown', () => {
4846
const content = '# Title\n\nThis is **bold** and *italic* text.';
49-
expect(calculateReadingTime(content)).toBe(0.04);
47+
expect(calculateReadingTime(content, 'en')).toBe(0.04);
5048
});
5149

5250
it('handles CJK content', () => {
5351
const content = '你好,世界!这是一段测试内容。';
54-
expect(calculateReadingTime(content)).toBe(0.06);
52+
expect(calculateReadingTime(content, 'zh')).toBe(0.04);
5553
});
5654
});

packages/docusaurus-plugin-content-blog/src/blogUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ async function parseBlogPostMarkdownFile({
210210
}
211211
}
212212

213-
const defaultReadingTime: ReadingTimeFunction = ({content, options}) =>
214-
calculateReadingTime(content, options);
213+
const defaultReadingTime: ReadingTimeFunction = ({content, locale, options}) =>
214+
calculateReadingTime(content, locale, options);
215215

216216
async function processBlogSourceFile(
217217
blogSourceRelative: string,
@@ -373,6 +373,7 @@ async function processBlogSourceFile(
373373
content,
374374
frontMatter,
375375
defaultReadingTime,
376+
locale: i18n.currentLocale,
376377
})
377378
: undefined,
378379
hasTruncateMarker: truncateMarker.test(content),

packages/docusaurus-plugin-content-blog/src/options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export const DEFAULT_OPTIONS: PluginOptions = {
6363
path: 'blog',
6464
editLocalizedFiles: false,
6565
authorsMapPath: 'authors.yml',
66-
readingTime: ({content, defaultReadingTime}) => defaultReadingTime({content}),
66+
readingTime: ({content, defaultReadingTime, locale}) =>
67+
defaultReadingTime({content, locale}),
6768
sortPosts: 'descending',
6869
showLastUpdateTime: false,
6970
showLastUpdateAuthor: false,

packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -387,15 +387,10 @@ declare module '@docusaurus/plugin-content-blog' {
387387
};
388388

389389
/**
390-
* Duplicate from ngryman/reading-time to keep stability of API.
390+
* Options for reading time calculation using Intl.Segmenter.
391391
*/
392392
type ReadingTimeOptions = {
393393
wordsPerMinute?: number;
394-
/**
395-
* @param char The character to be matched.
396-
* @returns `true` if this character is a word bound.
397-
*/
398-
wordBound?: (char: string) => boolean;
399394
};
400395

401396
/**
@@ -405,24 +400,22 @@ declare module '@docusaurus/plugin-content-blog' {
405400
export type ReadingTimeFunction = (params: {
406401
/** Markdown content. */
407402
content: string;
403+
/** Locale for word segmentation. */
404+
locale: string;
408405
/** Front matter. */
409406
frontMatter?: BlogPostFrontMatter & {[key: string]: unknown};
410-
/** Options accepted by ngryman/reading-time. */
407+
/** Options for reading time calculation. */
411408
options?: ReadingTimeOptions;
412409
}) => number;
413410

414411
/**
415-
* @returns The reading time directly plugged into metadata. `undefined` to
416-
* hide reading time for a specific post.
412+
* @returns The reading time directly plugged into metadata.
413+
* `undefined` to hide reading time for a specific post.
417414
*/
418415
export type ReadingTimeFunctionOption = (
419-
/**
420-
* The `options` is not provided by the caller; the user can inject their
421-
* own option values into `defaultReadingTime`
422-
*/
423416
params: Required<Omit<Parameters<ReadingTimeFunction>[0], 'options'>> & {
424417
/**
425-
* The default reading time implementation from ngryman/reading-time.
418+
* The default reading time implementation.
426419
*/
427420
defaultReadingTime: ReadingTimeFunction;
428421
},

packages/docusaurus-plugin-content-blog/src/readingTime.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,45 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import readingTime from 'reading-time';
9-
108
const DEFAULT_WORDS_PER_MINUTE = 200;
119

12-
interface ReadingTimeOptions {
13-
wordsPerMinute?: number;
14-
wordBound?: (char: string) => boolean;
10+
/**
11+
* Counts the number of words in a string using Intl.Segmenter.
12+
* @param content The text content to count words in.
13+
* @param locale The locale to use for segmentation.
14+
*/
15+
function countWords(content: string, locale: string): number {
16+
if (!content) {
17+
return 0;
18+
}
19+
const segmenter = new Intl.Segmenter(locale, {granularity: 'word'});
20+
let wordCount = 0;
21+
for (const {isWordLike} of segmenter.segment(content)) {
22+
if (isWordLike) {
23+
wordCount += 1;
24+
}
25+
}
26+
return wordCount;
1527
}
1628

1729
/**
18-
* Calculates the reading time for a given content string.
19-
* Uses the reading-time package under the hood.
30+
* Calculates the reading time for a given content string using Intl.Segmenter.
31+
* @param content The text content to calculate reading time for.
32+
* @param locale Required locale string for Intl.Segmenter
33+
* @param options Options for reading time calculation.
34+
* - wordsPerMinute: number of words per minute (default 200)
35+
* @returns Estimated reading time in minutes (float, rounded to 2 decimals)
2036
*/
2137
export function calculateReadingTime(
2238
content: string,
23-
options: ReadingTimeOptions = {},
39+
locale: string,
40+
options?: {wordsPerMinute?: number},
2441
): number {
25-
const wordsPerMinute = options.wordsPerMinute ?? DEFAULT_WORDS_PER_MINUTE;
26-
const {wordBound} = options;
27-
return readingTime(content, {wordsPerMinute, ...(wordBound && {wordBound})})
28-
.minutes;
42+
const wordsPerMinute = options?.wordsPerMinute ?? DEFAULT_WORDS_PER_MINUTE;
43+
const words = countWords(content, locale);
44+
if (words === 0) {
45+
return 0;
46+
}
47+
// Calculate reading time in minutes and round to 2 decimal places
48+
return Math.round((words / wordsPerMinute) * 100) / 100;
2949
}

website/_dogfooding/dogfooding.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ export const dogfoodingPluginInstances: PluginConfig[] = [
104104
readingTime: ({content, frontMatter, defaultReadingTime}) =>
105105
frontMatter.hide_reading_time
106106
? undefined
107-
: defaultReadingTime({content, options: {wordsPerMinute: 5}}),
107+
: defaultReadingTime({
108+
content,
109+
locale: 'en',
110+
options: {wordsPerMinute: 5},
111+
}),
108112
onInlineTags: 'warn',
109113
onInlineAuthors: 'ignore',
110114
onUntruncatedBlogPosts: 'ignore',

website/docs/api/plugins/plugin-content-blog.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,18 @@ type EditUrlFunction = (params: {
109109
```ts
110110
type ReadingTimeOptions = {
111111
wordsPerMinute: number;
112-
wordBound: (char: string) => boolean;
113112
};
114113

115114
type ReadingTimeCalculator = (params: {
116115
content: string;
116+
locale: string;
117117
frontMatter?: BlogPostFrontMatter & Record<string, unknown>;
118118
options?: ReadingTimeOptions;
119119
}) => number;
120120

121121
type ReadingTimeFn = (params: {
122122
content: string;
123+
locale: string;
123124
frontMatter: BlogPostFrontMatter & Record<string, unknown>;
124125
defaultReadingTime: ReadingTimeCalculator;
125126
}) => number | undefined;

0 commit comments

Comments
 (0)