From 72aaa42bcee5cc59d085ad771c2a55b3747dd003 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Wed, 1 Feb 2023 15:20:58 +0000 Subject: [PATCH] Add syntax for defining settings on images (#43) * Add modifier to support setting image sizes * Fix eslint issues in plugin * Add image sizing to main test * Add plugin to readme + changelog * Allow the units to be customised * Switch image_size to image_settings, add alignment support * Don't actually use float for left/right alignment * Tweak README wording for plugin * Wording tweaks --- CHANGELOG.md | 1 + README.md | 30 +++++++ fixtures/full-input.md | 4 + fixtures/full-output.html | 2 + index.js | 5 ++ modifiers/image_settings.js | 131 +++++++++++++++++++++++++++++++ modifiers/image_settings.test.js | 95 ++++++++++++++++++++++ styles/_images.scss | 24 +++++- util/regex_escape.js | 25 ++++++ 9 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 modifiers/image_settings.js create mode 100644 modifiers/image_settings.test.js create mode 100644 util/regex_escape.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 02af807..c52b685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Each list item should be prefixed with `(patch)` or `(minor)` or `(major)`. See `PUBLISH.md` for instructions on how to publish a new version. --> +- (minor) Add syntax for defining settings on images, such as size and alignment - (patch) Dependency updates diff --git a/README.md b/README.md index d53535b..42eb2c0 100644 --- a/README.md +++ b/README.md @@ -910,6 +910,36 @@ Set this property to `false` to disable this plugin. - `sluggify` (`function(string): string`, optional): Custom function to convert heading content to a slug Id. +### image_settings + +
+Add support for defining settings on images, such as size and alignment. + +The syntax for this is `{ width= height= align= }`, at the end of the image markup. +E.g. `![alt](test.png "title"){ width=100 height=200 align=left }`. +All settings are optional, and the order does not matter. + +By default, the width and height can be plain number (`100`), pixels (`100px`), or percentage (`100%`). +Other units can be supported by passing an array of unit strings via the `sizeUnits` option. + +Alignment can be left unset, which will center the image, or can be set to either `left` or `right`. + +**Example Markdown input:** + + ![alt](test.png "title"){ width=100 height=200 align=left } + +**Example HTML output:** + +

alt

+ +**Options:** + +Pass options for this plugin as the `image_settings` property of the `do-markdownit` plugin options. +Set this property to `false` to disable this plugin. + +- `sizeUnits` (`string[]`, optional, defaults to `['', 'px', '%']`): Image size units to allow. +
+ ### prismjs
diff --git a/fixtures/full-input.md b/fixtures/full-input.md index 5b7a845..964854c 100644 --- a/fixtures/full-input.md +++ b/fixtures/full-input.md @@ -36,6 +36,10 @@ Here's how to include an image with alt text and a title: ![Alt text for screen readers](https://assets.digitalocean.com/logos/DO_Logo_horizontal_blue.png "DigitalOcean Logo") +_We also support some extra syntax for setting the width, height and alignment of images. You can provide pixels (`200`/`200px`), or a percentage (`50%`), for the width/height. The alignment can be either `left` or `right`, with images being centered by default. These settings are all optional._ + +![](https://assets.digitalocean.com/public/mascot.png){ width=200 height=131 align=left } + Use horizontal rules to break up long sections: --- diff --git a/fixtures/full-output.html b/fixtures/full-output.html index 6ff9217..ab43054 100644 --- a/fixtures/full-output.html +++ b/fixtures/full-output.html @@ -30,6 +30,8 @@

Step 1 — Basic Markdown

Here’s how to include an image with alt text and a title:

Alt text for screen readers
DigitalOcean Logo
+

We also support some extra syntax for setting the width, height and alignment of images. You can provide pixels (200/200px), or a percentage (50%), for the width/height. The alignment can be either left or right, with images being centered by default. These settings are all optional.

+

Use horizontal rules to break up long sections:


Rich transformations are also applied:

diff --git a/index.js b/index.js index b66b60f..a98370a 100644 --- a/index.js +++ b/index.js @@ -50,6 +50,7 @@ const safeObject = require('./util/safe_object'); * @property {false} [fence_pre_attrs] Disable fence pre attributes. * @property {false|import('./modifiers/fence_classes').FenceClassesOptions} [fence_classes] Disable fence class filtering, or set options for the feature. * @property {false|import('./modifiers/heading_id').HeadingIdOptions} [heading_id] Disable Ids on headings, or set options for the feature. + * @property {false|import('./modifiers/image_settings').ImageSettingsOptions} [image_settings] Disable image settings syntax, or set options for the feature. * @property {false|import('./modifiers/prismjs').PrismJsOptions} [prismjs] Disable Prism highlighting, or set options for the feature. */ @@ -172,6 +173,10 @@ module.exports = (md, options) => { md.use(require('./modifiers/heading_id'), safeObject(optsObj.heading_id)); } + if (optsObj.image_settings !== false) { + md.use(require('./modifiers/image_settings'), safeObject(optsObj.image_settings)); + } + if (optsObj.prismjs !== false) { md.use(require('./modifiers/prismjs'), safeObject(optsObj.prismjs)); } diff --git a/modifiers/image_settings.js b/modifiers/image_settings.js new file mode 100644 index 0000000..319e440 --- /dev/null +++ b/modifiers/image_settings.js @@ -0,0 +1,131 @@ +/* +Copyright 2023 DigitalOcean + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +/** + * @module modifiers/image_settings + */ + +const safeObject = require('../util/safe_object'); +const regexEscape = require('../util/regex_escape'); + +/** + * @typedef {Object} ImageSettingsOptions + * @property {string[]} [sizeUnits=['', 'px', '%']] Image size units to allow. + */ + +/** + * Add support for defining settings on images, such as size and alignment. + * + * The syntax for this is `{ width= height= align= }`, at the end of the image markup. + * E.g. `![alt](test.png "title"){ width=100 height=200 align=left }`. + * All settings are optional, and the order does not matter. + * + * By default, the width and height can be plain number (`100`), pixels (`100px`), or percentage (`100%`). + * Other units can be supported by passing an array of unit strings via the `sizeUnits` option. + * + * Alignment can be left unset, which will center the image, or can be set to either `left` or `right`. + * + * @example + * ![alt](test.png "title"){ width=100 height=200 align=left } + * + *

alt

+ * + * @type {import('markdown-it').PluginWithOptions} + */ +module.exports = (md, options) => { + // Get the correct options + const optsObj = safeObject(options); + + // Get the units to allow + const units = Array.isArray(optsObj.sizeUnits) && optsObj.sizeUnits.length ? optsObj.sizeUnits : [ '', 'px', '%' ]; + const nonEmptyUnits = units.filter(unit => unit !== ''); + const unitPattern = `^\\d+${nonEmptyUnits.length ? `(?:${nonEmptyUnits.map(regexEscape).join('|')})${units.includes('') ? '?' : ''}` : ''}$`; + + /** + * Wrap the link parsing rule to allow for parsing settings syntax after the image. + * + * @param {import('markdown-it/lib/parser_inline').RuleInline} original Original parse function (`image`). + * @returns {import('markdown-it/lib/parser_inline').RuleInline} + * @private + */ + const imageWithSettings = original => (state, silent) => { + // Run the original image rule + const originalResult = original(state, silent); + if (!originalResult) return originalResult; + + // If the image rule succeeded, start looking for our extra settings syntax + // If at any point we fail, we just return the original result as to not break regular images + + // Check we have space for opening and closing tags + if (state.pos + 2 > state.posMax) return originalResult; + + // Check we're on an opening marker + if (state.src[state.pos] !== '{') return originalResult; + + // Look for closing marker + const closeIdx = state.src.indexOf('}', state.pos + 1); + if (closeIdx === -1 || closeIdx > state.posMax - 1) return originalResult; + + // Get the settings string + const parts = state.src.slice(state.pos + 1, closeIdx).trim().split(/\s+/); + const settings = {}; + for (const part of parts) { + // Check the setting has a key and value + const split = part.indexOf('='); + if (split === -1 || split === 0 || split === part.length - 1) return originalResult; + + // Check this is a valid setting + const key = part.slice(0, split); + const value = part.slice(split + 1); + switch (key) { + // If we have a size, check it's using a permitted unit + case 'width': + case 'height': + if (!value.match(unitPattern)) return originalResult; + break; + + // If we have an alignment, check it's a valid value + case 'align': + if (![ 'left', 'right' ].includes(value)) return originalResult; + break; + + // We're not expecting any other settings + default: + return originalResult; + } + + // Store the setting + settings[key] = value; + } + + // Apply the settings to the token + const imageToken = state.tokens[state.tokens.length - 1]; + if (settings.width) imageToken.attrSet('width', settings.width); + if (settings.height) imageToken.attrSet('height', settings.height); + if (settings.align) imageToken.attrSet('align', settings.align); + + // Update the position + state.pos = closeIdx + 1; + + // Return the original result + return originalResult; + }; + + // eslint-disable-next-line no-underscore-dangle + md.inline.ruler.at('image', imageWithSettings(md.inline.ruler.__rules__.find(rule => rule.name === 'image').fn)); +}; diff --git a/modifiers/image_settings.test.js b/modifiers/image_settings.test.js new file mode 100644 index 0000000..3507da5 --- /dev/null +++ b/modifiers/image_settings.test.js @@ -0,0 +1,95 @@ +/* +Copyright 2023 DigitalOcean + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +const md = require('markdown-it')().use(require('./image_settings')); + +it('handles an image with alt text and a title', () => { + expect(md.renderInline('![alt](test.png "title")')).toBe('alt'); +}); + +it('handles an image with a size (width + height)', () => { + expect(md.renderInline('![](test.png){ width=100 height=200 }')).toBe(''); +}); + +it('handles an image with a size (width + height) with a unit', () => { + expect(md.renderInline('![](test.png){ width=100px height=200px }')).toBe(''); +}); + +it('handles an image with a size (width only)', () => { + expect(md.renderInline('![](test.png){ width=100 }')).toBe(''); +}); + +it('handles an image with a size (height only)', () => { + expect(md.renderInline('![](test.png){ height=200 }')).toBe(''); +}); + +it('handles an image with alt text, a title and size', () => { + expect(md.renderInline('![alt](test.png "title"){ width=100 height=200 }')).toBe('alt'); +}); + +it('handles an image with an invalid size (no value)', () => { + expect(md.renderInline('![](test.png){ width= }')).toBe('{ width= }'); +}); + +it('handles an image with an invalid size (bad unit)', () => { + expect(md.renderInline('![](test.png){ width=10em height=20em }')).toBe('{ width=10em height=20em }'); +}); + +it('handles an image with an alignment', () => { + expect(md.renderInline('![](test.png){ align=left }')).toBe(''); +}); + +it('handles an image with an invalid alignment', () => { + expect(md.renderInline('![](test.png){ align=top }')).toBe('{ align=top }'); +}); + +it('handles an image with a size and alignment', () => { + expect(md.renderInline('![](test.png){ width=100 height=200 align=left }')).toBe(''); +}); + +it('handles an image with a size and alignment in a different order', () => { + expect(md.renderInline('![](test.png){ height=200 align=left width=100 }')).toBe(''); +}); + +it('handles an image with an invalid setting', () => { + expect(md.renderInline('![](test.png){ hello=world }')).toBe('{ hello=world }'); +}); + +it('handles an image with a valid and invalid settings', () => { + expect(md.renderInline('![](test.png){ width=100 hello=world }')).toBe('{ width=100 hello=world }'); +}); + +const mdNoUnit = require('markdown-it')().use(require('./image_settings'), { sizeUnits: [ '' ] }); + +it('handles an image with a size (width + height) with no unit, with no units allowed', () => { + expect(mdNoUnit.renderInline('![](test.png){ width=100 height=200 }')).toBe(''); +}); + +it('handles an image with a size (width + height) with a unit, with no units allowed', () => { + expect(mdNoUnit.renderInline('![](test.png){ width=100px height=200px }')).toBe('{ width=100px height=200px }'); +}); + +const mdCustomUnit = require('markdown-it')().use(require('./image_settings'), { sizeUnits: [ 'em' ] }); + +it('handles an image with a size (width + height) with no unit, with a custom unit allowed', () => { + expect(mdCustomUnit.renderInline('![](test.png){ width=100 height=200 }')).toBe('{ width=100 height=200 }'); +}); + +it('handles an image with a size (width + height) with a custom unit, with a custom unit allowed', () => { + expect(mdCustomUnit.renderInline('![](test.png){ width=100em height=200em }')).toBe(''); +}); diff --git a/styles/_images.scss b/styles/_images.scss index dfa1a8e..fb80f57 100644 --- a/styles/_images.scss +++ b/styles/_images.scss @@ -1,5 +1,5 @@ /* -Copyright 2022 DigitalOcean +Copyright 2023 DigitalOcean Licensed under the Apache License, Version 2.0 (the "License") !default; you may not use this file except in compliance with the License. @@ -26,11 +26,33 @@ figure { max-width: 100%; } +img { + &[align="left"] { + float: unset; + margin-left: 0; + } + + &[align="right"] { + float: unset; + margin-right: 0; + } +} + // Figures figure { overflow: hidden; padding: 1rem; + &:has(img[align="left"]) { + margin-left: 0; + width: fit-content; + } + + &:has(img[align="right"]) { + margin-right: 0; + width: fit-content; + } + img { border: none; border-radius: 0; diff --git a/util/regex_escape.js b/util/regex_escape.js new file mode 100644 index 0000000..df9667c --- /dev/null +++ b/util/regex_escape.js @@ -0,0 +1,25 @@ +/* +Copyright 2023 DigitalOcean + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +/** + * Escape a string for use in a RegExp. + * + * @param {string} string String to escape. + * @returns {string} + */ +module.exports = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string