├── .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 |
6 | {{#each tests}}
7 | - {{this.title}}: {{timeToRead this.text}}
8 | {{/each}}
9 |
--------------------------------------------------------------------------------
/tests/test-site/pages/liquid/noargs.liquid:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Liquid without arguments'
3 | ---
4 |
5 |
6 | {%- for test in tests %}
7 | - {{ test.title }}: {{ test.text | timeToRead }}
8 | {%- endfor %}
9 |
--------------------------------------------------------------------------------
/tests/test-site/pages/nunjucks/noargs.njk:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Nunjucks without arguments'
3 | ---
4 |
5 |
6 | {%- for test in tests %}
7 | - {{ test.title }}: {{ test.text | timeToRead }}
8 | {%- endfor %}
9 |
--------------------------------------------------------------------------------
/tests/test-site/pages/handlebars/args.hbs:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Handlebars with arguments'
3 | ---
4 |
5 |
6 | {{#each tests}}
7 | - {{this.title}}: {{timeToRead this.text 'zh' '100 words per minute'}}
8 | {{/each}}
9 |
--------------------------------------------------------------------------------
/tests/test-site/pages/liquid/args.liquid:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Liquid with arguments'
3 | ---
4 |
5 |
6 | {%- for test in tests %}
7 | - {{ test.title }}: {{ test.text | timeToRead: 'zh', '100 words per minute' }}
8 | {%- endfor %}
9 |
--------------------------------------------------------------------------------
/tests/test-site/pages/nunjucks/args.njk:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Nunjucks with arguments'
3 | ---
4 |
5 |
6 | {%- for test in tests %}
7 | - {{ test.title }}: {{ test.text | timeToRead('zh', '100 words per minute') }}
8 | {%- endfor %}
9 |
--------------------------------------------------------------------------------
/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 |
7 | {%- for page in collections.all %}
8 | -
9 | {{ page.data.title }} — checking status…
10 | {{ page.content }}
11 |
12 | {%- endfor %}
13 |
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 |
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 |
--------------------------------------------------------------------------------