├── .gitignore ├── tests ├── test-site │ ├── test-site.11tydata.json │ ├── pages │ │ ├── handlebars │ │ │ ├── noargs.hbs │ │ │ └── args.hbs │ │ ├── liquid │ │ │ ├── noargs.liquid │ │ │ └── args.liquid │ │ ├── nunjucks │ │ │ ├── noargs.njk │ │ │ └── args.njk │ │ └── javascript │ │ │ ├── noargs.11ty.js │ │ │ ├── args.11ty.js │ │ │ └── argsobject.11ty.js │ ├── _includes │ │ └── base.html │ ├── index.html │ └── _data │ │ └── tests.js ├── validator-keys.test.js ├── validator-output.test.js ├── validator-type.test.js ├── validator-digits.test.js ├── validator-style.test.js ├── validator-language.test.js ├── validator-insert.test.js ├── parser.test.js ├── validator-label.test.js ├── validator-speed.test.js └── measure-time.test.js ├── .eleventy.js ├── components ├── options-default.js ├── regular-expressions.js ├── options-parser.js ├── options-validator.js └── measure-time.js ├── index.js ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tests/test-site/_site/** -------------------------------------------------------------------------------- /tests/test-site/test-site.11tydata.json: -------------------------------------------------------------------------------- 1 | { 2 | "layout": "base" 3 | } -------------------------------------------------------------------------------- /tests/test-site/pages/handlebars/noargs.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Handlebars without arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/pages/liquid/noargs.liquid: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Liquid without arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/pages/nunjucks/noargs.njk: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Nunjucks without arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/pages/handlebars/args.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Handlebars with arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/pages/liquid/args.liquid: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Liquid with arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/pages/nunjucks/args.njk: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Nunjucks with arguments' 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /tests/test-site/_includes/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 9 | {{ content }} 10 | 11 | -------------------------------------------------------------------------------- /.eleventy.js: -------------------------------------------------------------------------------- 1 | const pluginTimeToRead = require('./index.js'); 2 | 3 | module.exports = function(eleventyConfig) { 4 | 5 | eleventyConfig.addPlugin(pluginTimeToRead, {}); 6 | 7 | return { 8 | dir: { 9 | input: './tests/test-site/', 10 | output: './tests/test-site/_site' 11 | } 12 | }; 13 | }; -------------------------------------------------------------------------------- /tests/validator-keys.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | test('rejects invalid keys', t => { 5 | t.throws(()=> { 6 | validator({ foo: 'bar' }); 7 | }); 8 | 9 | t.throws(()=> { 10 | validator({ hour: 'auto' }); 11 | }); 12 | 13 | t.throws(()=> { 14 | validator({ digit: 2 }); 15 | }); 16 | }); -------------------------------------------------------------------------------- /tests/test-site/pages/javascript/noargs.11ty.js: -------------------------------------------------------------------------------- 1 | class page { 2 | data() { 3 | return { 4 | title: "JavaScript without arguments" 5 | }; 6 | } 7 | 8 | render({tests}) { 9 | const html = tests.reduce((acc, cur) => { 10 | return acc + `\n\t\t
  • ${cur.title}: ${this.timeToRead(cur.text)}
  • `; 11 | }, ''); 12 | return `\n`; 13 | } 14 | } 15 | 16 | module.exports = page; -------------------------------------------------------------------------------- /tests/test-site/pages/javascript/args.11ty.js: -------------------------------------------------------------------------------- 1 | class page { 2 | data() { 3 | return { 4 | title: "JavaScript with arguments" 5 | }; 6 | } 7 | 8 | render({tests}) { 9 | const html = tests.reduce((acc, cur) => { 10 | return acc + `\n\t\t
  • ${cur.title}: ${this.timeToRead(cur.text, 'zh', '100 words per minute')}
  • `; 11 | }, ''); 12 | return `\n`; 13 | } 14 | } 15 | 16 | module.exports = page; -------------------------------------------------------------------------------- /tests/test-site/pages/javascript/argsobject.11ty.js: -------------------------------------------------------------------------------- 1 | class page { 2 | data() { 3 | return { 4 | title: "JavaScript with arguments as an object" 5 | }; 6 | } 7 | 8 | render({tests}) { 9 | const html = tests.reduce((acc, cur) => { 10 | return acc + `\n\t\t
  • ${cur.title}: ${this.timeToRead(cur.text, {language: 'zh', speed: '100 words per minute'})}
  • `; 11 | }, ''); 12 | return `\n`; 13 | } 14 | } 15 | 16 | module.exports = page; -------------------------------------------------------------------------------- /components/options-default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // The number of characters read per minute tends to be around 1000 for all languages: https://en.wikipedia.org/wiki/Words_per_minute#Reading_and_comprehension 3 | speed: '1000 character minute', 4 | language: 'en', 5 | style: 'long', 6 | type: 'unit', 7 | hours: 'auto', 8 | minutes: true, 9 | seconds: false, 10 | digits: 1, 11 | output: function(data) { 12 | return data.timing; 13 | }, 14 | 15 | // Deprecated, remove in 2.0 major release 16 | prepend: null, 17 | append: null, 18 | } 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const defaultOptions = require('./components/options-default.js'); 2 | const validateOptions = require('./components/options-validator.js'); 3 | const parseOptions = require('./components/options-parser.js'); 4 | const measureTime = require('./components/measure-time.js'); 5 | 6 | module.exports = function(eleventyConfig, customOptions) { 7 | const globalOptions = Object.assign( 8 | {}, 9 | defaultOptions, 10 | validateOptions(customOptions) 11 | ); 12 | eleventyConfig.addFilter('timeToRead', function(input, ...instanceOptions) { 13 | const options = Object.assign( 14 | {}, 15 | globalOptions, 16 | validateOptions(parseOptions(instanceOptions)) 17 | ); 18 | return measureTime(input, options); 19 | }); 20 | } -------------------------------------------------------------------------------- /tests/validator-output.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test('accepts a Function as output', t => { 6 | const testArgument1 = function(data) { return data.timing; }; 7 | 8 | t.is(validator({ output: testArgument1 }).output, testArgument1); 9 | }); 10 | 11 | test('rejects invalid output argument types', t => { 12 | t.throws(()=> { 13 | validator({ output: 'foo' }); 14 | }); 15 | 16 | t.throws(()=> { 17 | validator({ output: 123 }); 18 | }); 19 | 20 | t.throws(()=> { 21 | validator({ output: ['foo', 'bar'] }); 22 | }); 23 | 24 | t.throws(()=> { 25 | validator({ output: {foo: 'bar'} }); 26 | }); 27 | 28 | t.throws(()=> { 29 | validator({ output: true }); 30 | }); 31 | 32 | t.throws(()=> { 33 | validator({ output: false }); 34 | }); 35 | }); -------------------------------------------------------------------------------- /components/regular-expressions.js: -------------------------------------------------------------------------------- 1 | // Regex = 1 or more numbers + optional '.' followed by 1 or more numbers 2 | const speedUnitAmount = String.raw`[0-9]+(\.[0-9]+)?`; 3 | 4 | // Regex = 'character(s)' or 'word(s)' 5 | const speedUnitMeasure = String.raw`(character|word)s?`; 6 | 7 | // Regex = 'hour(s)' or 'minute(s)' or 'second(s)' 8 | const speedUnitInterval = String.raw`(hour|minute|second)s?`; 9 | 10 | // Regex = speedUnitAmount + ' ' + speedUnitMeasure + ' ' + optional anything followed by space + speedUnitInterval 11 | const speed = String.raw`^${speedUnitAmount} ${speedUnitMeasure} (.* )*${speedUnitInterval}$`; 12 | 13 | // Regex = '<' + optional '/' + 1 or more alphanumeric characters + a non-word character + 0 or more non-'>' characters + '>' 14 | const htmlTags = String.raw`<\/?[a-z0-9]+\b[^>]*>`; 15 | 16 | //Regex = '' 17 | const htmlComments = String.raw``; 18 | 19 | // Regex = htmlTags or htmlComments 20 | const html = String.raw`${htmlTags}|${htmlComments}`; 21 | 22 | module.exports = { 23 | speedUnitAmount, 24 | speed, 25 | html 26 | } -------------------------------------------------------------------------------- /tests/validator-type.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test(`accepts 'unit' as type`, t => { 6 | const testArgument = 'unit'; 7 | 8 | t.is(validator({ type: testArgument }).type, testArgument); 9 | }); 10 | 11 | test(`accepts 'conjunction' as type`, t => { 12 | const testArgument = 'conjunction'; 13 | 14 | t.is(validator({ type: testArgument }).type, testArgument); 15 | }); 16 | 17 | test('rejects invalid type strings', t => { 18 | t.throws(()=> { 19 | validator({ type: 'foo' }); 20 | }); 21 | 22 | t.throws(()=> { 23 | validator({ type: '' }); 24 | }); 25 | 26 | t.throws(()=> { 27 | validator({ type: 'con' }); 28 | }); 29 | }); 30 | 31 | 32 | test('rejects invalid type argument types', t => { 33 | t.throws(()=> { 34 | validator({ type: 250 }); 35 | }); 36 | 37 | t.throws(()=> { 38 | validator({ type: ['unit', 'conjunction'] }); 39 | }); 40 | 41 | t.throws(()=> { 42 | validator({ type: {list: 'unit'} }); 43 | }); 44 | 45 | t.throws(()=> { 46 | validator({ type: true }); 47 | }); 48 | 49 | t.throws(()=> { 50 | validator({ type: false }); 51 | }); 52 | }); -------------------------------------------------------------------------------- /tests/validator-digits.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test('accepts integer from 1-21 as digits', t => { 6 | const testArgument1 = 1; 7 | const testArgument2 = 2; 8 | const testArgument3 = 21; 9 | 10 | t.is(validator({ digits: testArgument1 }).digits, testArgument1); 11 | t.is(validator({ digits: testArgument2 }).digits, testArgument2); 12 | t.is(validator({ digits: testArgument3 }).digits, testArgument3); 13 | }); 14 | 15 | test('rejects invalid digits', t => { 16 | t.throws(()=> { 17 | validator({ digits: 0 }); 18 | }); 19 | 20 | t.throws(()=> { 21 | validator({ digits: -1 }); 22 | }); 23 | 24 | t.throws(()=> { 25 | validator({ digits: 22 }); 26 | }); 27 | 28 | t.throws(()=> { 29 | validator({ digits: 1.5 }); 30 | }); 31 | 32 | t.throws(()=> { 33 | validator({ digits: 0.9 }); 34 | }); 35 | }); 36 | 37 | 38 | test('rejects invalid digits argument types', t => { 39 | t.throws(()=> { 40 | validator({ digits: 'two' }); 41 | }); 42 | 43 | t.throws(()=> { 44 | validator({ digits: [1, 2] }); 45 | }); 46 | 47 | t.throws(()=> { 48 | validator({ digits: {padding: 2} }); 49 | }); 50 | 51 | t.throws(()=> { 52 | validator({ digits: true }); 53 | }); 54 | 55 | t.throws(()=> { 56 | validator({ digits: false }); 57 | }); 58 | }); -------------------------------------------------------------------------------- /tests/validator-style.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test(`accepts 'narrow' as style`, t => { 6 | const testArgument = 'narrow'; 7 | 8 | t.is(validator({ style: testArgument }).style, testArgument); 9 | }); 10 | 11 | test(`accepts 'short' as style`, t => { 12 | const testArgument = 'short'; 13 | 14 | t.is(validator({ style: testArgument }).style, testArgument); 15 | }); 16 | 17 | test(`accepts 'long' as style`, t => { 18 | const testArgument = 'long'; 19 | 20 | t.is(validator({ style: testArgument }).style, testArgument); 21 | }); 22 | 23 | test('rejects invalid style strings', t => { 24 | t.throws(()=> { 25 | validator({ style: 'foo' }); 26 | }); 27 | 28 | t.throws(()=> { 29 | validator({ style: '' }); 30 | }); 31 | 32 | t.throws(()=> { 33 | validator({ style: 'narro' }); 34 | }); 35 | }); 36 | 37 | 38 | test('rejects invalid style argument types', t => { 39 | t.throws(()=> { 40 | validator({ style: 250 }); 41 | }); 42 | t.throws(()=> { 43 | validator({ style: ['narrow', 'short', 'long'] }); 44 | }); 45 | 46 | t.throws(()=> { 47 | validator({ style: {type: 'narrow'} }); 48 | }); 49 | 50 | t.throws(()=> { 51 | validator({ style: true }); 52 | }); 53 | 54 | t.throws(()=> { 55 | validator({ style: false }); 56 | }); 57 | }); -------------------------------------------------------------------------------- /tests/validator-language.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test('accepts language code as language', t => { 6 | const testArgument1 = 'en'; 7 | const testArgument2 = 'en-gb'; 8 | const testArgument3 = 'zh-hans'; 9 | const testArgument4 = 'zh-u-nu-hanidec'; 10 | 11 | t.is(validator({ language: testArgument1 }).language, testArgument1); 12 | t.is(validator({ language: testArgument2 }).language, testArgument2); 13 | t.is(validator({ language: testArgument3 }).language, testArgument3); 14 | t.is(validator({ language: testArgument4 }).language, testArgument4); 15 | }); 16 | 17 | test('rejects unsupported language code as language', t => { 18 | t.throws(()=> { 19 | validator({ language: 'foo' }); 20 | }); 21 | }); 22 | 23 | test('rejects invalid language code as language', t => { 24 | t.throws(()=> { 25 | validator({ language: '123' }); 26 | }); 27 | }); 28 | 29 | 30 | test('rejects invalid language argument types', t => { 31 | t.throws(()=> { 32 | validator({ language: 250 }); 33 | }); 34 | 35 | t.throws(()=> { 36 | validator({ language: ['en', 'zh'] }); 37 | }); 38 | 39 | t.throws(()=> { 40 | validator({ language: {country: 'en'} }); 41 | }); 42 | 43 | t.throws(()=> { 44 | validator({ language: true }); 45 | }); 46 | 47 | t.throws(()=> { 48 | validator({ language: false }); 49 | }); 50 | }); -------------------------------------------------------------------------------- /components/options-parser.js: -------------------------------------------------------------------------------- 1 | const regEx = require('./regular-expressions.js'); 2 | 3 | module.exports = function(customOptions) { 4 | let options = {}; 5 | customOptions.forEach(option => { 6 | if(isSpeed(option)) { 7 | options.speed = option; 8 | } 9 | else if(isLanguage(option)) { 10 | options.language = option; 11 | } 12 | else if(isHandlebarsHelper(option)) { 13 | return; 14 | } 15 | else if(isJSArgument(option)) { 16 | Object.assign(options, option); 17 | } 18 | else { 19 | throw new Error(`Time-to-read encountered an unrecognised option: ${JSON.stringify(option)}`); 20 | } 21 | }) 22 | return options; 23 | } 24 | 25 | 26 | function isSpeed(option) { 27 | return new RegExp(regEx.speed,'i').test(option); 28 | } 29 | 30 | function isLanguage(option) { 31 | if(typeof option !== 'string') { return false; } 32 | try { 33 | Intl.getCanonicalLocales(option); 34 | return true; 35 | } 36 | catch { 37 | return false; 38 | } 39 | } 40 | 41 | function isHandlebarsHelper(option) { 42 | if(typeof option !== 'object') { return false; } 43 | 44 | const optionKeys = Object.keys(option); 45 | const handlebarKeys = ['lookupProperty', 'name', 'hash', 'data', 'loc']; 46 | 47 | return handlebarKeys.every(key => { 48 | return optionKeys.includes(key); 49 | }) 50 | } 51 | 52 | function isJSArgument(option) { 53 | if(typeof option === 'object' && !Array.isArray(option)) { 54 | return true; 55 | } 56 | } -------------------------------------------------------------------------------- /tests/validator-insert.test.js: -------------------------------------------------------------------------------- 1 | // Deprecated, remove in 2.0 major release 2 | 3 | const test = require('ava'); 4 | const validator = require('../components/options-validator.js'); 5 | 6 | 7 | test('accepts String as insert', t => { 8 | const testArgument1 = 'foo'; 9 | const testArgument2 = ''; 10 | 11 | t.is(validator({ prepend: testArgument1 }).prepend, testArgument1); 12 | t.is(validator({ append: testArgument1 }).append, testArgument1); 13 | 14 | t.is(validator({ prepend: testArgument2 }).prepend, testArgument2); 15 | t.is(validator({ append: testArgument2 }).append, testArgument2); 16 | }); 17 | 18 | test('accepts Number as insert', t => { 19 | const testArgument = 123; 20 | 21 | t.is(validator({ prepend: testArgument }).prepend, testArgument); 22 | t.is(validator({ append: testArgument }).append, testArgument); 23 | }); 24 | 25 | test('rejects invalid insert argument types', t => { 26 | t.throws(()=> { 27 | validator({ append: ['foo', 'bar'] }); 28 | }); 29 | t.throws(()=> { 30 | validator({ prepend: ['foo', 'bar'] }); 31 | }); 32 | 33 | t.throws(()=> { 34 | validator({ append: {text: 'foo'} }); 35 | }); 36 | t.throws(()=> { 37 | validator({ prepend: {text: 'foo'} }); 38 | }); 39 | 40 | t.throws(()=> { 41 | validator({ prepend: true }); 42 | }); 43 | t.throws(()=> { 44 | validator({ append: true }); 45 | }); 46 | 47 | t.throws(()=> { 48 | validator({ prepend: false }); 49 | }); 50 | t.throws(()=> { 51 | validator({ append: false }); 52 | }); 53 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-plugin-time-to-read", 3 | "version": "1.3.0", 4 | "description": "11ty plugin for estimating the time to read a given text. Supports multiple languages", 5 | "keywords": [ 6 | "11ty", 7 | "eleventy", 8 | "eleventy-plugin", 9 | "time", 10 | "read", 11 | "reading", 12 | "word", 13 | "count", 14 | "speed", 15 | "words per minute", 16 | "wpm", 17 | "how", 18 | "long", 19 | "length", 20 | "estimate" 21 | ], 22 | "homepage": "https://github.com/JKC-Codes/eleventy-plugin-time-to-read#readme", 23 | "bugs": { 24 | "url": "https://github.com/JKC-Codes/eleventy-plugin-time-to-read/issues" 25 | }, 26 | "license": "MPL-2.0", 27 | "author": { 28 | "name": "John Kemp-Cruz", 29 | "url": "https://jkc.codes/" 30 | }, 31 | "files": [ 32 | "index.js", 33 | "components/**" 34 | ], 35 | "main": "index.js", 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/JKC-Codes/eleventy-plugin-time-to-read.git" 39 | }, 40 | "scripts": { 41 | "eleventy": "npx @11ty/eleventy --dryrun --quiet", 42 | "ava": "ava", 43 | "test": "concurrently npm:ava npm:eleventy", 44 | "prepublishOnly": "npm run test" 45 | }, 46 | "devDependencies": { 47 | "@11ty/eleventy": "^2.0.0", 48 | "ava": "^6.0.0", 49 | "concurrently": "^8.0.0" 50 | }, 51 | "peerDependencies": { 52 | "@11ty/eleventy": "*" 53 | }, 54 | "peerDependenciesMeta": { 55 | "@11ty/eleventy": { 56 | "optional": true 57 | } 58 | }, 59 | "engines": { 60 | "node": ">=13.0.0" 61 | }, 62 | "ava": { 63 | "files": [ 64 | "!./tests/test-site/**" 65 | ], 66 | "failFast": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/test-site/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tests 3 | eleventyExcludeFromCollections: true 4 | --- 5 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /tests/parser.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const parser = require('../components/options-parser.js'); 3 | 4 | 5 | test('returns valid speed argument as object', t => { 6 | const testArgument1 = '250 words a minute'; 7 | const testArgument2 = '0.9 characters per second'; 8 | 9 | t.is(parser([testArgument1]).speed, testArgument1); 10 | t.is(parser([testArgument2]).speed, testArgument2); 11 | }); 12 | 13 | test('returns valid language argument as object', t => { 14 | const testArgument1 = 'en'; 15 | const testArgument2 = 'zh-u-nu-hanidec'; 16 | 17 | t.is(parser([testArgument1]).language, testArgument1); 18 | t.is(parser([testArgument2]).language, testArgument2); 19 | }); 20 | 21 | test('returns array of arguments as object', t => { 22 | const testArgument = ['1000 characters per hour', 'hi']; 23 | 24 | t.is(parser(testArgument).speed, testArgument[0]); 25 | t.is(parser(testArgument).language, testArgument[1]); 26 | }); 27 | 28 | test('ignores handlebars helper', t => { 29 | const testArgument = { 30 | lookupProperty: '[Function: lookupProperty]', 31 | name: 'timeToRead', 32 | hash: {}, 33 | data: { 34 | root: '[Object]', 35 | _parent: '[Object]', 36 | key: 3, 37 | index: 3, 38 | first: false, 39 | last: true 40 | }, 41 | loc: { start: '[Object]', end: '[Object]' } 42 | } 43 | 44 | t.is(Object.keys(parser([testArgument])).length, 0); 45 | }); 46 | 47 | test('accepts object argument', t => { 48 | const testArgument = { 49 | speed: '1000 characters per minute', 50 | language: 'en', 51 | style: 'long', 52 | type: 'unit', 53 | hours: 'auto', 54 | minutes: true, 55 | seconds: false, 56 | digits: 1, 57 | output: function(data) { 58 | return data.timing; 59 | }, 60 | 61 | // Deprecated, remove in 2.0 major release 62 | prepend: 'foo', 63 | append: 'bar' 64 | }; 65 | 66 | t.is(parser([testArgument]).speed, testArgument.speed); 67 | t.is(parser([testArgument]).language, testArgument.language); 68 | t.is(parser([testArgument]).style, testArgument.style); 69 | t.is(parser([testArgument]).type, testArgument.type); 70 | t.is(parser([testArgument]).hours, testArgument.hours); 71 | t.is(parser([testArgument]).minutes, testArgument.minutes); 72 | t.is(parser([testArgument]).seconds, testArgument.seconds); 73 | t.is(parser([testArgument]).digits, testArgument.digits); 74 | t.is(parser([testArgument]).output, testArgument.output); 75 | 76 | // Deprecated, remove in 2.0 major release 77 | t.is(parser([testArgument]).prepend, testArgument.prepend); 78 | t.is(parser([testArgument]).append, testArgument.append); 79 | }); -------------------------------------------------------------------------------- /tests/validator-label.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | test('accepts True as label', t => { 6 | const testArgument = true; 7 | 8 | t.is(validator({ hours: testArgument }).hours, testArgument); 9 | t.is(validator({ minutes: testArgument }).minutes, testArgument); 10 | t.is(validator({ seconds: testArgument }).seconds, testArgument); 11 | }); 12 | 13 | test('accepts False as label', t => { 14 | const testArgument = false; 15 | 16 | t.is(validator({ hours: testArgument }).hours, testArgument); 17 | t.is(validator({ minutes: testArgument }).minutes, testArgument); 18 | t.is(validator({ seconds: testArgument }).seconds, testArgument); 19 | }); 20 | 21 | test(`accepts 'auto' as label`, t => { 22 | const testArgument = 'auto'; 23 | 24 | t.is(validator({ hours: testArgument }).hours, testArgument); 25 | t.is(validator({ minutes: testArgument }).minutes, testArgument); 26 | t.is(validator({ seconds: testArgument }).seconds, testArgument); 27 | }); 28 | 29 | test('rejects invalid labels', t => { 30 | t.throws(()=> { 31 | validator({ hours: 'foo' }); 32 | }); 33 | 34 | t.throws(()=> { 35 | validator({ minutes: 'foo' }); 36 | }); 37 | 38 | t.throws(()=> { 39 | validator({ seconds: 'foo' }); 40 | }); 41 | 42 | t.throws(()=> { 43 | validator({ hours: '' }); 44 | }); 45 | 46 | t.throws(()=> { 47 | validator({ minutes: '' }); 48 | }); 49 | 50 | t.throws(()=> { 51 | validator({ seconds: '' }); 52 | }); 53 | 54 | t.throws(()=> { 55 | validator({ hours: 1 }); 56 | }); 57 | 58 | t.throws(()=> { 59 | validator({ minutes: 1 }); 60 | }); 61 | 62 | t.throws(()=> { 63 | validator({ seconds: 1 }); 64 | }); 65 | t.throws(()=> { 66 | validator({ hours: 0 }); 67 | }); 68 | 69 | t.throws(()=> { 70 | validator({ minutes: 0 }); 71 | }); 72 | 73 | t.throws(()=> { 74 | validator({ seconds: 0 }); 75 | }); 76 | }); 77 | 78 | 79 | test('rejects invalid label argument types', t => { 80 | t.throws(()=> { 81 | validator({ hours: 250 }); 82 | }); 83 | t.throws(()=> { 84 | validator({ minutes: 250 }); 85 | }); 86 | t.throws(()=> { 87 | validator({ seconds: 250 }); 88 | }); 89 | 90 | t.throws(()=> { 91 | validator({ hours: [true, false] }); 92 | }); 93 | t.throws(()=> { 94 | validator({ minutes: [true, false] }); 95 | }); 96 | t.throws(()=> { 97 | validator({ seconds: [true, false] }); 98 | }); 99 | 100 | t.throws(()=> { 101 | validator({ hours: {display: 'auto'} }); 102 | }); 103 | t.throws(()=> { 104 | validator({ minutes: {display: 'auto'} }); 105 | }); 106 | t.throws(()=> { 107 | validator({ seconds: {display: 'auto'} }); 108 | }); 109 | }); 110 | 111 | 112 | // Deprecated, remove in 2.0 major release 113 | test(`accepts 'only' as seconds`, t => { 114 | const testArgument = 'only'; 115 | t.is(validator({ seconds: testArgument }).seconds, testArgument); 116 | t.throws(()=> { 117 | validator({ hours: testArgument }); 118 | }); 119 | t.throws(()=> { 120 | validator({ minutes: testArgument }); 121 | }); 122 | }); -------------------------------------------------------------------------------- /tests/validator-speed.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const validator = require('../components/options-validator.js'); 3 | 4 | 5 | // Number unit 6 | test('accepts speed number as integer', t => { 7 | const testArgument1 = '250 words a minute'; 8 | const testArgument2 = '099 words a minute'; 9 | 10 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 11 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 12 | }); 13 | 14 | test('accepts speed number as decimal', t => { 15 | const testArgument1 = '1.5 words a minute'; 16 | const testArgument2 = '01.9 words a minute'; 17 | const testArgument3 = '060.07 words a minute'; 18 | 19 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 20 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 21 | t.is(validator({ speed: testArgument3 }).speed, testArgument3); 22 | }); 23 | 24 | test('rejects invalid speed number', t => { 25 | t.throws(()=> { 26 | validator({ speed: 'foo words a minute' }); 27 | }); 28 | 29 | t.throws(()=> { 30 | validator({ speed: '0 words a minute' }); 31 | }); 32 | 33 | t.throws(()=> { 34 | validator({ speed: '-1 words a minute' }); 35 | }); 36 | }); 37 | 38 | 39 | // Measure unit 40 | test(`accepts 'word' as speed measure`, t => { 41 | const testArgument1 = '250 word a minute'; 42 | const testArgument2 = '250 words a minute'; 43 | 44 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 45 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 46 | }); 47 | 48 | test(`accepts 'character' as speed measure`, t => { 49 | const testArgument1 = '250 character a minute'; 50 | const testArgument2 = '250 characters a minute'; 51 | 52 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 53 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 54 | }); 55 | 56 | test('rejects invalid speed measure', t => { 57 | t.throws(()=> { 58 | validator({ speed: '250 foo a minute' }); 59 | }); 60 | 61 | t.throws(()=> { 62 | validator({ speed: '250 wordsa a minute' }); 63 | }); 64 | 65 | t.throws(()=> { 66 | validator({ speed: '250 charascters a minute' }); 67 | }); 68 | }); 69 | 70 | 71 | // Time unit 72 | test(`accepts 'hour' as speed time unit`, t => { 73 | const testArgument1 = '250 words a hour'; 74 | const testArgument2 = '250 words a hours'; 75 | 76 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 77 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 78 | }); 79 | 80 | test(`accepts 'minute' as speed time unit`, t => { 81 | const testArgument1 = '250 words a minute'; 82 | const testArgument2 = '250 words a minutes'; 83 | 84 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 85 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 86 | }); 87 | 88 | test(`accepts 'second' as speed time unit`, t => { 89 | const testArgument1 = '250 words a second'; 90 | const testArgument2 = '250 words a seconds'; 91 | 92 | t.is(validator({ speed: testArgument1 }).speed, testArgument1); 93 | t.is(validator({ speed: testArgument2 }).speed, testArgument2); 94 | }); 95 | 96 | test('rejects invalid speed time unit', t => { 97 | t.throws(()=> { 98 | validator({ speed: '250 words a foo' }); 99 | }); 100 | 101 | t.throws(()=> { 102 | validator({ speed: '250 words a minut' }); 103 | }); 104 | 105 | t.throws(()=> { 106 | validator({ speed: '250 words a sec' }); 107 | }); 108 | }); 109 | 110 | 111 | // Argument types 112 | test('rejects invalid speed argument types', t => { 113 | t.throws(()=> { 114 | validator({ speed: 250 }); 115 | }); 116 | 117 | t.throws(()=> { 118 | validator({ speed: ['250', 'words', 'minute'] }); 119 | }); 120 | 121 | t.throws(()=> { 122 | validator({ speed: {number: 250} }); 123 | }); 124 | 125 | t.throws(()=> { 126 | validator({ speed: true }); 127 | }); 128 | 129 | t.throws(()=> { 130 | validator({ speed: false }); 131 | }); 132 | }); -------------------------------------------------------------------------------- /components/options-validator.js: -------------------------------------------------------------------------------- 1 | const regEx = require('./regular-expressions.js'); 2 | 3 | module.exports = function(userOptions = {}) { 4 | const validatedOptions = {}; 5 | 6 | for(let [key, value] of Object.entries(userOptions)) { 7 | if(value === null || value === undefined) { 8 | continue; 9 | } 10 | 11 | key = key.toLowerCase(); 12 | 13 | switch(key) { 14 | case 'speed': 15 | validateSpeed(value); 16 | break; 17 | 18 | case 'language': 19 | validateLanguage(value); 20 | break; 21 | 22 | case 'style': 23 | validateStyle(value); 24 | break; 25 | 26 | case 'type': 27 | validateType(value); 28 | break; 29 | 30 | case 'hours': 31 | case 'minutes': 32 | case 'seconds': 33 | validateLabel(value, key); 34 | break; 35 | 36 | case 'digits': 37 | validateDigits(value); 38 | break; 39 | 40 | case 'output': 41 | validateOutput(value); 42 | break; 43 | 44 | default: throw new Error(`Time-to-read encountered an unrecognised option: ${JSON.stringify(key)}`); 45 | 46 | // Deprecated, remove in 2.0 major release 47 | case 'prepend': 48 | case 'append': 49 | validateInserts(value, key); 50 | break; 51 | } 52 | 53 | validatedOptions[key] = value; 54 | } 55 | return validatedOptions; 56 | } 57 | 58 | 59 | function validateSpeed(speed) { 60 | if(!new RegExp(regEx.speed,'i').test(speed)) { 61 | throw new Error(`Time-to-read's speed option must be a space separated string matching: [Number greater than 0] ['words' or 'characters'] ['hour', 'minute' or 'second']. Received ${typeof speed}: ${JSON.stringify(speed)}`); 62 | } 63 | 64 | const speedNumber = speed.match(new RegExp(regEx.speedUnitAmount,'i'))[0]; 65 | if(speedNumber <= 0) { 66 | throw new Error(`Time-to-read's speed option must be greater than 0`); 67 | } 68 | } 69 | 70 | function validateLanguage(language) { 71 | if(typeof language !== 'string') { 72 | throw new Error(`Time-to-read's language option must be a string. Received ${typeof language}: ${JSON.stringify(language)}`); 73 | } 74 | 75 | try { 76 | Intl.getCanonicalLocales(language); 77 | } 78 | catch { 79 | throw new Error(`Time-to-read's language option must be a valid locale format. Received: ${JSON.stringify(language)}`); 80 | } 81 | 82 | if(!Intl.NumberFormat.supportedLocalesOf(language)[0]) { 83 | throw new Error(`The locale used in time-to-read's language option (${JSON.stringify(language)}) is not supported`); 84 | } 85 | } 86 | 87 | function validateStyle(style) { 88 | if(!/^(narrow|short|long)$/i.test(style)) { 89 | throw new Error(`Time-to-read's style option must be a string matching 'narrow', 'short' or 'long'. Received: ${typeof style}: ${JSON.stringify(style)}`); 90 | } 91 | } 92 | 93 | function validateType(type) { 94 | if(!/^(unit|conjunction)$/i.test(type)) { 95 | throw new Error(`Time-to-read's type option must be a string matching 'unit' or 'conjunction'. Received ${typeof type}: ${JSON.stringify(type)}`); 96 | } 97 | } 98 | 99 | function validateLabel(label, optionKey) { 100 | const isBoolean = typeof label === 'boolean'; 101 | const isAuto = /^auto$/i.test(label); 102 | 103 | const isOnlySeconds = optionKey === 'seconds' && /^only$/i.test(label); // Deprecated, remove in 2.0 major release 104 | 105 | if(!isBoolean && !isAuto && !isOnlySeconds) { // Deprecated, remove isOnlySeconds in 2.0 major release 106 | throw new Error(`Time-to-read's ${JSON.stringify(optionKey)} option must be True, False or 'auto'. Received ${typeof label}: '${JSON.stringify(label)}'`); 107 | } 108 | } 109 | 110 | function validateDigits(digits) { 111 | const number = typeof digits === 'string' ? Number(digits) : digits; 112 | const isInteger = Number.isInteger(number); 113 | const isWithinRange = number >= 1 && number <= 21; 114 | 115 | if(!isInteger || !isWithinRange) { 116 | throw new Error(`Time-to-read's digits option must be an integer from 1 to 21. Received ${typeof digits}: ${JSON.stringify(digits)}`); 117 | } 118 | } 119 | 120 | function validateOutput(output) { 121 | if(typeof output !== 'function') { 122 | throw new Error(`Time-to-read's output option must be a function. Received ${typeof output}: ${JSON.stringify(output)}`); 123 | } 124 | } 125 | 126 | 127 | // Deprecated, remove in 2.0 major release 128 | function validateInserts(insert, optionKey) { 129 | const isNull = insert === null; 130 | const isString = typeof insert === 'string'; 131 | const isNumber = typeof insert === 'number'; 132 | 133 | if(!isNull && !isString && !isNumber) { 134 | throw new Error(`Time-to-read's ${optionKey} option must be a string. Received: ${insert}`); 135 | } 136 | } -------------------------------------------------------------------------------- /components/measure-time.js: -------------------------------------------------------------------------------- 1 | const regEx = require('./regular-expressions.js'); 2 | 3 | module.exports = function(page, options) { 4 | const text = convertToPlainText(page); 5 | const parsedSpeed = parseSpeedOption(options.speed); 6 | const counts = getCounts(text); 7 | const totalSeconds = getTotalSeconds(counts, parsedSpeed); 8 | const timings = getTimings(totalSeconds, options.hours, options.minutes, options.seconds); 9 | let sentence = getTimeToRead(timings, options.language, options.style, options.type, options.digits); 10 | 11 | // Deprecated, remove in 2.0 major release 12 | if(/^only$/i.test(options.seconds)) { 13 | return totalSeconds; 14 | } 15 | else { 16 | if(options.prepend !== null) { 17 | sentence = options.prepend + sentence; 18 | } 19 | if(options.append !== null) { 20 | sentence = sentence + options.append; 21 | }; 22 | } 23 | 24 | return options.output({ 25 | timing: sentence, 26 | hours: timings.hours, 27 | minutes: timings.minutes, 28 | seconds: timings.seconds, 29 | totalCharacters: counts.characters, 30 | totalWords: counts.words, 31 | count: parsedSpeed.measure === 'word' ? counts.words : counts.characters, // Deprecated, remove in 2.0 major release 32 | totalSeconds: totalSeconds, 33 | speed: parsedSpeed, 34 | language: options.language 35 | }); 36 | } 37 | 38 | 39 | function convertToPlainText(page) { 40 | const html = page.content || page.templateContent || page; 41 | 42 | if(typeof html !== 'string') { 43 | throw new Error("Time-to-read's input must be a string or template"); 44 | } 45 | 46 | // Remove html 47 | return html.replace(new RegExp(regEx.html,'gi'), ''); 48 | } 49 | 50 | function parseSpeedOption(speedOption) { 51 | const speedOptions = speedOption.split(' '); 52 | 53 | function trimAndLowerCase(text) { 54 | if(text.endsWith('s')) { 55 | text = text.slice(0, -1); 56 | } 57 | return text.toLowerCase(); 58 | }; 59 | 60 | return { 61 | amount: Number(speedOptions[0]), 62 | measure: trimAndLowerCase(speedOptions[1]), 63 | interval: trimAndLowerCase(speedOptions[speedOptions.length - 1]) 64 | }; 65 | } 66 | 67 | function getCounts(text) { 68 | const counts = { 69 | characters: 0, 70 | words: 0 71 | }; 72 | let currentWord = ''; 73 | 74 | for(const character of text) { 75 | // If the character is whitespace 76 | if(/\s/.test(character)) { 77 | if(currentWord === '') { 78 | continue; 79 | } 80 | else { 81 | counts.characters += currentWord.normalize('NFC').length; 82 | counts.words++; 83 | currentWord = ''; 84 | } 85 | } 86 | else { 87 | currentWord += character; 88 | } 89 | } 90 | 91 | if(currentWord !== '') { 92 | counts.characters += currentWord.normalize('NFC').length; 93 | counts.words++; 94 | } 95 | 96 | return counts; 97 | } 98 | 99 | function getTotalSeconds(count, speed) { 100 | if(speed.measure === 'word') { 101 | count = count.words; 102 | } 103 | else if(speed.measure === 'character') { 104 | count = count.characters; 105 | } 106 | 107 | // Normalise to seconds 108 | switch(speed.interval) { 109 | case('hour'): count *= 60; 110 | case('minute'): count *= 60; 111 | } 112 | 113 | return Math.ceil(count / speed.amount); 114 | } 115 | 116 | function getTimings(totalSeconds, showHours, showMinutes, showSeconds) { 117 | let hours, minutes, seconds; 118 | 119 | if(showHours && showMinutes && !showSeconds) { 120 | hours = Math.floor(totalSeconds / 3600); 121 | minutes = Math.round((totalSeconds % 3600) / 60); 122 | if(hours === 0 && minutes === 0) { 123 | minutes = 1; 124 | } 125 | } 126 | else if(showHours && showMinutes && showSeconds) { 127 | hours = Math.floor(totalSeconds / 3600); 128 | minutes = Math.floor((totalSeconds % 3600) / 60); 129 | seconds = totalSeconds % 60; 130 | } 131 | else if(!showHours && showMinutes && !showSeconds) { 132 | minutes = Math.max(1, Math.round(totalSeconds / 60)); 133 | } 134 | else if(!showHours && showMinutes && showSeconds) { 135 | minutes = Math.floor(totalSeconds / 60); 136 | seconds = totalSeconds % 60; 137 | } 138 | else if(!showHours && !showMinutes && showSeconds) { 139 | seconds = Math.max(1, totalSeconds); 140 | } 141 | else if(showHours && !showMinutes && showSeconds) { 142 | hours = Math.floor(totalSeconds / 3600); 143 | seconds = totalSeconds % 3600; 144 | } 145 | else if(showHours && !showMinutes && !showSeconds) { 146 | hours = Math.max(1, Math.round(totalSeconds / 3600)); 147 | } 148 | 149 | if(!showHours || (/^auto$/i.test(showHours) && hours === 0)) { 150 | hours = null; 151 | } 152 | if(!showMinutes || (/^auto$/i.test(showMinutes) && minutes === 0)) { 153 | minutes = null; 154 | } 155 | if(!showSeconds || (/^auto$/i.test(showSeconds) && seconds === 0)) { 156 | seconds = null; 157 | } 158 | 159 | return { hours, minutes, seconds }; 160 | } 161 | 162 | function getTimeToRead(timings, language, style, type, digits) { 163 | 164 | function addLabel(unit, amount) { 165 | return new Intl.NumberFormat(language, { 166 | style: 'unit', 167 | unit: unit, 168 | unitDisplay: style, 169 | minimumIntegerDigits: digits 170 | }).format(amount); 171 | } 172 | 173 | let timeUnits = []; 174 | if(timings.hours !== null) { 175 | timeUnits.push(addLabel('hour', timings.hours)); 176 | } 177 | if(timings.minutes !== null) { 178 | timeUnits.push(addLabel('minute', timings.minutes)); 179 | } 180 | if(timings.seconds !== null) { 181 | timeUnits.push(addLabel('second', timings.seconds)); 182 | } 183 | 184 | return new Intl.ListFormat(language, { 185 | type: type, 186 | style: style 187 | }).format(timeUnits); 188 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time To Read 2 | 3 | An [11ty](https://www.11ty.dev/) plugin that approximates how long it would take a user to read a given text and outputs the result in your choice of language and format. 4 | 5 | ``` 6 | 1 minute 7 | 3 minutes 8 | 3 minutes, 10 seconds 9 | 3 minutes and 10 seconds 10 | 3 min & 10 sec 11 | 3m, 10s 12 | 3m 10s 13 | 3 minuty i 10 sekund 14 | ३ मिनट और १० सेकंड 15 | 三分钟和一〇秒钟 16 | 🕒🕒🕒 3 minutes to read 17 | ``` 18 | 19 | - [Installation](#installation) 20 | - [Usage](#usage) 21 | - [Configuration](#configuration) 22 | - [Speed](#speed) 23 | - [Language](#language) 24 | - [Style](#style) 25 | - [Type](#type) 26 | - [Hours](#hours) 27 | - [Minutes](#minutes) 28 | - [Seconds](#seconds) 29 | - [Digits](#digits) 30 | - [Output](#output) 31 | - [Example](#example) 32 | - [Licence](#licence) 33 | 34 | 35 | ## Installation 36 | 37 | ```shell 38 | npm install eleventy-plugin-time-to-read 39 | ``` 40 | 41 | 42 | ## Usage 43 | 44 | In your [Eleventy config file](https://www.11ty.dev/docs/config/) (`.eleventy.js` by default): 45 | ```js 46 | const timeToRead = require('eleventy-plugin-time-to-read'); 47 | 48 | module.exports = function(eleventyConfig) { 49 | eleventyConfig.addPlugin(timeToRead); 50 | } 51 | ``` 52 | 53 | Then, depending on your template engine (Liquid by default) insert the filter into your template: 54 | 55 | ``` 56 | // Liquid (.liquid) or Nunjucks (.njk): 57 | It will take {{ 'text' | timeToRead }} to read this 58 | 59 | // Handlebars (.hbs): 60 | It will take {{ timeToRead 'text' }} to read this 61 | 62 | // Javascript (.11ty.js): 63 | It will take ${this.timeToRead('text')} to read this 64 | 65 | // Output: 66 | It will take 1 minute to read this 67 | ``` 68 | 69 | 70 | ## Configuration 71 | 72 | ```js 73 | const timeToRead = require('eleventy-plugin-time-to-read'); 74 | 75 | module.exports = function(eleventyConfig) { 76 | eleventyConfig.addPlugin(timeToRead, { 77 | speed: '1000 characters per minute', 78 | language: 'en', 79 | style: 'long', 80 | type: 'unit', 81 | hours: 'auto', 82 | minutes: true, 83 | seconds: false, 84 | digits: 1, 85 | output: function(data) { 86 | return data.timing; 87 | } 88 | }); 89 | } 90 | ``` 91 | 92 | ### Speed 93 | 94 | - Default: '1000 characters per minute' 95 | - Accepts: A String formatted as: Number 'characters'/'words' [optional preposition] 'hour'/'minute'/'second' 96 | 97 | The speed to calculate the time to read with. E.g. '250 words a minute', '5 words per second'. 98 | 99 | Can also be entered when using a filter: 100 | ``` 101 | {{ content | timeToRead: '220 words a minute' }} // Liquid 102 | 103 | {{ content | timeToRead ('220 words a minute') }} // Nunjucks 104 | 105 | {{ timeToRead content '220 words a minute' }} // Handlebars 106 | 107 | ${this.timeToRead(data.content, '220 words a minute')} // JavaScript 108 | ``` 109 | 110 | ### Language 111 | 112 | - Default: 'en' 113 | - Accepts: A String representing a language supported by the [Internationalisation API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). 114 | 115 | The language to use when outputting reading times. For example: 116 | 117 | - fr = French 118 | - es = Spanish 119 | - ru = Russian 120 | - zh-hans = Simplified Chinese 121 | 122 | Number scripts can be changed using '-u-nu-', for example: 123 | 124 | - en = 3 minutes 125 | - zh = 3分钟 126 | - zh-u-nu-hanidec = 三分钟 127 | - en-u-nu-hanidec = 三 minutes 128 | - hi-u-nu-deva = ३ मिनट 129 | 130 | Can also be entered when using a filter: 131 | ``` 132 | {{ content | timeToRead: 'zh-hans' }} // Liquid 133 | 134 | {{ content | timeToRead ('zh-hans') }} // Nunjucks 135 | 136 | {{ timeToRead content 'zh-hans' }} // Handlebars 137 | 138 | ${this.timeToRead(data.content, 'zh-hans')} // JavaScript 139 | ``` 140 | 141 | ### Style 142 | 143 | - Default: 'long' 144 | - Accepts: 'narrow', 'short' or 'long' 145 | 146 | The style of the text and conjunction, for example: 147 | 148 | - long = 3 minutes and 10 seconds 149 | - short = 3 min & 10 sec 150 | - narrow = 3m, 10s 151 | 152 | The exact output depends on the *language* and *type* options. 153 | 154 | ### Type 155 | 156 | - Default: 'unit' 157 | - Accepts: 'unit' or 'conjunction' 158 | 159 | The type of connection between list items, for example: 160 | 161 | - unit = 3 minutes, 10 seconds 162 | - conjunction = 3 minutes and 10 seconds 163 | 164 | The exact output depends on the *language* and *style* options. 165 | 166 | ### Hours 167 | 168 | - Default: 'auto' 169 | - Accepts: Boolean or 'auto' 170 | 171 | Whether to show (*true*) or hide (*false*) hours. 'auto' will only display hours when they are greater than zero. 172 | 173 | ### Minutes 174 | 175 | - Default: 'true' 176 | - Accepts: Boolean or 'auto' 177 | 178 | Whether to show (*true*) or hide (*false*) minutes. 'auto' will only display minutes when they are greater than zero. 179 | 180 | ### Seconds 181 | 182 | - Default: 'false' 183 | - Accepts: Boolean or 'auto' 184 | 185 | Whether to show (*true*) or hide (*false*) seconds. 'auto' will only display seconds when they are greater than zero. 186 | 187 | ### Digits 188 | 189 | - Default: 1 190 | - Accepts: An integer between 1 and 21 inclusive 191 | 192 | The minimum number of digits to display. Will pad with 0 if not met, for example: 193 | 194 | - 1 = 3 minutes, 10 seconds 195 | - 2 = 03 minutes, 10 seconds 196 | - 3 = 003 minutes, 010 seconds 197 | 198 | ### Output 199 | 200 | - Default: function(data) { return data.timing; } 201 | - Accepts: Function 202 | 203 | Controls the output of Time To Read via a callback function's return value. Will be passed an object with the following keys: 204 | 205 | - timing - [String] the computed reading time, for example: '3 minutes, 10 seconds' 206 | - hours - [Number|Null] the number of hours required to read the given text (if applicable) 207 | - minutes - [Number|Null] the number of minutes required to read the given text after hours have been deducted (if applicable) 208 | - seconds - [Number|Null] the number of seconds required to read the given text after hours and minutes have been deducted (if applicable) 209 | - totalCharacters - [Number] the amount of characters in the given text 210 | - totalWords - [Number] the amount of words in the given text 211 | - totalSeconds - [Number] the number of seconds required to read the given text 212 | - speed - [Object] The parsed data from the speed option. Has the following keys: 213 | - measure - [String] 'character' or 'word' 214 | - interval - [String] 'hour', 'minute' or 'second' 215 | - amount - [Number] the amount of measures per interval 216 | - language - [String] returns the string passed to the language option 217 | 218 | Can be used to customise text, for example: 219 | ```js 220 | function (data) { 221 | const numberOfEmoji = Math.max(1, Math.round(data.totalSeconds / 60)); 222 | const emojiString = '🕒'.repeat(numberOfEmoji); 223 | 224 | return `${emojiString} ${data.timing} to read`; // 🕒🕒🕒 3 minutes to read 225 | } 226 | ``` 227 | 228 | 229 | ## Example 230 | 231 | How to create a blog page listing all posts with their reading times as well as include the reading time within those posts. 232 | 233 | #### File structure: 234 | 235 | ``` 236 | _includes 237 | └─ post.liquid 238 | blog 239 | └─ post.md 240 | blog.html 241 | .eleventy.js 242 | ``` 243 | 244 | #### _includes/post.liquid 245 | 246 | ```liquid 247 |
    248 |

    {{ title }}

    249 |

    About {{ content | timeToRead }} to read

    250 |
    251 | 252 |
    253 | {{ content }} 254 |
    255 | ``` 256 | 257 | #### blog/post.md 258 | 259 | ```md 260 | --- 261 | layout: post.liquid 262 | title: Lorem Ipsum 263 | tags: blogPost 264 | --- 265 | Lorem ipsum dolor sit… 266 | ``` 267 | 268 | #### blog.html 269 | 270 | ```html 271 |

    Blog

    272 | 273 | 281 | ``` 282 | 283 | #### .eleventy.js 284 | 285 | ```js 286 | const timeToRead = require('eleventy-plugin-time-to-read'); 287 | 288 | module.exports = function(eleventyConfig) { 289 | eleventyConfig.addPlugin(timeToRead); 290 | } 291 | ``` 292 | 293 | 294 | ## Licence 295 | [MPL-2.0](https://choosealicense.com/licenses/mpl-2.0/) -------------------------------------------------------------------------------- /tests/measure-time.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const measureTimeFunction = require('../components/measure-time.js'); 3 | 4 | function characters(amount) { 5 | return 'a'.repeat(amount); 6 | } 7 | 8 | function words(amount) { 9 | return 'foo '.repeat(amount); 10 | } 11 | 12 | function measureTime(text, options = {}) { 13 | const defaultOptions = { 14 | speed: '1 character per second', 15 | language: 'en', 16 | style: 'long', 17 | type: 'unit', 18 | hours: 'auto', 19 | minutes: 'auto', 20 | seconds: 'auto', 21 | digits: 1, 22 | output: function(data) { return data.timing; }, 23 | prepend: null, // Deprecated, remove in 2.0 major release 24 | append: null, // Deprecated, remove in 2.0 major release 25 | } 26 | const fullOptions = Object.assign({}, defaultOptions, options); 27 | return measureTimeFunction(text, fullOptions); 28 | } 29 | 30 | 31 | test('only accepts string or template', t => { 32 | t.notThrows(()=> { 33 | measureTime('') 34 | }); 35 | t.notThrows(()=> { 36 | measureTime('foo') 37 | }); 38 | t.notThrows(()=> { 39 | measureTime({foo: 'bar', templateContent: 'baz'}) 40 | }); 41 | t.notThrows(()=> { 42 | measureTime({foo: 'bar', content: 'baz'}) 43 | }); 44 | 45 | t.throws(()=> { 46 | measureTime(123) 47 | }); 48 | t.throws(()=> { 49 | measureTime(true) 50 | }); 51 | t.throws(()=> { 52 | measureTime(false) 53 | }); 54 | t.throws(()=> { 55 | measureTime(['foo', 'bar']) 56 | }); 57 | t.throws(()=> { 58 | measureTime({foo: 'bar'}) 59 | }); 60 | }); 61 | 62 | test('ignores html', t => { 63 | t.is(measureTime('', {output: function(data) { return data.totalSeconds; }}), 0); 64 | 65 | t.is(measureTime('

    ', {output: function(data) { return data.totalSeconds; }}), 0); 66 | 67 | t.is(measureTime(`${characters(10)} ${characters(10)}

    characters

    ${characters(10)}`, {output: function(data) { return data.totalSeconds; }}), 40); 68 | }); 69 | 70 | test('calculates correct speed', t => { 71 | t.is(measureTime('', {output: function(data) { return data.totalSeconds; }}), 0); 72 | t.is(measureTime('a', {output: function(data) { return data.totalSeconds; }}), 1); 73 | t.is(measureTime(' a ', {output: function(data) { return data.totalSeconds; }}), 1); 74 | t.is(measureTime(characters(456), {output: function(data) { return data.totalSeconds; }}), 456); 75 | t.is(measureTime(characters(456), {speed: '10 characters per second', output: function(data) { return data.totalSeconds; }}), 46); 76 | 77 | t.is(measureTime('', {speed: '1 word per second', output: function(data) { return data.totalSeconds; }}), 0); 78 | t.is(measureTime('word', {speed: '1 word per second', output: function(data) { return data.totalSeconds; }}), 1); 79 | t.is(measureTime(' word ', {speed: '1 word per second', output: function(data) { return data.totalSeconds; }}), 1); 80 | t.is(measureTime(words(789), {speed: '1 word per second', output: function(data) { return data.totalSeconds; }}), 789); 81 | t.is(measureTime(words(789), {speed: '10 words per second', output: function(data) { return data.totalSeconds; }}), 79); 82 | 83 | t.is(measureTime('', {speed: '1 character per minute', output: function(data) { return data.totalSeconds; }}), 0); 84 | t.is(measureTime('a', {speed: '1 character per minute', output: function(data) { return data.totalSeconds; }}), 60); 85 | t.is(measureTime(' a ', {speed: '1 character per minute', output: function(data) { return data.totalSeconds; }}), 60); 86 | t.is(measureTime(characters(154), {speed: '1 character per minute', output: function(data) { return data.totalSeconds; }}), 9240); 87 | t.is(measureTime(characters(154), {speed: '10 characters per minute', output: function(data) { return data.totalSeconds; }}), 924); 88 | 89 | t.is(measureTime('', {speed: '1 character per hour', output: function(data) { return data.totalSeconds; }}), 0); 90 | t.is(measureTime('a', {speed: '1 character per hour', output: function(data) { return data.totalSeconds; }}), 3600); 91 | t.is(measureTime(' a ', {speed: '1 character per hour', output: function(data) { return data.totalSeconds; }}), 3600); 92 | t.is(measureTime(characters(45), {speed: '1 character per hour', output: function(data) { return data.totalSeconds; }}), 162000); 93 | t.is(measureTime(characters(45), {speed: '10 characters per hour', output: function(data) { return data.totalSeconds; }}), 16200); 94 | }); 95 | 96 | test('outputs multiple languages', t => { 97 | t.is(measureTime('a', {language: 'en'}), '1 second'); 98 | 99 | t.is(measureTime('a', {language: 'es'}), '1 segundo'); 100 | 101 | t.is(measureTime('a', {language: 'zh-u-nu-hanidec'}), '一秒钟'); 102 | }); 103 | 104 | test('outputs different styles', t => { 105 | t.is(measureTime('a', {style: 'long'}), '1 second'); 106 | 107 | t.is(measureTime('a', {style: 'short'}), '1 sec'); 108 | 109 | t.is(measureTime('a', {style: 'narrow'}), '1s'); 110 | }); 111 | 112 | test('outputs different types', t => { 113 | t.is(measureTime(characters(90), {type: 'unit'}), '1 minute, 30 seconds'); 114 | 115 | t.is(measureTime(characters(90), {type: 'conjunction'}), '1 minute and 30 seconds'); 116 | 117 | t.is(measureTime(characters(90), {type: 'conjunction', style: 'short'}), '1 min & 30 sec'); 118 | 119 | t.is(measureTime(characters(90), {type: 'conjunction', style: 'narrow'}), '1m, 30s'); 120 | }); 121 | 122 | test('outputs auto labels correctly', t => { 123 | t.is(measureTime(characters(3599), {hours: 'auto', minutes: 'auto', seconds: 'auto'}), '59 minutes, 59 seconds'); 124 | t.is(measureTime(characters(3600), {hours: 'auto', minutes: 'auto', seconds: 'auto'}), '1 hour'); 125 | t.is(measureTime(characters(3601), {hours: 'auto', minutes: 'auto', seconds: 'auto'}), '1 hour, 1 second'); 126 | t.is(measureTime(characters(3540), {hours: 'auto', minutes: 'auto', seconds: 'auto'}), '59 minutes'); 127 | t.is(measureTime(characters(3660), {hours: 'auto', minutes: 'auto', seconds: 'auto'}), '1 hour, 1 minute'); 128 | }); 129 | 130 | test('outputs true/false labels correctly', t => { 131 | t.is(measureTime('', {hours: true, minutes: true, seconds: true}), '0 hours, 0 minutes, 0 seconds'); 132 | t.is(measureTime(characters(3661), {hours: true, minutes: true, seconds: true}), '1 hour, 1 minute, 1 second'); 133 | t.is(measureTime(characters(3661), {hours: false, minutes: true, seconds: true}), '61 minutes, 1 second'); 134 | t.is(measureTime(characters(3661), {hours: true, minutes: false, seconds: true}), '1 hour, 61 seconds'); 135 | t.is(measureTime(characters(3661), {hours: true, minutes: true, seconds: false}), '1 hour, 1 minute'); 136 | t.is(measureTime(characters(3661), {hours: true, minutes: false, seconds: false}), '1 hour'); 137 | t.is(measureTime(characters(3661), {hours: false, minutes: true, seconds: false}), '61 minutes'); 138 | t.is(measureTime(characters(3661), {hours: false, minutes: false, seconds: true}), '3,661 seconds'); 139 | }); 140 | 141 | test('outputs rounded times correctly', t => { 142 | t.is(measureTime(characters(1), {hours: 'auto', minutes: true, seconds: false}), '1 minute'); 143 | t.is(measureTime(characters(59), {hours: 'auto', minutes: true, seconds: false}), '1 minute'); 144 | t.is(measureTime(characters(89), {hours: false, minutes: true, seconds: false}), '1 minute'); 145 | t.is(measureTime(characters(90), {hours: false, minutes: true, seconds: false}), '2 minutes'); 146 | t.is(measureTime(characters(90), {hours: false, minutes: true, seconds: true}), '1 minute, 30 seconds'); 147 | t.is(measureTime(characters(5399), {hours: true, minutes: false, seconds: false}), '1 hour'); 148 | t.is(measureTime(characters(5400), {hours: true, minutes: false, seconds: false}), '2 hours'); 149 | t.is(measureTime(characters(5400), {hours: true, minutes: false, seconds: true}), '1 hour, 1,800 seconds'); 150 | }); 151 | 152 | test('outputs padded digits', t => { 153 | t.is(measureTime('foo', {digits: 1}), '3 seconds'); 154 | t.is(measureTime('foo', {digits: 2}), '03 seconds'); 155 | t.is(measureTime('foo', {digits: 3}), '003 seconds'); 156 | 157 | t.is(measureTime(characters(90), {digits: 1, minutes: false}), '90 seconds'); 158 | t.is(measureTime(characters(90), {digits: 2, minutes: false}), '90 seconds'); 159 | t.is(measureTime(characters(90), {digits: 3, minutes: false}), '090 seconds'); 160 | }); 161 | 162 | test('passes correct arguments to output', t => { 163 | const arguments = { 164 | hours: true, 165 | minutes: true, 166 | seconds: true, 167 | speed: '5 characters a minute', 168 | language: 'es', 169 | output: function(data) { 170 | return JSON.stringify([ 171 | data.timing, 172 | data.hours, 173 | data.minutes, 174 | data.seconds, 175 | data.count, // Deprecated, remove in 2.0 major release 176 | data.totalCharacters, 177 | data.totalWords, 178 | data.totalSeconds, 179 | data.speed.amount, 180 | data.speed.measure, 181 | data.speed.interval, 182 | data.language 183 | ]); 184 | } 185 | } 186 | 187 | t.is(measureTime('foobarbaz', arguments), `["0 horas, 1 minuto y 48 segundos",0,1,48,9,9,1,108,5,"character","minute","es"]`); 188 | }); 189 | 190 | test('output can be modified', t => { 191 | const outputFunction = function (data) { 192 | const numberOfEmoji = Math.max(1, Math.round(data.totalSeconds / 60)); 193 | const emojiString = '🕒'.repeat(numberOfEmoji); 194 | 195 | return `${emojiString} ${data.timing} to read`; 196 | } 197 | 198 | t.is(measureTime(characters(180), {output: outputFunction}), '🕒🕒🕒 3 minutes to read'); 199 | }); 200 | 201 | 202 | // Deprecated, remove in 2.0 major release 203 | test('outputs with pre/append', t => { 204 | t.is(measureTime('a', {prepend: 'foo'}), 'foo1 second'); 205 | t.is(measureTime('a', {prepend: 'bar '}), 'bar 1 second'); 206 | t.is(measureTime('a', {prepend: '2 '}), '2 1 second'); 207 | 208 | t.is(measureTime('a', {append: 'foo'}), '1 secondfoo'); 209 | t.is(measureTime('a', {append: ' bar'}), '1 second bar'); 210 | t.is(measureTime('a', {append: ' 2'}), '1 second 2'); 211 | }); -------------------------------------------------------------------------------- /tests/test-site/_data/tests.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | title: '1000 characters', 4 | text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent euismod massa vehicula molestie suscipit. Sed ut ante felis. In faucibus pellentesque lacus sed faucibus. Pellentesque pulvinar in erat quis cursus. Nullam lobortis sagittis gravida. Quisque maximus nulla non mauris gravida, nec ornare diam fringilla. Vivamus aliquet lacus ut lorem aliquam molestie. Mauris tincidunt, arcu vitae ultricies luctus, turpis lectus pharetra est, quis euismod nibh massa eu arcu. Duis placerat, felis nec finibus sagittis, ipsum dui scelerisque nibh, sit amet lacinia ipsum eros et massa. Sed tempor, tortor eget lobortis tempor, urna lacus cursus nibh, ut lobortis eros libero sed elit. Cras est augue, tincidunt nec diam vitae, consequat euismod ex. Donec sed pharetra tellus. Proin varius velit velit. Cras aliquam est ut turpis interdum tristique. 5 | 6 | Donec rhoncus tempus augue ac luctus. In vitae molestie erat, sit amet viverra elit. Maecenas tristique finibus sem, nec iaculis leo. Phasellus ante odio.` 7 | }, 8 | { 9 | title: '5000 characters', 10 | text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean fringilla leo ut massa molestie, sed volutpat lacus semper. Sed tincidunt nec quam sit amet feugiat. Curabitur tincidunt est nisi, et laoreet nunc blandit id. Morbi sagittis, tortor eget bibendum feugiat, arcu ex hendrerit orci, vel dignissim mi magna a augue. Nulla vel orci diam. In consequat nisi mi. Curabitur venenatis lorem eu laoreet cursus. Etiam quis ex ligula. Pellentesque imperdiet ut dui at mollis. Duis efficitur blandit dapibus. Nullam sit amet libero imperdiet, malesuada erat nec, aliquet mi. Ut eget dolor fermentum, ullamcorper ex quis, ullamcorper ante. 11 | 12 | Fusce congue ac tortor in fringilla. Quisque eget gravida est, et tincidunt diam. Sed vitae libero eu metus facilisis semper eget id ligula. Praesent molestie massa eget neque vehicula sagittis. Vivamus turpis massa, laoreet ut egestas in, dictum vel arcu. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed nisi justo, iaculis eget augue eu, laoreet interdum nisi. 13 | 14 | Sed varius nunc et enim gravida, ac interdum nunc fringilla. Etiam gravida urna consequat quam egestas euismod. Proin placerat magna tortor, id dictum sapien imperdiet sit amet. In ornare sapien erat, ac vehicula nunc malesuada euismod. Duis aliquam nunc nec dui auctor, sit amet tristique felis dignissim. Maecenas at vestibulum turpis, ut consequat elit. Proin vel eleifend mi, eget pretium eros. Duis enim dolor, pretium at metus at, ornare vestibulum augue. Vestibulum molestie dui in neque ultricies, et sagittis sapien scelerisque. Quisque aliquet faucibus fringilla. Aliquam condimentum eros nec lobortis cursus. 15 | 16 | Vivamus erat mauris, congue ut risus id, mattis tristique lorem. Nunc imperdiet interdum placerat. Etiam blandit vehicula sapien ut bibendum. Suspendisse consectetur eros a nibh iaculis, et scelerisque metus commodo. Nullam id enim leo. Suspendisse venenatis sollicitudin metus quis porttitor. Nullam suscipit neque eu nunc lobortis pellentesque. Quisque ornare mi sed dictum placerat. Curabitur aliquam non orci et rutrum. Sed quis libero elit. Phasellus egestas orci sit amet semper sagittis. Aenean volutpat lorem a tempus fringilla. Nunc ultrices nisl diam, a scelerisque lacus venenatis vitae. Ut urna lorem, scelerisque sit amet aliquam vitae, egestas id sapien. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Phasellus dui dui, tincidunt sed facilisis id, congue eget quam. 17 | 18 | Donec in congue metus. Sed neque nunc, efficitur et varius sed, fringilla nec augue. Integer gravida aliquam elit, et scelerisque orci volutpat ut. Nullam felis risus, elementum ac nisi vitae, rhoncus molestie est. Quisque sapien velit, congue ac purus et, efficitur consequat elit. Mauris fringilla, elit mollis blandit ultrices, nibh lacus suscipit elit, eget porttitor augue sem vitae dui. Fusce nec arcu ac augue lobortis mollis. Duis lacinia, eros a ullamcorper tincidunt, enim leo tincidunt eros, sed posuere dolor ex sit amet erat. Cras in leo velit. 19 | 20 | Vivamus ac ullamcorper leo. Aenean mattis pretium dolor finibus ullamcorper. Sed vel dapibus metus, in lacinia odio. Suspendisse posuere enim elementum eleifend laoreet. In tellus mi, tempus et ultricies non, ultrices in tortor. Integer faucibus ac diam at sagittis. Integer mauris diam, condimentum sed euismod et, suscipit feugiat magna. Nulla at lacus lacinia, euismod orci at, pretium velit. 21 | 22 | In elementum nibh a quam iaculis consequat. Etiam eu gravida ligula. Aenean at nisi ante. Cras quis nunc eu sem congue lacinia. Cras lacinia orci a ex aliquam tincidunt sed ut orci. Ut non metus nunc. Donec porta efficitur ante a sodales. Phasellus eget elementum felis. Aliquam sed hendrerit quam. Nulla justo libero, cursus non tempus id, consectetur eget orci. Suspendisse pretium nisl sed lacus blandit sodales sed et nulla. Mauris placerat ex ipsum, a euismod ex ornare et. Nam euismod accumsan porttitor. Sed nisl sapien, congue et augue eget, fermentum maximus nulla. Morbi ante dui, tincidunt ac eros facilisis, faucibus ullamcorper libero. Duis leo lorem, tempus quis ipsum in, placerat pretium eros. 23 | 24 | Fusce faucibus tellus id mauris aliquet pharetra. Mauris sed bibendum nunc, non fringilla justo. Praesent sed lacus elit. Nullam ut nisl lectus. Integer ac lorem nisl. Aliquam posuere arcu non eleifend vehicula. In sit amet libero eget dui ornare laoreet nec ultrices nulla. Nullam aliquet eros eu ante consequat, eu laoreet lorem sodales. Vivamus eleifend sem nunc, eget facilisis metus rutrum et. Morbi elit libero, ullamcorper sed nisi a, egestas sagittis lectus. Pellentesque at urna quis nibh dapibus malesuada in eu elit. Ut vel lacus et neque consequat pharetra. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent non porttitor tellus. Mauris dictum, risus ac posuere sodales, velit nulla placerat mauris, quis malesuada ante tellus a metus. Duis quam purus, congue at sapien id, fringilla congue et.` 25 | }, 26 | { 27 | title: '250 words', 28 | text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean tincidunt ac nunc at rutrum. Phasellus laoreet euismod efficitur. In gravida fermentum justo ac consequat. Vivamus et diam sed dolor finibus consectetur. Praesent id diam ac nulla commodo venenatis et id augue. Vestibulum semper accumsan neque a pellentesque. Donec accumsan nisi ex, eu suscipit ipsum dapibus sit amet. 29 | 30 | Integer quis ultricies lacus. Cras a pulvinar turpis. Morbi iaculis fringilla rhoncus. Aenean rhoncus mauris sit amet cursus dictum. Pellentesque dapibus, turpis a vulputate lobortis, tellus leo ullamcorper magna, a fermentum tortor ipsum sed urna. Suspendisse felis lorem, semper id erat sed, ornare consectetur mauris. In congue massa ac ante varius condimentum. Proin ac ipsum et lectus blandit malesuada. Integer a purus eget dui elementum hendrerit vel nec risus. Vivamus aliquam at sem nec molestie. Donec placerat nibh nec odio scelerisque, ac efficitur ligula placerat. In hendrerit, lorem vitae tincidunt pellentesque, elit sem tempor ex, eu euismod nunc elit in lacus. Morbi quis accumsan est. Morbi risus massa, iaculis ut leo aliquam, volutpat molestie quam. 31 | 32 | Duis tincidunt scelerisque fringilla. Aliquam justo risus, aliquam nec neque a, aliquam ultrices ex. Sed vulputate viverra mauris id lacinia. Donec tristique mattis lectus, ut dictum lacus tincidunt et. Suspendisse vitae risus nunc. Etiam sit amet enim sed quam porta auctor. In in accumsan mauris. Sed maximus iaculis neque in suscipit. In consequat, nisi sed congue iaculis, nisl lectus volutpat neque, vel ultricies felis arcu sed risus. Aliquam porta sapien ut felis tincidunt pretium. Nam non nulla id.` 33 | }, 34 | { 35 | title: '1000 words', 36 | text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce suscipit nunc eros, a malesuada turpis vestibulum a. Vivamus eu cursus libero. Vestibulum molestie turpis a est maximus interdum. Donec bibendum, massa eget sodales sodales, magna eros consequat magna, eu lobortis elit est ac risus. Nulla facilisi. Ut placerat libero at dui fringilla bibendum. In hac habitasse platea dictumst. Fusce tristique est elit, ut laoreet nisi consequat a. Vestibulum eu sodales augue. Nam vitae tortor vel sapien placerat egestas vehicula eu urna. Phasellus ullamcorper vehicula mauris ut facilisis. 37 | 38 | Quisque ante ex, commodo vel tortor in, bibendum maximus est. Nulla eleifend mauris turpis, nec elementum urna scelerisque in. Etiam dignissim quam in quam euismod porttitor. Integer interdum vitae ligula a tincidunt. Etiam ac pretium nisi, efficitur malesuada libero. Pellentesque ut tellus nulla. Aliquam gravida lacus eros, et porta dolor egestas id. Curabitur maximus pulvinar sem id ullamcorper. Duis gravida aliquet dui sed ultricies. 39 | 40 | Maecenas est ante, accumsan eu facilisis ultrices, placerat sit amet est. Nullam nibh urna, feugiat ac imperdiet ac, tincidunt in lectus. In ipsum nisl, dapibus ut neque a, facilisis varius velit. Curabitur odio erat, ultrices non ligula et, sagittis dictum sem. Pellentesque vel nisl at orci dapibus aliquet. Etiam cursus augue eu ante ullamcorper hendrerit. Vestibulum in neque sem. Vestibulum ultrices accumsan facilisis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Proin pulvinar ac augue et hendrerit. 41 | 42 | Nunc vitae sodales eros. Pellentesque nec eros vitae orci posuere dictum. Donec porta nisi id egestas lobortis. Phasellus consectetur tristique magna at dapibus. Ut vitae lacus scelerisque, congue metus sit amet, maximus erat. Etiam vel convallis leo. Sed neque quam, iaculis eget nulla non, tincidunt aliquam arcu. Sed ex arcu, dignissim ac elit ultricies, sodales facilisis arcu. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum luctus orci id condimentum facilisis. Sed pretium, nulla non rhoncus pellentesque, tellus purus maximus arcu, vel luctus massa est quis purus. Vestibulum vestibulum sapien nulla. 43 | 44 | Nullam convallis orci mi, a laoreet dolor maximus sed. Suspendisse rhoncus malesuada dolor et dignissim. Duis quis efficitur ligula. In quis viverra leo, at aliquam orci. Duis sit amet lorem et massa aliquam varius eget id sem. Etiam ut maximus mi. Praesent ultrices felis ut nulla placerat, eu mattis elit mollis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi aliquam diam nisi, nec posuere dolor vulputate eu. Proin auctor diam non eros blandit, ut efficitur tortor pulvinar. Ut rhoncus ullamcorper feugiat. Vivamus scelerisque euismod varius. Pellentesque lacinia nisl non odio iaculis iaculis id eget massa. Cras consequat diam ut porttitor facilisis. Cras faucibus eget quam in rhoncus. 45 | 46 | Suspendisse ultricies placerat metus at ultrices. Ut vitae orci scelerisque, porttitor ante eu, suscipit nisi. Integer mattis commodo justo at vehicula. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed tristique euismod lobortis. Fusce vitae turpis felis. Praesent sem libero, cursus ut est et, consequat posuere nunc. Sed sit amet ullamcorper turpis, quis gravida orci. Fusce fringilla vitae ligula sit amet maximus. Aliquam erat volutpat. Phasellus et dui non arcu dapibus finibus. Suspendisse potenti. Phasellus vehicula eget lacus non mollis. 47 | 48 | Proin congue dolor sit amet ipsum auctor, et pharetra mi laoreet. In dictum lectus sit amet placerat euismod. Proin ornare porta porta. Duis laoreet, erat at aliquet varius, magna ante pharetra elit, quis ultrices libero nisl a erat. Suspendisse sit amet iaculis tellus. Mauris eu erat et est vulputate hendrerit vel vitae mauris. Vestibulum scelerisque ex ac enim feugiat auctor. Nulla ac erat vel mauris dapibus gravida. Mauris feugiat urna a tellus ultricies malesuada. Duis vel ex et nunc tincidunt bibendum. Aenean vel efficitur dui, non ultrices erat. 49 | 50 | Suspendisse accumsan ornare hendrerit. In venenatis felis ac orci porta, at aliquam elit varius. Aliquam et purus sit amet elit luctus congue. Phasellus lacinia mollis velit vitae ultrices. Sed id molestie ligula, non ullamcorper sem. Maecenas id suscipit elit. Vivamus vitae arcu ut orci fringilla rhoncus. Praesent eleifend sagittis mi vitae porttitor. Aliquam sed faucibus est. Vestibulum pulvinar ligula metus, commodo viverra justo dictum a. Phasellus arcu tortor, rhoncus quis felis non, auctor congue ligula. Curabitur quis diam ante. Nam tempor nec est at placerat. Nulla nisi erat, bibendum non consequat in, egestas et sem. Ut ullamcorper vulputate dui eu varius. 51 | 52 | Nunc viverra, est in blandit cursus, metus ex interdum nisl, vel vehicula nibh lectus vitae dui. Pellentesque tempor risus et diam euismod ultricies. Nullam lacinia pellentesque nisi, blandit sollicitudin est finibus sed. Vestibulum dignissim in ipsum non posuere. Phasellus egestas urna posuere ex porta, vitae ultrices dui lacinia. Mauris porta eu ante sed malesuada. Etiam ultrices interdum accumsan. Nullam id commodo sem. 53 | 54 | Nunc vestibulum in nisi sed condimentum. Mauris semper id orci quis sodales. Morbi viverra dignissim turpis at placerat. Praesent tincidunt enim in tellus feugiat ullamcorper. Aenean nulla enim, vulputate quis viverra ac, eleifend eu ipsum. In feugiat lorem volutpat lorem porttitor aliquam. Pellentesque metus enim, porttitor non maximus ut, ornare eu augue. Proin egestas purus eu justo laoreet, eu consequat tellus pulvinar. Curabitur accumsan nulla id tellus fermentum, vitae dapibus risus molestie. Nulla id purus orci. 55 | 56 | Nunc id nulla sed nisl consequat iaculis ut eu arcu. Proin sit amet ornare velit, ac maximus tortor. Maecenas eget dignissim tortor. Nullam porttitor erat eu augue rutrum volutpat a at mi. Quisque maximus egestas magna, vel sodales magna viverra in. Pellentesque vitae fringilla dui. Donec ullamcorper laoreet elit eu tempor. Curabitur consequat, justo sed aliquam vulputate, neque libero sodales tortor, elementum volutpat lectus lectus non est. 57 | 58 | Donec et lacinia ex. Maecenas tincidunt consectetur velit at vestibulum. Nullam sit amet justo a arcu varius semper. Nulla facilisi. Sed eu mauris maximus, pulvinar massa ac, convallis lorem. Nulla ut sem fermentum felis consectetur pretium ut ac neque. Fusce viverra hendrerit feugiat. 59 | 60 | Ut pretium, elit quis venenatis egestas, ante erat cursus nunc, non pulvinar tortor lorem in quam. Nam quis diam id neque mollis bibendum. Curabitur id tincidunt purus, nec rutrum velit. Nam sodales augue nunc, at fringilla risus aliquet vitae.` 61 | } 62 | ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | --------------------------------------------------------------------------------