diff --git a/README.md b/README.md index e2f7512..7462787 100644 --- a/README.md +++ b/README.md @@ -29,34 +29,68 @@ const readingTime = require('reading-time'); const stats = readingTime(text); // -> // stats: { -// text: '1 min read', // minutes: 1, // time: 60000, -// words: 200 +// words: {total: 200} // } +console.log(`The reading time is: ${stats.minutes} min`); ``` ### Stream ```javascript -const {ReadingTimeStream} = require('reading-time'); +const {ReadingTimeStream, readingTimeWithCount} = require('reading-time'); const analyzer = new ReadingTimeStream(); fs.createReadStream('foo') .pipe(analyzer) - .on('data', stats => { - // ... + .on('data', (count) => { + console.log(`The reading time is: ${readingTimeWithCount(count).minutes} min`); }); ``` ## API -`readingTime(text, options?)` +### `readingTime(text, options?)` + +Returns an object with `minutes`, `time` (in milliseconds), and `words`. + +```ts +type ReadingTimeResults = { + minutes: number; + time: number; + words: WordCountStats; +}; +``` + +- `text`: the text to analyze +- options (optional) + - `options.wordsPerMinute`: (optional) the words per minute an average reader can read (default: 200) + - `options.wordBound`: (optional) a function that returns a boolean value depending on if a character is considered as a word bound (default: spaces, new lines and tabs) + +### `countWords(text, options?)` + +Returns an object representing the word count stats: + +```ts +type WordCountStats = { + total: number; +}; +``` - `text`: the text to analyze +- options (optional) + - `options.wordBound`: (optional) a function that returns a boolean value depending on if a character is considered as a word bound (default: spaces, new lines and tabs) + +### `readingTimeWithCount(words, options?)` + +Returns an object with `minutes` (rounded minute stats) and `time` (exact time in milliseconds). + +- `words`: the word count stats - options (optional) - `options.wordsPerMinute`: (optional) the words per minute an average reader can read (default: 200) - - `options.wordBound`: (optional) a function that returns a boolean value depending on if a character is considered as a word bound (default: spaces, new lines and tabulations) + +Note that `readingTime(text, options) === readingTimeWithCount(countWords(text, options), options)`. ## Help wanted! diff --git a/package.json b/package.json index a1d6c5d..229eb5f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "words per minute" ], "author": "Nicolas Gryman (http://ngryman.sh)", + "contributors": ["Joshua Chen (https://joshcena.com)"], "license": "MIT", "devDependencies": { "@types/chai": "^4.2.21", diff --git a/src/index.ts b/src/index.ts index 3b0603b..0fd7a13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -import readingTime from './reading-time' +import readingTime, { countWords, readingTimeWithCount } from './reading-time' import ReadingTimeStream from './stream' // This part is to make TS happy -export { ReadingTimeStream } +export { ReadingTimeStream, countWords, readingTimeWithCount } export default readingTime // Wacky way to support const readingTime = require('reading-time') :( @@ -10,5 +10,7 @@ export default readingTime // decouples it from the exports object, which TS export compiles to module.exports = readingTime module.exports.default = readingTime +module.exports.countWords = countWords +module.exports.readingTimeWithCount = readingTimeWithCount module.exports.ReadingTimeStream = ReadingTimeStream module.exports.__esModule = true diff --git a/src/reading-time.ts b/src/reading-time.ts index 4452fe2..1204b2d 100644 --- a/src/reading-time.ts +++ b/src/reading-time.ts @@ -4,7 +4,7 @@ * MIT Licensed */ -import type { Options, ReadTimeResults } from 'reading-time' +import type { Options, ReadingTimeStats, WordCountStats, ReadingTimeResult } from 'reading-time' type WordBoundFunction = Options['wordBound'] @@ -58,12 +58,9 @@ const isPunctuation: WordBoundFunction = (c) => { ) } -function readingTime(text: string, options: Options = {}): ReadTimeResults { +export function countWords(text: string, options: Options = {}): WordCountStats { let words = 0, start = 0, end = text.length - 1 - const { - wordsPerMinute = 200, - wordBound: isWordBound = isAnsiWordBound - } = options + const { wordBound: isWordBound = isAnsiWordBound } = options // fetch bounds while (isWordBound(text[start])) start++ @@ -94,20 +91,31 @@ function readingTime(text: string, options: Options = {}): ReadTimeResults { } } } + return { total: words } +} +export function readingTimeWithCount( + words: WordCountStats, + options: Options = {} +): ReadingTimeStats { + const { wordsPerMinute = 200 } = options // reading time stats - const minutes = words / wordsPerMinute - // Math.round used to resolve floating point funkyness + const minutes = words.total / wordsPerMinute + // Math.round used to resolve floating point funkiness // http://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html const time = Math.round(minutes * 60 * 1000) const displayed = Math.ceil(parseFloat(minutes.toFixed(2))) return { - text: displayed + ' min read', - minutes, - time, - words + minutes: displayed, + time } } -export default readingTime +export default function readingTime(text: string, options: Options = {}): ReadingTimeResult { + const words = countWords(text, options) + return { + ...readingTimeWithCount(words, options), + words + } +} diff --git a/src/stream.ts b/src/stream.ts index 1e8f767..3b15e2d 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -4,39 +4,28 @@ * MIT Licensed */ -import readingTime from './reading-time' +import { countWords } from './reading-time' import { Transform, TransformCallback } from 'stream' -import type { Options, ReadTimeResults } from 'reading-time' +import type { Options, WordCountStats } from 'reading-time' class ReadingTimeStream extends Transform { options: Options; - stats: ReadTimeResults; + stats: WordCountStats; constructor(options: Options = {}) { super({ objectMode: true }) this.options = options - this.stats = { - text: '', - minutes: 0, - time: 0, - words: 0 - } + this.stats = { total: 0 } } _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void { - const stats = readingTime(chunk.toString(encoding), this.options) - - this.stats.minutes += stats.minutes - this.stats.time += stats.time - this.stats.words += stats.words - + const stats = countWords(chunk.toString(encoding), this.options) + this.stats.total += stats.total callback() } _flush(callback: TransformCallback): void { - this.stats.text = Math.ceil(parseFloat(this.stats.minutes.toFixed(2))) + ' min read' - this.push(this.stats) callback() } diff --git a/src/types.d.ts b/src/types.d.ts index acbd903..71b9def 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,25 +1,33 @@ declare module 'reading-time' { import { Transform, TransformCallback } from 'stream' - export interface Options { + export type Options = { wordBound?: (char: string) => boolean; wordsPerMinute?: number; } - export interface ReadTimeResults { - text: string; + export type ReadingTimeStats = { time: number; - words: number; minutes: number; } + export type WordCountStats = { + total: number; + } + export class ReadingTimeStream extends Transform { - stats: ReadTimeResults; + stats: WordCountStats; options: Options; constructor(options?: Options); _transform: (chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback) => void; _flush: (callback: TransformCallback) => void; } - export default function readingTime(text: string, options?: Options): ReadTimeResults + export type ReadingTimeResult = ReadingTimeStats & { + words: WordCountStats; + } + + export function countWords(text: string, options?: Options): WordCountStats + export function readingTimeWithCount(words: WordCountStats, options?: Options): ReadingTimeStats + export default function readingTime(text: string, options?: Options): ReadingTimeResult } diff --git a/test/reading-time.spec.ts b/test/reading-time.spec.ts index 66c250a..e1bbeab 100644 --- a/test/reading-time.spec.ts +++ b/test/reading-time.spec.ts @@ -6,11 +6,11 @@ import readingTime from '../src' import chai from 'chai' -import { Options, ReadTimeResults } from 'reading-time' +import { Options, ReadingTimeResult } from 'reading-time' chai.should() -const test = (words: number | string, expect: Partial, options?: Options) => +const test = (words: number | string, expect: Partial, options?: Options) => (done: () => void) => { const text = 'number' === typeof words ? generateText(words) : words @@ -27,13 +27,15 @@ const test = (words: number | string, expect: Partial, options? } const res = readingTime(text, options) - if (expect.text) { - res.should.have.property('text', expect.text) + if (expect.minutes) { + res.should.have.property('minutes', expect.minutes) } - res.should.have.property('words', expect.words ?? words) if (expect.time) { res.should.have.property('time', expect.time) } + if (expect.words) { + res.should.have.property('words').to.deep.equal(expect.words) + } done() } @@ -56,79 +58,79 @@ function generateText(words: number) { describe('readingTime()', () => { it('should handle less than 1 minute text', test(2, { - text: '1 min read', + minutes: 1, time: 600 })) it('should handle less than 1 minute text', test(50, { - text: '1 min read', + minutes: 1, time: 15000 })) it('should handle 1 minute text', test(100, { - text: '1 min read', + minutes: 1, time: 30000 })) it('should handle 2 minutes text', test(300, { - text: '2 min read', + minutes: 2, time: 90000 })) it('should handle a very long text', test(500, { - text: '3 min read', + minutes: 3, time: 150000 })) it('should handle text containing multiple successive whitespaces', test('word word word', { - text: '1 min read', + minutes: 1, time: 900 })) it('should handle text starting with whitespaces', test(' word word word', { - text: '1 min read', + minutes: 1, time: 900 })) it('should handle text ending with whitespaces', test('word word word ', { - text: '1 min read', + minutes: 1, time: 900 })) it('should handle text containing links', test('word http://ngryman.sh word', { - text: '1 min read', + minutes: 1, time: 900 })) it('should handle text containing markdown links', test('word [blog](http://ngryman.sh) word', { - text: '1 min read', + minutes: 1, time: 900 })) it('should handle text containing one word correctly', test('0', { - text: '1 min read', + minutes: 1, time: 300 })) it('should handle text containing a black hole', test('', { - text: '0 min read', + minutes: 0, time: 0 })) it('should accept a custom word per minutes value', test(200, { - text: '2 min read', + minutes: 2, time: 120000 }, { wordsPerMinute: 100 })) }) @@ -136,36 +138,36 @@ describe('readingTime()', () => { describe('readingTime CJK', () => { it('should handle a CJK paragraph', test('今天,我要说中文!(没错,现在这个库也完全支持中文了)', { - words: 22 + words: { total: 22 } })) it('should handle a CJK paragraph with Latin words', test('你会说English吗?', { - words: 5 + words: { total: 5 } })) it('should handle a CJK paragraph with Latin punctuation', test('科学文章中, 经常使用英语标点... (虽然这段话并不科学)', { - words: 22 + words: { total: 22 } })) it('should handle a CJK paragraph starting and terminating in Latin words', test('JoshCena喜欢GitHub', { - words: 4 + words: { total: 4 } })) it('should handle a typical Korean paragraph', test('이것은 한국어 단락입니다', { - words: 11 + words: { total: 11 } })) it('should handle a typical Japanese paragraph', test('天気がいいから、散歩しましょう', { - words: 14 + words: { total: 14 } })) it('should treat Katakana as one word', test('メガナイトありませんか?', { - words: 7 + words: { total: 7 } })) }) diff --git a/test/stream.spec.ts b/test/stream.spec.ts index 8610b01..eaf4121 100644 --- a/test/stream.spec.ts +++ b/test/stream.spec.ts @@ -6,11 +6,11 @@ import { ReadingTimeStream } from '../src' import chai from 'chai' -import { Options, ReadTimeResults } from 'reading-time' +import { Options, WordCountStats } from 'reading-time' chai.should() -const test = (words: number | string, expect: Partial, options?: Options) => +const test = (words: number | string, expect: WordCountStats, options?: Options) => (done: () => void) => { const chunks = 'number' === typeof words ? generateChunks(words) : [Buffer.from(words)] @@ -28,13 +28,7 @@ const test = (words: number | string, expect: Partial, options? const analyzer = new ReadingTimeStream(options) analyzer.on('data', (res) => { - if (expect.text) { - res.should.have.property('text', expect.text) - } - res.should.have.property('words', expect.words ?? words) - if (expect.time) { - res.should.have.property('time', expect.time) - } + res.should.deep.equal(expect) done() }) @@ -66,118 +60,32 @@ function generateChunks(words: number) { } describe('readingTime stream', () => { - it('should handle less than 1 minute text', - test(2, { - text: '1 min read', - time: 600 - })) - - it('should handle less than 1 minute text', - test(50, { - text: '1 min read', - time: 15000 - })) - - it('should handle 1 minute text', - test(100, { - text: '1 min read', - time: 30000 - })) - - it('should handle 2 minutes text', - test(300, { - text: '2 min read', - time: 90000 - })) - - it('should handle a very long text', - test(500, { - text: '3 min read', - time: 150000 - })) - + it('should handle less than 1 minute text', test(2, { total: 2 })) + it('should handle less than 1 minute text', test(50, { total: 50 })) + it('should handle 1 minute text', test(100, { total: 100 })) + it('should handle 2 minutes text', test(300, { total: 300 })) + it('should handle a very long text', test(500, { total: 500 })) it('should handle text containing multiple successive whitespaces', - test('word word word', { - text: '1 min read', - time: 900 - })) - - it('should handle text starting with whitespaces', - test(' word word word', { - text: '1 min read', - time: 900 - })) - - it('should handle text ending with whitespaces', - test('word word word ', { - text: '1 min read', - time: 900 - })) - - it('should handle text containing links', - test('word http://ngryman.sh word', { - text: '1 min read', - time: 900 - })) - + test('word word word', { total: 3 })) + it('should handle text starting with whitespaces', test(' word word word', { total: 3 })) + it('should handle text ending with whitespaces', test('word word word ', { total: 3 })) + it('should handle text containing links', test('word http://ngryman.sh word', { total: 3 })) it('should handle text containing markdown links', - test('word [blog](http://ngryman.sh) word', { - text: '1 min read', - time: 900 - })) - - it('should handle text containing one word correctly', - test('0', { - text: '1 min read', - time: 300 - })) - - it('should handle text containing a black hole', - test('', { - text: '0 min read', - time: 0 - })) - + test('word [blog](http://ngryman.sh) word', { total: 3 })) + it('should handle text containing one word correctly', test('0', { total: 1 })) + it('should handle text containing a black hole', test('', { total: 0 })) it('should accept a custom word per minutes value', - test(200, { - text: '2 min read', - time: 120000 - }, { wordsPerMinute: 100 })) + test(200, { total: 200 }, { wordsPerMinute: 100 })) }) describe('readingTime stream CJK', () => { - it('should handle a CJK paragraph', - test('今天,我要说中文!(没错,现在这个库也完全支持中文了)', { - words: 22 - })) - - it('should handle a CJK paragraph with Latin words', - test('你会说English吗?', { - words: 5 - })) - + it('should handle a CJK paragraph', test('今天,我要说中文!(没错,现在这个库也完全支持中文了)', { total: 22 })) + it('should handle a CJK paragraph with Latin words', test('你会说English吗?', { total: 5 })) it('should handle a CJK paragraph with Latin punctuation', - test('科学文章中, 经常使用英语标点... (虽然这段话并不科学)', { - words: 22 - })) - + test('科学文章中, 经常使用英语标点... (虽然这段话并不科学)', { total: 22 })) it('should handle a CJK paragraph starting and terminating in Latin words', - test('JoshCena喜欢GitHub', { - words: 4 - })) - - it('should handle a typical Korean paragraph', - test('이것은 한국어 단락입니다', { - words: 11 - })) - - it('should handle a typical Japanese paragraph', - test('天気がいいから、散歩しましょう', { - words: 14 - })) - - it('should treat Katakana as one word', - test('メガナイトありませんか?', { - words: 7 - })) + test('JoshCena喜欢GitHub', { total: 4 })) + it('should handle a typical Korean paragraph', test('이것은 한국어 단락입니다', { total: 11 })) + it('should handle a typical Japanese paragraph', test('天気がいいから、散歩しましょう', { total: 14 })) + it('should treat Katakana as one word', test('メガナイトありませんか?', { total: 7 })) })