Skip to content

Commit

Permalink
Add syntax for defining settings on images (#43)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
MattIPv4 authored Feb 1, 2023
1 parent eaa8342 commit 72aaa42
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</details>

### image_settings

<details>
<summary>Add support for defining settings on images, such as size and alignment.</summary>

The syntax for this is `{ width=<width> height=<height> align=<alignment> }`, 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:**

<p><img src="test.png" alt="alt" title="title" width="100" height="200" align="left"></p>

**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.
</details>

### prismjs

<details>
Expand Down
4 changes: 4 additions & 0 deletions fixtures/full-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

---
Expand Down
2 changes: 2 additions & 0 deletions fixtures/full-output.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ <h2 id="step-1-basic-markdown">Step 1 — Basic Markdown</h2>
</blockquote>
<p>Here’s how to include an image with alt text and a title:</p>
<figure><img src="https://assets.digitalocean.com/logos/DO_Logo_horizontal_blue.png" alt="Alt text for screen readers"><figcaption>DigitalOcean Logo</figcaption></figure>
<p><em>We also support some extra syntax for setting the width, height and alignment of images. You can provide pixels (<code>200</code>/<code>200px</code>), or a percentage (<code>50%</code>), for the width/height. The alignment can be either <code>left</code> or <code>right</code>, with images being centered by default. These settings are all optional.</em></p>
<p><img src="https://assets.digitalocean.com/public/mascot.png" alt="" width="200" height="131" align="left"></p>
<p>Use horizontal rules to break up long sections:</p>
<hr>
<p>Rich transformations are also applied:</p>
Expand Down
5 changes: 5 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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));
}
Expand Down
131 changes: 131 additions & 0 deletions modifiers/image_settings.js
Original file line number Diff line number Diff line change
@@ -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=<width> height=<height> align=<alignment> }`, 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 }
*
* <p><img src="test.png" alt="alt" title="title" width="100" height="200" align="left"></p>
*
* @type {import('markdown-it').PluginWithOptions<ImageSettingsOptions>}
*/
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));
};
95 changes: 95 additions & 0 deletions modifiers/image_settings.test.js
Original file line number Diff line number Diff line change
@@ -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('<img src="test.png" alt="alt" title="title">');
});

it('handles an image with a size (width + height)', () => {
expect(md.renderInline('![](test.png){ width=100 height=200 }')).toBe('<img src="test.png" alt="" width="100" height="200">');
});

it('handles an image with a size (width + height) with a unit', () => {
expect(md.renderInline('![](test.png){ width=100px height=200px }')).toBe('<img src="test.png" alt="" width="100px" height="200px">');
});

it('handles an image with a size (width only)', () => {
expect(md.renderInline('![](test.png){ width=100 }')).toBe('<img src="test.png" alt="" width="100">');
});

it('handles an image with a size (height only)', () => {
expect(md.renderInline('![](test.png){ height=200 }')).toBe('<img src="test.png" alt="" height="200">');
});

it('handles an image with alt text, a title and size', () => {
expect(md.renderInline('![alt](test.png "title"){ width=100 height=200 }')).toBe('<img src="test.png" alt="alt" title="title" width="100" height="200">');
});

it('handles an image with an invalid size (no value)', () => {
expect(md.renderInline('![](test.png){ width= }')).toBe('<img src="test.png" alt="">{ width= }');
});

it('handles an image with an invalid size (bad unit)', () => {
expect(md.renderInline('![](test.png){ width=10em height=20em }')).toBe('<img src="test.png" alt="">{ width=10em height=20em }');
});

it('handles an image with an alignment', () => {
expect(md.renderInline('![](test.png){ align=left }')).toBe('<img src="test.png" alt="" align="left">');
});

it('handles an image with an invalid alignment', () => {
expect(md.renderInline('![](test.png){ align=top }')).toBe('<img src="test.png" alt="">{ align=top }');
});

it('handles an image with a size and alignment', () => {
expect(md.renderInline('![](test.png){ width=100 height=200 align=left }')).toBe('<img src="test.png" alt="" width="100" height="200" align="left">');
});

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('<img src="test.png" alt="" width="100" height="200" align="left">');
});

it('handles an image with an invalid setting', () => {
expect(md.renderInline('![](test.png){ hello=world }')).toBe('<img src="test.png" alt="">{ hello=world }');
});

it('handles an image with a valid and invalid settings', () => {
expect(md.renderInline('![](test.png){ width=100 hello=world }')).toBe('<img src="test.png" alt="">{ 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('<img src="test.png" alt="" width="100" height="200">');
});

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('<img src="test.png" alt="">{ 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('<img src="test.png" alt="">{ 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('<img src="test.png" alt="" width="100em" height="200em">');
});
24 changes: 23 additions & 1 deletion styles/_images.scss
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions util/regex_escape.js
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 72aaa42

Please sign in to comment.