Skip to content

Commit

Permalink
Render captions for singleton images with titles (#17)
Browse files Browse the repository at this point in the history
* Add new plugin to wrap images in figures with captions

* Update and fix full integration test

* Add styling for figures + figcaptions

* Fix script execution for dev

* Add plugin to readme

* Add to changelog
  • Loading branch information
MattIPv4 authored Jun 24, 2022
1 parent 10ec466 commit 6914251
Show file tree
Hide file tree
Showing 12 changed files with 239 additions and 6 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ 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) Render captions for singleton images with titles


## v1.1.0 - 3cc7209

- (minor) Provide SCSS styling for Markdown
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ Set this property to `false` to disable this plugin.
- `strict` (`boolean`, optional, defaults to `false`): If the end of a comment must be explicitly found.
</details>

### image_caption

<details>
<summary>Wrap singleton images that have title text in a figure with a rendered caption.</summary>

**Example Markdown input:**

![alt text](test.png "title text")

![alt text](test.png "title text _with Markdown_")

**Example HTML output:**

<figure><img src="test.png" alt="alt text"><figcaption>title text</figcaption></figure>

<figure><img src="test.png" alt="alt text"><figcaption>title text <em>with Markdown</em></figcaption></figure>

**Options:**

Pass options for this plugin as the `image_caption` property of the `do-markdownit` plugin options.
Set this property to `false` to disable this plugin.

_No options are available for this plugin._
</details>

### callout

<details>
Expand Down
29 changes: 27 additions & 2 deletions dev/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,46 @@ limitations under the License.
require('./client.scss');
const render = require('./render');

document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', event => {
const textbox = document.getElementById('textbox');
const output = document.getElementById('output');

/**
* Handle the textbox being updated.
* Resizes that textbox to match the input, renders the input to HTML.
*/
const update = () => {
// Resize textbox to match input
textbox.style.overflowY = 'hidden';
textbox.style.height = 'auto';
textbox.style.height = `${textbox.scrollHeight}px`;

// Render the Markdown to HTML
output.innerHTML = render(textbox.value);

// Ensure scripts are loaded
Array.from(output.querySelectorAll('script')).forEach(oldScript => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.appendChild(document.createTextNode(oldScript.innerHTML));
oldScript.parentNode.replaceChild(newScript, oldScript);
});
};

// Monkey-patch addEventListener to short-circuit future DOMContentLoaded handlers
const addEventListener = document.addEventListener.bind(document);

/**
* Register a new event listener on the document.
* Immediately executes DOMContentLoaded listeners, as well as registering them.
*
* @type {typeof addEventListener}
*/
document.addEventListener = (type, listener, options) => {
if (type === 'DOMContentLoaded') listener(event);
addEventListener(type, listener, options);
};

// Listen for updates, and do an initial render
textbox.addEventListener('input', update);
update();
});
2 changes: 1 addition & 1 deletion fixtures/full-output.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h2 id="step-1-basic-markdown">Step 1 — Basic Markdown</h2>
</ul>
</blockquote>
<p>Here’s how to include an image with alt text and a title:</p>
<p><img src="https://assets.digitalocean.com/logos/DO_Logo_horizontal_blue.png" alt="Alt text for screen readers" title="DigitalOcean Logo"></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>Use horizontal rules to break up long sections:</p>
<hr>
<p>Rich transformations are also applied:</p>
Expand Down
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const safeObject = require('./util/safe_object');
* @property {false} [highlight] Disable highlight syntax.
* @property {false|import('./rules/user_mention').UserMentionOptions} [user_mention] Disable user mentions, or set options for the feature.
* @property {false|import('./rules/html_comment').HtmlCommentOptions} [html_comment] Disable HTML comment stripping, or set options for the feature.
* @property {false} [image_caption] Disable image captions.
* @property {false|import('./rules/embeds/callout').CalloutOptions} [callout] Disable callout block syntax, or set options for the feature.
* @property {false|import('./rules/embeds/rsvp_button').RsvpButtonOptions} [rsvp_button] Disable RSVP buttons, or set options for the feature.
* @property {false|import('./rules/embeds/terminal_button').TerminalButtonOptions} [terminal_button] Disable terminal buttons, or set options for the feature.
Expand All @@ -41,7 +42,7 @@ const safeObject = require('./util/safe_object');
* @property {false|import('./modifiers/fence_secondary_label').FenceSecondaryLabelOptions} [fence_secondary_label] Disable fence secondary labels, or set options for the feature.
* @property {false|import('./modifiers/fence_environment').FenceEnvironmentOptions} [fence_environment] Disable fence environments, or set options for the feature.
* @property {false|import('./modifiers/fence_prefix').FencePrefixOptions} [fence_prefix] Disable fence prefixes, or set options for the feature.
* @property {false} [fence_pre_attrs] Disable fence pre attributes, or set options for the feature.
* @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/prismjs').PrismJsOptions} [prismjs] Disable Prism highlighting, or set options for the feature.
Expand Down Expand Up @@ -70,6 +71,10 @@ module.exports = (md, options) => {
md.use(require('./rules/html_comment'), safeObject(optsObj.html_comment));
}

if (optsObj.image_caption !== false) {
md.use(require('./rules/image_caption'), safeObject(optsObj.image_caption));
}

// Register embeds

