├── .gitignore
├── .npmignore
├── test
├── _import.mjs
├── _require.cjs
└── index.test.js
├── .editorconfig
├── lazy-example.js
├── .github
└── workflows
│ └── ci.yml
├── LICENSE
├── package.json
├── README.md
└── src
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github
2 | node_modules
3 | .DS_Store
4 | .editorconfig
5 |
--------------------------------------------------------------------------------
/test/_import.mjs:
--------------------------------------------------------------------------------
1 | import assert from 'assert';
2 | import plugin from 'markdown-it-image-figures';
3 |
4 | assert.equal(typeof plugin, 'function', 'should return a function');
5 |
--------------------------------------------------------------------------------
/test/_require.cjs:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const plugin = require('markdown-it-image-figures');
3 |
4 | assert.equal(typeof plugin, 'function', 'should return a function');
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | indent_style = space
7 | indent_size = 2
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/lazy-example.js:
--------------------------------------------------------------------------------
1 | /* global lozad */
2 | /* eslint-env browser */
3 | (function (useNative, selector) {
4 | // Lazy Loading supported
5 | if (useNative && 'loading' in HTMLImageElement.prototype) {
6 | const lazyEls = document.querySelectorAll(`.${selector}`);
7 |
8 | lazyEls.forEach((lazyEl) => {
9 | lazyEl.setAttribute('src', lazyEl.getAttribute('data-src'));
10 | });
11 | } else {
12 | const observer = lozad(`.${selector}`);
13 | observer.observe();
14 | }
15 | }(true, 'lazy'));
16 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 |
5 | concurrency:
6 | group: branch-node-${{ github.ref }}
7 | cancel-in-progress: true
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node: [ '12', '14', '16' ]
15 | name: Node ${{ matrix.node }} sample
16 | steps:
17 | - uses: actions/checkout@v3
18 | - name: Setup node
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: ${{ matrix.node }}
22 | - run: npm ci --ignore-scripts
23 | - run: npm run build
24 | - run: npm test
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Antonio Laguna
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markdown-it-image-figures",
3 | "version": "2.0.2",
4 | "description": "Render images occurring by itself in a paragraph as a figure with support for figcaptions.",
5 | "license": "MIT",
6 | "repository": "git+https://github.com/Antonio-Laguna/markdown-it-image-figures.git",
7 | "author": {
8 | "name": "Antonio Laguna",
9 | "email": "antonio@laguna.es",
10 | "url": "https://antonio.laguna.es"
11 | },
12 | "engines": {
13 | "node": ">=12.0.0"
14 | },
15 | "source": "src/index.js",
16 | "type": "module",
17 | "exports": {
18 | "import": "./dist/markdown-it-images-figures.mjs",
19 | "require": "./dist/markdown-it-images-figures.cjs",
20 | "default": "./dist/markdown-it-images-figures.module.js"
21 | },
22 | "main": "./dist/markdown-it-images-figures.cjs",
23 | "module": "./dist/markdown-it-images-figures.module.js",
24 | "unpkg": "./dist/markdown-it-images-figures.umd.js",
25 | "scripts": {
26 | "build": "microbundle --target node --compress --sourcemap false",
27 | "clean": "node -e \"fs.rmSync('./dist', { recursive: true, force: true });\"",
28 | "dev": "microbundle watch",
29 | "test": "mocha ./test/index.test.js && npm run test:exports",
30 | "test:exports": "node ./test/_import.mjs && node ./test/_require.cjs",
31 | "lint": "eslint src/*.js lazy-example.js",
32 | "prepublishOnly": "npm run clean && npm run build && npm run lint && npm run test"
33 | },
34 | "files": [
35 | "src/index.js",
36 | "LICENSE",
37 | "README.md",
38 | "dist"
39 | ],
40 | "homepage": "https://github.com/Antonio-Laguna/markdown-it-image-figures",
41 | "keywords": [
42 | "markdown-it",
43 | "markdown-it-plugin",
44 | "img",
45 | "figure",
46 | "lazy",
47 | "image"
48 | ],
49 | "eslintConfig": {
50 | "extends": [
51 | "firstandthird",
52 | "plugin:mocha/recommended"
53 | ],
54 | "plugins": [
55 | "mocha"
56 | ],
57 | "env": {
58 | "browser": false,
59 | "mocha": true,
60 | "node": true,
61 | "es6": true
62 | }
63 | },
64 | "peerDependencies": {
65 | "markdown-it": "*"
66 | },
67 | "devDependencies": {
68 | "babel-eslint": "^10.1.0",
69 | "eslint": "^8.12.0",
70 | "eslint-config-firstandthird": "^6.0.3",
71 | "eslint-plugin-import": "^2.25.4",
72 | "eslint-plugin-mocha": "^10.0.3",
73 | "markdown-it": "*",
74 | "markdown-it-attrs": "*",
75 | "microbundle": "^0.14.2",
76 | "mocha": "^9.2.2"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Markdown IT Image Figures
2 |
3 |
4 |
5 | Render images occurring by itself in a paragraph as `
`, similar to [pandoc's implicit figures](http://pandoc.org/README.html#images).
6 |
7 | This module is a fork from [markdown-it-implicit-figures](https://github.com/arve0/markdown-it-implicit-figures) in which I wanted to introduce new features and make sure this was up to what the standard is today.
8 |
9 | Example input:
10 | ```md
11 | text with 
12 |
13 | 
14 |
15 | works with links too:
16 |
17 | [](page.html)
18 | ```
19 |
20 | Output:
21 | ```html
22 |
text with 
23 |
24 | works with links too:
25 |
26 | ```
27 |
28 |
29 | ## Install
30 |
31 | ```
32 | $ npm i markdown-it-image-figures
33 | ```
34 |
35 | ## Usage
36 |
37 | ```js
38 | const md = require('markdown-it')();
39 | const implicitFigures = require('markdown-it-image-figures');
40 |
41 | md.use(implicitFigures);
42 |
43 | const src = 'text with \n\n\n\nanother paragraph';
44 | const res = md.render(src);
45 |
46 | console.log(res);
47 |
48 | /*
49 | text with 
50 |
51 | another paragraph
52 | */
53 | ```
54 |
55 | ### Options
56 |
57 | - `dataType`: Set `dataType` to `true` to declare the `data-type` being wrapped,
58 | e.g.: ``. This can be useful for applying a special
59 | styling for different kind of figures.
60 |
61 | - `figcaption`: Set `figcaption` to `true` to use the title as a `` block after the image. E.g.: `` renders to
62 |
63 | ```html
64 |
65 |
66 | This is a caption
67 |
68 | ```
69 |
70 | - `tabindex`: Set `tabindex` to `true` to add a `tabindex` property to each figure, beginning at `tabindex="1"` and incrementing for each figure encountered. Could be used with [this css-trick](https://css-tricks.com/expanding-images-html5/), which expands figures upon mouse-over.
71 |
72 | - `link`: Put a link around the image if there is none yet.
73 |
74 | - `copyAttrs`: Copy attributes matching (RegExp or string) `copyAttrs` to `figure` element.
75 |
76 | - `lazy`: Applies the `loading` attribute as `lazy`.
77 |
78 | - `removeSrc`: Removes the source from the image and saves it on `data-src`.
79 |
80 | Code like `` renders to:
81 |
82 | ````html
83 |
84 |
85 |
86 | ````
87 |
88 | You can override it for a single image with something like `{loading=eager}` which will generate the following markup:
89 |
90 | ````html
91 |
92 |
93 |
94 | ````
95 |
96 | - `classes`: Adds the classes to the list of classes the image might have.
97 |
98 | - `async`: Adds the attribute `decoding="async"` to all images. As with `lazy` you should be able to undo this for singular images `{decoding=auto}`
99 |
100 | ## Web performance recommended settings
101 |
102 | Recommended settings for web performance is as follows
103 |
104 | ```
105 | {
106 | lazy: true,
107 | async: true
108 | }
109 | ```
110 |
111 | Which will add `loading="lazy"` and `decoding="async"` to all images. This can be changed per image as explained above so you can opt out for a image at the top if you'd like. This will work great for the majority of the browsers.
112 |
113 | However, if you need to broad your browser support and ensure that old browsers get lazy loaded images, you should probably use this setting:
114 |
115 | ```js
116 | md.use(implicitFigures, {
117 | lazy: true,
118 | removeSrc: true,
119 | async: true,
120 | classes: 'lazy'
121 | });
122 |
123 | const src = '';
124 | const res = md.render(src);
125 |
126 | console.log(res);
127 | /*
128 |
129 |
130 |
131 | */
132 | ```
133 |
134 | Then you need to load something like [Lozad.js](https://github.com/ApoorvSaxena/lozad.js) and some script like [this](./lazy-example.js). You might want to customise the class on the attribute `classes` which get added to the `img` (for easy selector).
135 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function removeAttributeFromList(attrs, attribute) {
4 | const arr = Array.isArray(attrs) ? attrs : [];
5 |
6 | return arr.filter(([k]) => k !== attribute);
7 | }
8 |
9 | function removeAttributeFromImage(image, attribute) {
10 | if (image && image.attrs) {
11 | image.attrs = removeAttributeFromList(image.attrs, attribute);
12 | }
13 | }
14 |
15 | export default function imageFiguresPlugin(md, options) {
16 | options = options || {};
17 |
18 | function imageFigures(state) {
19 | // reset tabIndex on md.render()
20 | let tabIndex = 1;
21 |
22 | // do not process first and last token
23 | for (let i = 1, l = state.tokens.length; i < (l - 1); ++i) {
24 | const token = state.tokens[i];
25 |
26 | if (token.type !== 'inline') {
27 | continue;
28 | }
29 | // children: image alone, or link_open -> image -> link_close
30 | if (!token.children || (token.children.length !== 1 && token.children.length !== 3)) {
31 | continue;
32 | }
33 | // one child, should be img
34 | if (token.children.length === 1 && token.children[0].type !== 'image') {
35 | continue;
36 | }
37 | // three children, should be image enclosed in link
38 | if (token.children.length === 3) {
39 | const [childrenA, childrenB, childrenC] = token.children;
40 | const isEnclosed = childrenA.type !== 'link_open' ||
41 | childrenB.type !== 'image' ||
42 | childrenC.type !== 'link_close';
43 |
44 | if (isEnclosed) {
45 | continue;
46 | }
47 | }
48 | // prev token is paragraph open
49 | if (i !== 0 && state.tokens[i - 1].type !== 'paragraph_open') {
50 | continue;
51 | }
52 | // next token is paragraph close
53 | if (i !== (l - 1) && state.tokens[i + 1].type !== 'paragraph_close') {
54 | continue;
55 | }
56 |
57 | // We have inline token containing an image only.
58 | // Previous token is paragraph open.
59 | // Next token is paragraph close.
60 | // Lets replace the paragraph tokens with figure tokens.
61 | const figure = state.tokens[i - 1];
62 | figure.type = 'figure_open';
63 | figure.tag = 'figure';
64 | state.tokens[i + 1].type = 'figure_close';
65 | state.tokens[i + 1].tag = 'figure';
66 |
67 | if (options.dataType) {
68 | state.tokens[i - 1].attrPush(['data-type', 'image']);
69 | }
70 | let image;
71 |
72 | if (options.link && token.children.length === 1) {
73 | [image] = token.children;
74 | const link = new state.Token('link_open', 'a', 1);
75 | link.attrPush(['href', image.attrGet('src')]);
76 |
77 | token.children.unshift(link);
78 | token.children.push(new state.Token('link_close', 'a', -1));
79 | }
80 |
81 | // for linked images, image is one off
82 | image = token.children.length === 1 ? token.children[0] : token.children[1];
83 |
84 | if (options.figcaption) {
85 | let figCaption;
86 | const captionObj = image.attrs.find(([k]) => k === 'title');
87 |
88 | if (Array.isArray(captionObj)) {
89 | figCaption = captionObj[1];
90 | }
91 |
92 | if (figCaption) {
93 | const [captionContent] = md.parseInline(figCaption, state.env);
94 | token.children.push(
95 | new state.Token('figcaption_open', 'figcaption', 1)
96 | );
97 | token.children.push(...captionContent.children);
98 | token.children.push(
99 | new state.Token('figcaption_close', 'figcaption', -1)
100 | );
101 |
102 | if (image.attrs) {
103 | image.attrs = removeAttributeFromList(image.attrs, 'title');
104 | }
105 | }
106 | }
107 |
108 | if (options.copyAttrs && image.attrs) {
109 | const f = options.copyAttrs === true ? '' : options.copyAttrs;
110 | // Copying so any further changes aren't duplicated
111 | figure.attrs = image.attrs
112 | .filter(([k]) => k.match(f))
113 | .map(a => Array.from(a));
114 | }
115 |
116 | if (options.tabindex) {
117 | // add a tabindex property
118 | // you could use this with css-tricks.com/expanding-images-html5
119 | state.tokens[i - 1].attrPush(['tabindex', tabIndex]);
120 | tabIndex++;
121 | }
122 |
123 | if (options.lazy) {
124 | const hasLoading = image.attrs.some(([attribute]) => attribute === 'loading');
125 |
126 | if (!hasLoading) {
127 | image.attrs.push(['loading', 'lazy']);
128 | }
129 | }
130 |
131 | if (options.async) {
132 | const hasDecoding = image.attrs.some(([attribute]) => attribute === 'decoding');
133 |
134 | if (!hasDecoding) {
135 | image.attrs.push(['decoding', 'async']);
136 | }
137 | }
138 |
139 | if (options.classes && typeof options.classes === 'string') {
140 | let hasClass = false;
141 |
142 | for (let j = 0, length = image.attrs.length; j < length && !hasClass; j++) {
143 | const attrPair = image.attrs[j];
144 |
145 | if (attrPair[0] === 'class') {
146 | attrPair[1] = `${attrPair[1]} ${options.classes}`;
147 | hasClass = true;
148 | }
149 | }
150 |
151 | if (!hasClass) {
152 | image.attrs.push(['class', options.classes]);
153 | }
154 | }
155 |
156 | if (options.removeSrc) {
157 | const src = image.attrs.find(([k]) => k === 'src');
158 | image.attrs.push(['data-src', src[1]]);
159 | removeAttributeFromImage(image, 'src');
160 | }
161 | }
162 | }
163 |
164 | md.core.ruler.before('linkify', 'image_figures', imageFigures);
165 | }
166 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-arrow-callback */
2 | 'use strict';
3 | import assert from 'assert';
4 | import attrs from 'markdown-it-attrs';
5 | import mdIT from 'markdown-it';
6 | import implicitFigures from '../dist/markdown-it-images-figures.mjs';
7 |
8 | describe('markdown-it-image-figures', function() {
9 | let md;
10 | beforeEach(function() {
11 | md = mdIT().use(implicitFigures);
12 | });
13 |
14 | it('should add when image is by itself in a paragraph', function() {
15 | const src = 'text with \n\n\n\nanother paragraph';
16 | const expected = 'text with 
\n
\nanother paragraph
\n';
17 | const res = md.render(src);
18 | assert.strictEqual(res, expected);
19 | });
20 |
21 | it('should add when image is by itself in a paragraph and preceeded by a standalone link', function() {
22 | md = mdIT().use(implicitFigures, { dataType: true, figcaption: true });
23 | const src = '[](http://example.com)';
24 | const expected = '
Caption\n';
25 | const res = md.render(src);
26 | assert.strictEqual(res, expected);
27 | });
28 |
29 | it('should add data-type=image to figures when opts.dataType is set', function() {
30 | md = mdIT().use(implicitFigures, { dataType: true });
31 | const src = '\n';
32 | const expected = '
\n';
33 | const res = md.render(src);
34 | assert.strictEqual(res, expected);
35 | });
36 |
37 | it('should add convert alt text into a figcaption when opts.figcaption is set', function() {
38 | md = mdIT().use(implicitFigures, { figcaption: true });
39 | const src = '';
40 | const expected = '
This is a caption\n';
41 | const res = md.render(src);
42 | assert.strictEqual(res, expected);
43 | });
44 |
45 | it('should convert alt text for each image into a figcaption when opts.figcaption is set', function() {
46 | md = mdIT().use(implicitFigures, { figcaption: true });
47 | const src = '\n\n';
48 | const expected = '
caption 1\n
caption 2\n';
49 | const res = md.render(src);
50 | assert.strictEqual(res, expected);
51 | });
52 |
53 | it('should add incremental tabindex to figures when opts.tabindex is set', function() {
54 | md = mdIT().use(implicitFigures, { tabindex: true });
55 | const src = '\n\n';
56 | const expected = '
\n
\n';
57 | const res = md.render(src);
58 | assert.strictEqual(res, expected);
59 | });
60 |
61 | it('should reset tabindex on each md.render()', function() {
62 | md = mdIT().use(implicitFigures, { tabindex: true });
63 | const src = '\n\n';
64 | const expected = '
\n
\n';
65 | let res = md.render(src);
66 | assert.strictEqual(res, expected);
67 | // render again, should produce same if resetting
68 | res = md.render(src);
69 | assert.strictEqual(res, expected);
70 | });
71 |
72 | it('should not make figures of paragraphs with text and inline code', function() {
73 | const src = 'Text.\n\nAnd `code`.';
74 | const expected = 'Text.
\nAnd code.
\n';
75 | const res = md.render(src);
76 | assert.strictEqual(res, expected);
77 | });
78 |
79 | it('should not make figures of paragraphs with links only', function() {
80 | const src = '[link](page.html)';
81 | const expected = 'link
\n';
82 | const res = md.render(src);
83 | assert.strictEqual(res, expected);
84 | });
85 |
86 | it('should linkify captions', function() {
87 | md = mdIT({ linkify: true }).use(implicitFigures, { figcaption: true });
88 | const src = '';
89 | const expected = '
www.google.com\n';
90 | const res = md.render(src);
91 | assert.strictEqual(res, expected);
92 | });
93 |
94 | it('should work with markdown-it-attrs', function() {
95 | md = mdIT().use(attrs).use(implicitFigures);
96 | const src = '{.asdf}';
97 | const expected = '
\n';
98 | const res = md.render(src);
99 | assert.strictEqual(res, expected);
100 | });
101 |
102 | it('should put the image inside a link to the image if it is not yet linked', function() {
103 | md = mdIT().use(implicitFigures, { link: true });
104 | const src = '';
105 | const expected = '
\n';
106 | const res = md.render(src);
107 | assert.strictEqual(res, expected);
108 | });
109 |
110 | it('should not mess up figcaption when linking', function() {
111 | md = mdIT().use(implicitFigures, { figcaption: true, link: true });
112 | const src = '';
113 | const expected = '
www.google.com\n';
114 | const res = md.render(src);
115 | assert.strictEqual(res, expected);
116 | });
117 |
118 | it('should leave the image inside a link (and not create an extra one) if it is already linked', function() {
119 | md = mdIT().use(implicitFigures, { link: true });
120 | const src = '[](link.html)';
121 | const expected = '
\n';
122 | const res = md.render(src);
123 | assert.strictEqual(res, expected);
124 | });
125 |
126 | it('should keep structured markup inside caption (event if not supported in "alt" attribute)', function() {
127 | md = mdIT().use(implicitFigures, { figcaption: true });
128 | const src = '")';
129 | const expected = '
Image from source\n';
130 | const res = md.render(src);
131 | assert.strictEqual(res, expected);
132 | });
133 |
134 | it('should copy attributes from img to figure tag', function() {
135 | md = mdIT().use(attrs).use(implicitFigures, { copyAttrs: '^class$' });
136 | const src = '{.cls attr=val}';
137 | const expected = '
\n';
138 | const res = md.render(src);
139 | assert.strictEqual(res, expected);
140 | });
141 |
142 | it('should support lazy loading', function() {
143 | md = mdIT().use(attrs).use(implicitFigures, { lazy: true });
144 | const src = '';
145 | const expected = '
\n';
146 | const res = md.render(src);
147 | assert.strictEqual(res, expected);
148 | });
149 |
150 | it('should be possible to override lazy', function() {
151 | md = mdIT().use(attrs).use(implicitFigures, { lazy: true });
152 | const src = '{loading=eager}';
153 | const expected = '
\n';
154 | const res = md.render(src);
155 | assert.strictEqual(res, expected);
156 | });
157 |
158 | it('should support async decoding', function() {
159 | md = mdIT().use(attrs).use(implicitFigures, { async: true });
160 | const src = '';
161 | const expected = '
\n';
162 | const res = md.render(src);
163 | assert.strictEqual(res, expected);
164 | });
165 |
166 | it('should be possible to override decoding', function() {
167 | md = mdIT().use(attrs).use(implicitFigures, { async: true });
168 | const src = '{decoding=sync}';
169 | const expected = '
\n';
170 | const res = md.render(src);
171 | assert.strictEqual(res, expected);
172 | });
173 |
174 | it('should support removing source', function() {
175 | md = mdIT().use(attrs).use(implicitFigures, { removeSrc: true });
176 | const src = '';
177 | const expected = '
\n';
178 | const res = md.render(src);
179 | assert.strictEqual(res, expected);
180 | });
181 |
182 | it('should support adding classes', function() {
183 | md = mdIT().use(attrs).use(implicitFigures, { classes: 'one two' });
184 | const src = '';
185 | const expected = '
\n';
186 | const res = md.render(src);
187 | assert.strictEqual(res, expected);
188 | });
189 |
190 | it('should support copying classes', function() {
191 | md = mdIT().use(attrs).use(implicitFigures, { copyAttrs: '^class$', classes: 'one two' });
192 | const src = '{.cls attr=val}';
193 | const expected = '
\n';
194 | const res = md.render(src);
195 | assert.strictEqual(res, expected);
196 | });
197 | });
198 |
--------------------------------------------------------------------------------