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. `{ 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:**
+
+ { width=100 height=200 align=left }
+
+**Example HTML output:**
+
+
+
+**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:

+_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._
+
+{ 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:
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. `{ 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
+ * { width=100 height=200 align=left }
+ *
+ *
+ *
+ * @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('')).toBe('');
+});
+
+it('handles an image with a size (width + height)', () => {
+ expect(md.renderInline('{ width=100 height=200 }')).toBe('');
+});
+
+it('handles an image with a size (width + height) with a unit', () => {
+ expect(md.renderInline('{ width=100px height=200px }')).toBe('');
+});
+
+it('handles an image with a size (width only)', () => {
+ expect(md.renderInline('{ width=100 }')).toBe('');
+});
+
+it('handles an image with a size (height only)', () => {
+ expect(md.renderInline('{ height=200 }')).toBe('');
+});
+
+it('handles an image with alt text, a title and size', () => {
+ expect(md.renderInline('{ width=100 height=200 }')).toBe('');
+});
+
+it('handles an image with an invalid size (no value)', () => {
+ expect(md.renderInline('{ width= }')).toBe('{ width= }');
+});
+
+it('handles an image with an invalid size (bad unit)', () => {
+ expect(md.renderInline('{ width=10em height=20em }')).toBe('{ width=10em height=20em }');
+});
+
+it('handles an image with an alignment', () => {
+ expect(md.renderInline('{ align=left }')).toBe('');
+});
+
+it('handles an image with an invalid alignment', () => {
+ expect(md.renderInline('{ align=top }')).toBe('{ align=top }');
+});
+
+it('handles an image with a size and alignment', () => {
+ expect(md.renderInline('{ width=100 height=200 align=left }')).toBe('');
+});
+
+it('handles an image with a size and alignment in a different order', () => {
+ expect(md.renderInline('{ height=200 align=left width=100 }')).toBe('');
+});
+
+it('handles an image with an invalid setting', () => {
+ expect(md.renderInline('{ hello=world }')).toBe('{ hello=world }');
+});
+
+it('handles an image with a valid and invalid settings', () => {
+ expect(md.renderInline('{ 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('{ width=100 height=200 }')).toBe('');
+});
+
+it('handles an image with a size (width + height) with a unit, with no units allowed', () => {
+ expect(mdNoUnit.renderInline('{ 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('{ 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('{ 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