Skip to content

Commit

Permalink
Add syntax for columns to customise layout (#19)
Browse files Browse the repository at this point in the history
* Parse and detect a single column

* Parse adjacent columns and wrap

* Provide options for class names

* Add styling for columns, and include plugin in index

* Add full tests for columns

* Include columns in full demo fixture

* Add to changelog

* Add plugin to README

* Fix eslint issues
  • Loading branch information
MattIPv4 authored Jul 8, 2022
1 parent 6914251 commit d201297
Show file tree
Hide file tree
Showing 9 changed files with 492 additions and 13 deletions.
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 columns to customise layout
- (minor) Render captions for singleton images with titles


Expand Down
61 changes: 52 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,43 @@ Set this property to `false` to disable this plugin.
_No options are available for this plugin._
</details>

### columns

<details>
<summary>Add support for columns in Markdown, as block syntax.</summary>

To declare a column, wrap content with `[column` on the line before, and `]` on a new line at the end.
Two or more columns must be adjacent to each other to be parsed as a set of columns.

**Example Markdown input:**

[column
Content for the first column
]
[column
Content for the second column
]

**Example HTML output:**

<div class="columns">
<div class="column">
<p>Content for the first column</p>
</div>
<div class="column">
<p>Content for the second column</p>
</div>
</div>

**Options:**

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

- `outerClassName` (`string`, optional, defaults to `'columns'`): Class to use for the outer columns container.
- `innerClassName` (`string`, optional, defaults to `'column'`): Class to use for the inner column container.
</details>

### glob

<details>
Expand Down Expand Up @@ -861,15 +898,21 @@ $root-text-styles: false;

### SCSS Variables

| Variable | Default | Usage | File |
|-------------------------------|------------------------|-------------------------------------------------------------|---------------------------------------------------------------------|
| `$callouts-class` | `callout` | The class name used for the `callout` plugin. | [`_callouts.scss`](./styles/_callouts.scss) |
| `$callouts-label-class` | `callout-label` | The class name used for labels in the `callout` plugin. | [`_callouts.scss`](./styles/_callouts.scss) |
| `$code-label-class` | `code-label` | The class name used for the `fence_label` plugin. | [`_code_label.scss`](./styles/_code_label.scss) |
| `$code-secondary-label-class` | `secondary-code-label` | The class name used for the `fence_secondary_label` plugin. | [`_code_secondary_label.scss`](./styles/_code_secondary_label.scss) |
| `$rsvp-button-class` | `rsvp` | The class name used for the `rsvp_button` plugin. | [`_rsvp_button.scss`](./styles/_rsvp_button.scss) |
| `$terminal-button-class` | `terminal` | The class name used for the `terminal_button` plugin. | [`_terminal_button.scss`](./styles/_terminal_button.scss) |
| `$root-text-styles` | `true` | Enable or disable the `& {` selector for root text styles. | [`_typography.scss`](./styles/_typography.scss) |
<!--
Variables listed here should be sorted based on the filename, and then by variable name.
-->

| Variable | Default | Usage | File |
|------------------------------------------|------------------------|-------------------------------------------------------------|---------------------------------------------------------------------|
| `$callouts-class` _(string)_ | `callout` | The class name used for the `callout` plugin. | [`_callouts.scss`](./styles/_callouts.scss) |
| `$callouts-label-class` _(string)_ | `callout-label` | The class name used for labels in the `callout` plugin. | [`_callouts.scss`](./styles/_callouts.scss) |
| `$code-label-class` _(string)_ | `code-label` | The class name used for the `fence_label` plugin. | [`_code_label.scss`](./styles/_code_label.scss) |
| `$code-secondary-label-class` _(string)_ | `secondary-code-label` | The class name used for the `fence_secondary_label` plugin. | [`_code_secondary_label.scss`](./styles/_code_secondary_label.scss) |
| `$columns-inner-class` _(string)_ | `column` | The inner class name used for the `columns` plugin. | [`_columns.scss`](./styles/_columns.scss) |
| `$columns-outer-class` _(string)_ | `columns` | The outer class name used for the `columns` plugin. | [`_columns.scss`](./styles/_columns.scss) |
| `$rsvp-button-class` _(string)_ | `rsvp` | The class name used for the `rsvp_button` plugin. | [`_rsvp_button.scss`](./styles/_rsvp_button.scss) |
| `$terminal-button-class` _(string)_ | `terminal` | The class name used for the `terminal_button` plugin. | [`_terminal_button.scss`](./styles/_terminal_button.scss) |
| `$root-text-styles` _(boolean)_ | `true` | Enable or disable the `& {` selector for root text styles. | [`_typography.scss`](./styles/_typography.scss) |

Alongside these variables used for controlling specific styles, there is also the
[`_theme.scss`](./styles/_theme.scss) file that contains all the colors used by the package.
Expand Down
21 changes: 19 additions & 2 deletions fixtures/full-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,24 @@ You can also mention users by username:
@MattIPv4


## Step 4 — Embeds
## Step 4 — Layout

Columns allow you to customise the layout of your Markdown:

[column
Content inside a column is regular Markdown block content.

> Any block or inline syntax can be used, including quotes.
]

[column
Two or more columns adjacent to each other are needed to create a column layout.

On desktop the columns will be evenly distributed in a single row, on tablets they will wrap naturally, and on mobile they will be in a single stack.
]


## Step 5 — Embeds

### YouTube

Expand Down Expand Up @@ -305,7 +322,7 @@ Setting a custom number of cols and rows for the Asciinema terminal:
[asciinema 239367 50 20]


## Step 5 — Tutorials
## Step 6 — Tutorials

Certain features of our Markdown engine are designed specifically for our tutorial content-types.
These may not be enabled in all contexts in the DigitalOcean community, but are enabled by default in the do-markdownit plugin.
Expand Down
18 changes: 16 additions & 2 deletions fixtures/full-output.html
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,21 @@ <h2 id="step-3-callouts">Step 3 — Callouts</h2>
</div>
<p>You can also mention users by username:</p>
<p><a href="/users/MattIPv4">@MattIPv4</a></p>
<h2 id="step-4-embeds">Step 4 — Embeds</h2>
<h2 id="step-4-layout">Step 4 — Layout</h2>
<p>Columns allow you to customise the layout of your Markdown:</p>
<div class="columns">
<div class="column">
<p>Content inside a column is regular Markdown block content.</p>
<blockquote>
<p>Any block or inline syntax can be used, including quotes.</p>
</blockquote>
</div>
<div class="column">
<p>Two or more columns adjacent to each other are needed to create a column layout.</p>
<p>On desktop the columns will be evenly distributed in a single row, on tablets they will wrap naturally, and on mobile they will be in a single stack.</p>
</div>
</div>
<h2 id="step-5-embeds">Step 5 — Embeds</h2>
<h3 id="youtube">YouTube</h3>
<p>Embedding a YouTube video (id, height, width):</p>
<iframe src="https://www.youtube.com/embed/iom_nhYQIYk" class="youtube" height="225" width="400" frameborder="0" allowfullscreen>
Expand Down Expand Up @@ -341,7 +355,7 @@ <h3 id="asciinema">Asciinema</h3>
<noscript>
<a href="https://asciinema.org/a/239367" target="_blank">View asciinema recording</a>
</noscript>
<h2 id="step-5-tutorials">Step 5 — Tutorials</h2>
<h2 id="step-6-tutorials">Step 6 — Tutorials</h2>
<p>Certain features of our Markdown engine are designed specifically for our tutorial content-types.
These may not be enabled in all contexts in the DigitalOcean community, but are enabled by default in the do-markdownit plugin.</p>
<p><button data-js="rsvp-button" data-form-id="1234" disabled="disabled" class="rsvp">Marketo RSVP buttons use the `rsvp_button` flag</button></p>
Expand Down
5 changes: 5 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const safeObject = require('./util/safe_object');
* @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.
* @property {false|import('./rules/embeds/columns').ColumnsOptions} [columns] Disable columns, or set options for the feature.
* @property {false} [glob] Disable glob embeds.
* @property {false} [dns] Disable DNS lookup embeds.
* @property {false} [asciinema] Disable Asciinema embeds.
Expand Down Expand Up @@ -89,6 +90,10 @@ module.exports = (md, options) => {
md.use(require('./rules/embeds/terminal_button'), safeObject(optsObj.terminal_button));
}

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

if (optsObj.glob !== false) {
md.use(require('./rules/embeds/glob'), safeObject(optsObj.glob));
}
Expand Down
174 changes: 174 additions & 0 deletions rules/embeds/columns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
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/embeds/columns
*/

const safeObject = require('../../util/safe_object');

/**
* @typedef {Object} ColumnsOptions
* @property {string} [outerClassName='columns'] Class to use for the outer columns container.
* @property {string} [innerClassName='column'] Class to use for the inner column container.
*/

/**
* Add support for columns in Markdown, as block syntax.
*
* To declare a column, wrap content with `[column` on the line before, and `]` on a new line at the end.
* Two or more columns must be adjacent to each other to be parsed as a set of columns.
*
* @example
* [column
* Content for the first column
* ]
* [column
* Content for the second column
* ]
*
* <div class="columns">
* <div class="column">
* <p>Content for the first column</p>
* </div>
* <div class="column">
* <p>Content for the second column</p>
* </div>
* </div>
*
* @type {import('markdown-it').PluginWithOptions<ColumnsOptions>}
*/
module.exports = (md, options) => {
// Get the correct options
const optsObj = safeObject(options);
const outerClassName = typeof optsObj.outerClassName === 'string' ? optsObj.outerClassName : 'columns';
const innerClassName = typeof optsObj.innerClassName === 'string' ? optsObj.innerClassName : 'column';

/**
* Find a column block within the given lines, starting at the first line, returning the closing index.
*
* @param {string[]} lines Lines of Markdown to parse.
* @returns {false|number}
* @private
*/
const findColumn = lines => {
// Perform some basic checks to ensure the column is valid
if (lines.length < 3) return false; // [column + content + ]
if (lines[0] !== '[column') return false;

// Attempt to find the closing bracket for this, allowing bracket pairs inside
let closingIndex = -1;
let open = 0;
for (let i = 1; i < lines.length; i += 1) {
// If we found an opening bracket that isn't closed on the same line, increase the open count
if (lines[i][0] === '[' && lines[i][lines[i].length - 1] !== ']') open += 1;

// If we found a closing bracket, check if we're at the same level as the opening bracket
if (lines[i] === ']') {
if (open === 0) {
closingIndex = i;
break;
}
open -= 1;
}
}

return closingIndex === -1 ? false : closingIndex;
};

/**
* Parsing rule for column markup.
*
* @type {import('markdown-it/lib/parser_block').RuleBlock}
* @private
*/
const columnsRule = (state, startLine, endLine, silent) => {
// If silent, don't replace
if (silent) return false;

// Get current string to consider (current line to end)
const currentLines = Array.from({ length: endLine - startLine }, (_, i) => {
const pos = state.bMarks[startLine + i] + state.tShift[startLine + i];
const max = state.eMarks[startLine + i];
return state.src.substring(pos, max);
});

// Find adjacent columns starting from
const columns = [];
let nextLine = 0;
while (true) { // eslint-disable-line no-constant-condition
const column = findColumn(currentLines.slice(nextLine));
if (column === false) break;

// Add the column to the list
columns.push([ nextLine, nextLine + column ]);
nextLine += column + 1;

// Skip a single blank line between columns
if (currentLines[nextLine] === '') nextLine += 1;
}

// If we found less than two columns, don't do anything
if (columns.length < 2) return false;

// Create the outer columns container
const tokenContainerOpen = state.push('columns', 'div', 1);
tokenContainerOpen.block = true;
tokenContainerOpen.map = [ startLine, startLine + columns[columns.length - 1][1] ];
tokenContainerOpen.attrSet('class', outerClassName);

for (const column of columns) {
// Ensure we only tokenize the content of the column
const oldParentType = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = 'column';
state.lineMax = startLine + column[1];

// Add the opening token to state
const tokenOpen = state.push('column', 'div', 1);
tokenOpen.block = true;
tokenOpen.map = [ startLine + column[0], startLine + column[1] ];
tokenOpen.markup = currentLines[0];
tokenOpen.attrSet('class', innerClassName);

// Process the content of the column as block content
state.md.block.tokenize(state, startLine + column[0] + 1, startLine + column[1]);

// Add the closing token to state
const tokenClose = state.push('column', 'div', -1);
tokenClose.block = true;
tokenClose.markup = ']';

// Reset the parser to continue on
state.parentType = oldParentType;
state.lineMax = oldLineMax;
}

// Add the closing token to state
const tokenContainerClose = state.push('columns', 'div', -1);
tokenContainerClose.block = true;

// Move the parser past this content
state.line = startLine + columns[columns.length - 1][1] + 1;

// Done
return true;
};

md.block.ruler.before('paragraph', 'columns', columnsRule);
};
Loading

0 comments on commit d201297

Please sign in to comment.