Skip to content

Commit 02a6581

Browse files
authored
✨ Colon fence renderer (#39)
1 parent bd22f50 commit 02a6581

File tree

8 files changed

+231
-2
lines changed

8 files changed

+231
-2
lines changed

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"devDependencies": {
6060
"@rollup/plugin-babel": "^5.3.0",
6161
"@rollup/plugin-commonjs": "^21.0.1",
62+
"@rollup/plugin-json": "^4.1.0",
6263
"@rollup/plugin-node-resolve": "^13.1.3",
6364
"@types/jest": "^27.4.0",
6465
"@types/js-yaml": "^4.0.5",

rollup.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import commonjs from "@rollup/plugin-commonjs"
33
import babel from "@rollup/plugin-babel"
44
import resolve from "@rollup/plugin-node-resolve"
55
import { terser } from "rollup-plugin-terser"
6+
import json from "@rollup/plugin-json"
67

78
export default {
89
input: "src/index.ts",
910
plugins: [
1011
typescript(), // Integration between Rollup and Typescript
1112
commonjs(), // Convert CommonJS modules to ES6
1213
babel({ babelHelpers: "bundled" }), // transpile ES6/7 code
13-
resolve() // resolve third party modules in node_modules
14+
resolve(), // resolve third party modules in node_modules
15+
json()
1416
],
1517
output: [
1618
{

src/colonFence.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type MarkdownIt from "markdown-it/lib"
2+
import type Renderer from "markdown-it/lib/renderer"
3+
import type StateBlock from "markdown-it/lib/rules_block/state_block"
4+
import { escapeHtml, unescapeAll } from "markdown-it/lib/common/utils"
5+
6+
// Ported from: https://github.com/executablebooks/mdit-py-plugins/blob/master/mdit_py_plugins/colon_fence.py
7+
8+
function _rule(state: StateBlock, startLine: number, endLine: number, silent: boolean) {
9+
let haveEndMarker = false
10+
let pos = state.bMarks[startLine] + state.tShift[startLine]
11+
let maximum = state.eMarks[startLine]
12+
13+
// if it's indented more than 3 spaces, it should be a code block
14+
if (state.sCount[startLine] - state.blkIndent >= 4) {
15+
return false
16+
}
17+
18+
if (pos + 3 > maximum) {
19+
return false
20+
}
21+
22+
const marker = state.src.charCodeAt(pos)
23+
24+
// /* : */
25+
if (marker !== 0x3a) {
26+
return false
27+
}
28+
29+
// scan marker length
30+
let mem = pos
31+
pos = state.skipChars(pos, marker)
32+
33+
let length = pos - mem
34+
35+
if (length < 3) {
36+
return false
37+
}
38+
39+
const markup = state.src.slice(mem, pos)
40+
const params = state.src.slice(pos, maximum)
41+
42+
// Since start is found, we can report success here in validation mode
43+
if (silent) {
44+
return true
45+
}
46+
47+
// search end of block
48+
let nextLine = startLine
49+
50+
// eslint-disable-next-line no-constant-condition
51+
while (true) {
52+
nextLine += 1
53+
if (nextLine >= endLine) {
54+
// unclosed block should be autoclosed by end of document.
55+
// also block seems to be autoclosed by end of parent
56+
break
57+
}
58+
59+
pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]
60+
maximum = state.eMarks[nextLine]
61+
62+
if (pos < maximum && state.sCount[nextLine] < state.blkIndent) {
63+
// non-empty line with negative indent should stop the list:
64+
// - ```
65+
// test
66+
break
67+
}
68+
69+
if (state.src.charCodeAt(pos) != marker) {
70+
continue
71+
}
72+
73+
if (state.sCount[nextLine] - state.blkIndent >= 4) {
74+
// closing fence should be indented less than 4 spaces
75+
continue
76+
}
77+
78+
pos = state.skipChars(pos, marker)
79+
80+
// closing code fence must be at least as long as the opening one
81+
if (pos - mem < length) {
82+
continue
83+
}
84+
85+
// make sure tail has spaces only
86+
pos = state.skipSpaces(pos)
87+
88+
if (pos < maximum) {
89+
continue
90+
}
91+
92+
haveEndMarker = true
93+
// found!
94+
break
95+
}
96+
// If a fence has heading spaces, they should be removed from its inner block
97+
length = state.sCount[startLine]
98+
99+
state.line = nextLine + (haveEndMarker ? 1 : 0)
100+
101+
const token = state.push("colon_fence", "code", 0)
102+
token.info = params
103+
token.content = state.getLines(startLine + 1, nextLine, length, true)
104+
token.markup = markup
105+
token.map = [startLine, state.line]
106+
107+
return true
108+
}
109+
110+
const colonFenceRender: Renderer.RenderRule = function colonFenceRender(tokens, idx) {
111+
const token = tokens[idx]
112+
const info = token.info ? unescapeAll(token.info).trim() : ""
113+
const content = escapeHtml(token.content)
114+
let block_name = ""
115+
116+
if (info) {
117+
block_name = info.split(" ")[0]
118+
}
119+
const codeClass = block_name ? ` class="block-${block_name}"` : ""
120+
return `<pre><code${codeClass}>${content}</code></pre>\n`
121+
}
122+
123+
/**
124+
* This plugin directly mimics regular fences, but with `:` colons.
125+
* Example:
126+
* ```md
127+
* :::name
128+
* contained text
129+
* :::
130+
* ```
131+
*/
132+
export function colonFencePlugin(md: MarkdownIt) {
133+
md.block.ruler.before("fence", "colon_fence", _rule, {
134+
alt: ["paragraph", "reference", "blockquote", "list", "footnote_def"]
135+
})
136+
md.renderer.rules["colon_fence"] = colonFenceRender
137+
}

src/directives/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function directivePlugin(md: MarkdownIt, options: IOptions): void
2929
/** Convert fences identified as directives to `directive` tokens */
3030
function replaceFences(state: StateCore): boolean {
3131
for (const token of state.tokens) {
32-
if (token.type === "fence") {
32+
if (token.type === "fence" || token.type === "colon_fence") {
3333
const match = token.info.match(/^\{([^\s}]+)\}\s*(.*)$/)
3434
if (match) {
3535
token.type = "directive"

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
IDirectiveData
1010
} from "./directives"
1111
import statePlugin from "./state/plugin"
12+
import { colonFencePlugin } from "./colonFence"
1213

1314
export { rolesDefault, rolePlugin, Role }
1415
export { directivesDefault, directivePlugin, Directive, directiveOptions }
@@ -18,11 +19,13 @@ export type { IRoleData, IRoleOptions, IDirectiveData, IDirectiveOptions }
1819
/** Allowed options for docutils plugin */
1920
export interface IOptions extends IDirectiveOptions, IRoleOptions {
2021
// TODO new token render rules
22+
colonFences: boolean
2123
}
2224

2325
/** Default options for docutils plugin */
2426
const OptionDefaults: IOptions = {
2527
parseRoles: true,
28+
colonFences: true,
2629
replaceFences: true,
2730
rolesAfter: "inline",
2831
directivesAfter: "block",
@@ -36,6 +39,7 @@ const OptionDefaults: IOptions = {
3639
export function docutilsPlugin(md: MarkdownIt, options?: IOptions): void {
3740
const fullOptions = { ...OptionDefaults, ...options }
3841

42+
if (fullOptions.colonFences) md.use(colonFencePlugin)
3943
md.use(rolePlugin, fullOptions)
4044
md.use(directivePlugin, fullOptions)
4145
md.use(statePlugin, fullOptions)

tests/colonFences.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/* eslint-disable jest/valid-title */
2+
import MarkdownIt from "markdown-it"
3+
import docutils_plugin from "../src"
4+
import readFixtures from "./readFixtures"
5+
6+
describe("Parses colonFences", () => {
7+
test("colon fences parse", () => {
8+
const mdit = MarkdownIt().use(docutils_plugin)
9+
const parse = mdit.parse(`:::{tip}\nThis is a tip in a fence!\n:::`, {})
10+
expect(parse[0].type).toBe("parsed_directive_open")
11+
})
12+
test("colon fences render", () => {
13+
const mdit = MarkdownIt().use(docutils_plugin)
14+
const rendered = mdit.render(`:::{tip}\nfence\n:::`)
15+
expect(rendered.trim()).toEqual(
16+
'<aside class="admonition tip">\n<header class="admonition-title">Tip</header>\n<p>fence</p>\n</aside>'
17+
)
18+
})
19+
})
20+
21+
describe("Parses fenced directives", () => {
22+
readFixtures("directives.fence").forEach(([name, text, expected]) => {
23+
const mdit = MarkdownIt().use(docutils_plugin)
24+
const rendered = mdit.render(text)
25+
it(name, () => expect(rendered.trim()).toEqual((expected || "").trim()))
26+
})
27+
})

tests/fixtures/directives.fence.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
Unknown fence directive
2+
.
3+
:::{unknown} argument
4+
content
5+
:::
6+
.
7+
<aside class="directive-unhandled">
8+
<header><mark>unknown</mark><code> argument</code></header>
9+
<pre>content
10+
</pre></aside>
11+
.
12+
13+
Fenced admonition:
14+
.
15+
:::{admonition} This is a **title**
16+
An example of an admonition with custom _title_.
17+
:::
18+
.
19+
<aside class="admonition">
20+
<header class="admonition-title">This is a <strong>title</strong></header>
21+
<p>An example of an admonition with custom <em>title</em>.</p>
22+
</aside>
23+
.
24+
25+
Fenced note on split lines:
26+
.
27+
:::{note} An example
28+
of an admonition on two lines.
29+
:::
30+
.
31+
<aside class="admonition note">
32+
<header class="admonition-title">Note</header>
33+
<p>An example
34+
of an admonition on two lines.</p>
35+
</aside>
36+
.

0 commit comments

Comments
 (0)