if (optsObj.callout !== false) {
Expand Down
2 changes: 2 additions & 0 deletions rules/embeds/caniuse.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ module.exports = md => {
* @private
*/
const canIUseScriptRule = state => {
if (state.inlineMode) return;

// Check if we need to inject the script
if (state.env.caniuse && state.env.caniuse.tokenized && !state.env.caniuse.injected) {
// Set that we've injected it
Expand Down
2 changes: 2 additions & 0 deletions rules/embeds/codepen.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ module.exports = md => {
* @private
*/
const codepenScriptRule = state => {
if (state.inlineMode) return;

// Check if we need to inject the script
if (state.env.codepen && state.env.codepen.tokenized && !state.env.codepen.injected) {
// Set that we've injected it
Expand Down
2 changes: 2 additions & 0 deletions rules/embeds/dns.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ module.exports = md => {
* @private
*/
const dnsScriptRule = state => {
if (state.inlineMode) return;

// Check if we need to inject the script
if (state.env.dns && state.env.dns.tokenized && !state.env.dns.injected) {
// Set that we've injected it
Expand Down
2 changes: 2 additions & 0 deletions rules/embeds/glob.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ module.exports = md => {
* @private
*/
const globScriptRule = state => {
if (state.inlineMode) return;

// Check if we need to inject the script
if (state.env.glob && state.env.glob.tokenized && !state.env.glob.injected) {
// Set that we've injected it
Expand Down
79 changes: 79 additions & 0 deletions rules/image_caption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
Copyright 2022 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 rules/image_caption
*/

/**
* Wrap singleton images that have title text in a figure with a rendered caption.
*
* @example
* ![alt text](test.png "title text")
*
* ![alt text](test.png "title text _with Markdown_")
*
* <figure><img src="test.png" alt="alt text"><figcaption>title text</figcaption></figure>
*
* <figure><img src="test.png" alt="alt text"><figcaption>title text <em>with Markdown</em></figcaption></figure>
*
* @type {import('markdown-it').PluginSimple}
*/
module.exports = md => {
/**
* Parsing rule for wrapping singleton image that has a title in a figure with a caption.
*
* @type {import('markdown-it/lib/parser_core').RuleCore}
* @private
*/
const imageCaptionRule = state => {
// Iterate over all tokens, except the first and last
for (let i = 1; i < state.tokens.length - 1; i += 1) {
const token = state.tokens[i];

// Check if we have an image
if (token.type !== 'inline') continue;
if (token.children.length !== 1) continue;
if (token.children[0].type !== 'image') continue;

// Check if we have a title
const title = token.children[0].attrGet('title');
if (!title) continue;

// Check this is the only item in the paragraph
const open = state.tokens[i - 1];
const close = state.tokens[i + 1];
if (open.type !== 'paragraph_open') continue;
if (close.type !== 'paragraph_close') continue;

// Mutate open/close to become a figure, not a paragraph
open.type = 'figure_open';
open.tag = 'figure';
close.type = 'figure_close';
close.tag = 'figure';

// Inject the caption, treating the title as inline Markdown
token.children.push(new state.Token('figcaption_open', 'figcaption', 1));
token.children.push(...md.parseInline(title, state.env)[0].children);
token.children.push(new state.Token('figcaption_close', 'figcaption', -1));
token.children[0].attrs = token.children[0].attrs.filter(([ attr ]) => attr !== 'title');
}
};

md.core.ruler.after('inline', 'image_caption', imageCaptionRule);
};
65 changes: 65 additions & 0 deletions rules/image_caption.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
Copyright 2022 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_caption'));

it('handles an image with no alt text and no title text (no caption)', () => {
expect(md.render('![](test.png)')).toBe(`<p><img src="test.png" alt=""></p>
`);
});

it('handles an image with alt text and no title text (no caption)', () => {
expect(md.render('![alt text](test.png)')).toBe(`<p><img src="test.png" alt="alt text"></p>
`);
});

it('handles an image with no alt text and unclosed title text (no image)', () => {
expect(md.render('![](test.png "title text)')).toBe(`<p>![](test.png &quot;title text)</p>
`);
});

it('handles an image with alt text and unclosed title text (no image)', () => {
expect(md.render('![alt text](test.png "title text)')).toBe(`<p>![alt text](test.png &quot;title text)</p>
`);
});

it('handles an image with no alt text but title text, with surrounding text (no caption)', () => {
expect(md.render('![](test.png "title text") hello')).toBe(`<p><img src="test.png" alt="" title="title text"> hello</p>
`);
});

it('handles an image with no alt text but title text, with text after (no caption)', () => {
expect(md.render('![](test.png "title text")\nhello')).toBe(`<p><img src="test.png" alt="" title="title text">
hello</p>
`);
});

it('handles an image with no alt text but title text', () => {
expect(md.render('![](test.png "title text")')).toBe(`<figure><img src="test.png" alt=""><figcaption>title text</figcaption></figure>
`);
});

it('handles an image with alt text and title text', () => {
expect(md.render('![alt text](test.png "title text")')).toBe(`<figure><img src="test.png" alt="alt text"><figcaption>title text</figcaption></figure>
`);
});

it('handles an image with alt text and title text using Markdown', () => {
expect(md.render('![alt text](test.png "title text _with Markdown_")')).toBe(`<figure><img src="test.png" alt="alt text"><figcaption>title text <em>with Markdown</em></figcaption></figure>
`);
});
27 changes: 25 additions & 2 deletions styles/_images.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,32 @@ limitations under the License.
@import "theme";

// Images
img {
img,
figure {
border: solid 2px $gray8;
border-radius: 16px;
display: block;
margin: 1em auto;
margin: 1rem auto;
max-width: 100%;
}

// Figures
figure {
overflow: hidden;
padding: 1rem;

img {
border: none;
border-radius: 0;
margin: 0 auto;
}

figcaption {
border-top: solid 1px $gray8;
background: $gray9;
font-size: 0.9em;
text-align: center;
padding: 1rem;
margin: 1rem -1rem -1rem;
}
}

0 comments on commit 6914251

Please sign in to comment.