1
0
Fork 0
This commit is contained in:
sup39 2021-01-12 20:05:19 +09:00
commit 7a10e1949d
16 changed files with 2103 additions and 0 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
node_modules/

17
.eslintrc.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
env: {
es6: true,
node: true,
},
extends: [
'google',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {
'arrow-parens': ['error', 'as-needed'],
'indent': ['error', 2, {'MemberExpression': 1}],
},
};

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
Copyright (c) 2021 sup39[サポミク]
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

212
README.md Normal file
View file

@ -0,0 +1,212 @@
# markdown-it-attr
A [markdown-it](https://github.com/markdown-it/markdown-it) plugin
to write id, classes, and attributes.
## Syntax
`{#id .class1 .class2 attr=val attr1='val"s"' attr2="val's" attr3}`
### Where to put `{...}`
|type|where|example|
|:-:|:-:|--|
|inline tag|**AFTER** tag|em / strong|
|inline block|beginning / end|li / td / th|
|block|**BEFORE** block|h1 / ul / table|
Note: There is no way to add attributes to `tr` without extension.
See [Extension: Attributes for tr](#tr-extension) for more info.
## Examples
### Attributes for inline tag
Add `{...}` **AFTER** the inline tag.
#### em / strong
Example Input:
```markdown
*x*{.a} **y**{.b} ***z***{.c}
```
Output:
```html
<p><em class="a">x</em> <strong class="b">y</strong> <em class="c"><strong>z</strong></em></p>
```
### Attributes for inline block
Add `{...}` at the **beginning** or the **end** in the inline block.
#### list item
Example Input:
```markdown
- {.a} x1
- x2 {.b}
- x3
```
Output:
```html
<ul>
<li class="a">x1</li>
<li class="b">x2</li>
<li>x3</li>
</ul>
```
#### th / td
Example Input:
```markdown
|h1{.a}|h2{.b}|
|------|------|
|d1{.c}|d2{.d}|
```
Output:
```html
<table>
<thead>
<tr>
<th class="a">h1</th>
<th class="b">h2</th>
</tr>
</thead>
<tbody>
<tr>
<td class="c">d1</td>
<td class="d">d2</td>
</tr>
</tbody>
</table>
```
### Attributes for block
Add `{...}` **BEFORE** the block.
#### header
Example Input:
```markdown
{.a}
# h1
```
Output:
```html
<h1 class="a">h1</h1>
```
#### list
Example Input:
```markdown
{.b}
- l1
- l2
```
Output:
```html
<ul class="b">
<li>l1</li>
<li>l2</li>
</ul>
```
#### table
Example Input:
```markdown
{.c}
|h1|h2|
|--|--|
|d1|d2|
```
Output:
```html
<table class="c">
<thead>
<tr>
<th>h1</th>
<th>h2</th>
</tr>
</thead>
<tbody>
<tr>
<td>d1</td>
<td>d2</td>
</tr>
</tbody>
</table>
```
## Usage
```js
const md = require('markdown-it')();
const mia = require('@sup39/markdown-it-attrs');
console.log(md.use(mia).render(`
{#head-id}
# head
`));
```
Expected output:
```html
<h1 id="head-id">head</h1>
```
<h2 id="tr-extension">Extension: Attributes for tr</h2>
To make adding attributes to `tr` work, it is required to use
[@sup39/markdown-it-raw-table](https://github.com/sup39/markdown-it-raw-table)
plugin, in order to prevent forcing td count to be equal to th count,
which eliminates the attributes of `tr`.
```js
const md = require('markdown-it')();
const mia = require('markdown-it-attrs');
const mrt = require('@sup39/markdown-it-raw-table');
// enable raw_table_tr rule
mia.inlineAttrsApplyRules.find(e=>e.name==='raw_table_tr').disabled = false;
console.log(md.use(mia).use(mrt).render(`
| h1 | h2 | h3 {.ch} |
| -- | -- | -- |
| x1 | x2 {.c2} | x3 {rowspan=2} | {.r1}
| x4 {colspan=2 .c4} | {.r2}
`));
```
Expected output:
```html
<table>
<thead>
<tr>
<th>h1</th>
<th>h2</th>
<th class="ch">h3</th>
</tr>
</thead>
<tbody>
<tr class="r1">
<td>x1</td>
<td class="c2">x2</td>
<td rowspan="2" class="c3">x3</td>
</tr>
<tr class="r2">
<td colspan="2" class="c4">x4</td>
</tr>
</tbody>
</table>
```
<table>
<thead>
<tr>
<th>h1</th>
<th>h2</th>
<th class="ch">h3</th>
</tr>
</thead>
<tbody>
<tr class="r1">
<td>x1</td>
<td class="c2">x2</td>
<td rowspan="2" class="c3">x3</td>
</tr>
<tr class="r2">
<td colspan="2" class="c4">x4</td>
</tr>
</tbody>
</table>
Note that adding attributes to `tr` of the `th` row is NOT available.

101
lib/index.js Normal file
View file

@ -0,0 +1,101 @@
const {parseAttrs, attrConcat} = require('./utils.js');
const {inlineAttrsApplyRules} = require('./rules.js');
/**
* @param {MarkdownIt} md
* @param {{deliminator: string, re: RegExp}} opts
*/
function pluginAttr(md, opts={}) {
const deliminator = opts.deliminator || '{';
const deliminatorLength = deliminator.length;
/* eslint-disable-next-line max-len */
const reDefault = /\#(?<id>[^\s#.="'}]+)|\.(?<class>[^\s#.="'}]+)|(?<attr>[^\s#.="'}]+)(?:\=(?<val>[^\s#.="'}]+|(?<q>["']).*?\k<q>))?|}/g;
const reRaw = opts.re;
const re = reRaw ?
(reRaw.sticky || reRaw.global) ? reRaw :
new RegExp(reRaw, reRaw.flags+'g') :
reDefault;
md.block.ruler.before('table', 'block_attrs', (state, l0, l1, silent) => {
const {src, bMarks, eMarks, tShift} = state;
// guard thisLine.sCount == nextLine.sCount
if (state.sCount[l0] !== state.sCount[l0+1]) return false;
// disallow contiguous two { }
if (
l1 > l0+2 &&
state.sCount[l0+1] === state.sCount[l0+2] &&
src.startsWith(deliminator, bMarks[l0+1]+tShift[l0+1])
) return false;
// parse
const indexStart = bMarks[l0]+tShift[l0];
const indexEnd = eMarks[l0];
if (src.startsWith(deliminator, indexStart)) {
// parse attrs
re.lastIndex = indexStart+deliminatorLength;
const {attrs, index} = parseAttrs(src, re) || {};
// should meet end of line
if (!(attrs && index === indexEnd)) return false;
// push
if (silent) return true;
state.line = l0+1;
const token = state.push('block_attr', '', 0);
token.attrs = attrs;
token.hidden = true;
return true;
}
return false;
}, {alt: ['paragraph']});
md.inline.ruler.push('inline_attrs', (state, silent) => {
const {src, pos} = state;
if (src.startsWith(deliminator, pos)) {
re.lastIndex = pos+deliminatorLength;
const {attrs, index} = parseAttrs(src, re) || {};
if (!attrs) return false;
// set attr
if (silent) return true;
const token = state.push('inline_attr', '', 0);
token.attrs = attrs;
token.content = src.substring(pos, index);
token.hidden = true;
state.pos = index;
return true;
}
return false;
});
md.core.ruler.after('block', 'apply_block_attrs', state => {
state.tokens.forEach((token, i, tokens) => {
if (token.type === 'block_attr' && token.attrs) {
const tokenN = tokens[i+1];
if (tokenN) {
attrConcat(tokenN, token);
tokens.splice(i, 1);
}
}
});
});
md.core.ruler.after('inline', 'apply_inline_attrs', state => {
state.tokens.forEach((blockToken, iBlock, blockTokens) => {
const {children} = blockToken;
if (!children) return;
children.forEach((token, i, tokens) => {
if (token.type !== 'inline_attr') return;
const matched = inlineAttrsApplyRules.some(({handler, disabled}) =>
disabled ? false : handler(token, i, tokens, iBlock, blockTokens));
// not seen as attrs
if (!matched) {
token.type = 'text';
token.hidden = false;
}
});
// remove inline_attr here to prevent index changed
blockToken.children = children.filter(t => t.type !== 'inline_attr');
});
});
}
module.exports = Object.assign(pluginAttr, {
inlineAttrsApplyRules,
});

78
lib/rules.js Normal file
View file

@ -0,0 +1,78 @@
const {
findOpenToken, find, rfind, rfindIndex, attrConcat,
} = require('./utils.js');
const inlineAttrsApplyRules = [
{
name: 'first_child',
handler(token, i, tokens, iBlock, blockTokens) {
if (i !== 0) return;
const tokenO = rfind(
blockTokens, iBlock-1, t=>!t.hidden, t=>t.nesting===1);
if (!tokenO) return;
// push attrs
attrConcat(tokenO, token);
const tokenNext = tokens[1];
if (tokenNext && tokenNext.type==='text') {
// trim start of next token
tokenNext.content = tokenNext.content.replace(/^\s+/, '');
}
return true;
},
},
{
name: 'after_inline_close',
handler(token, i, tokens, iBlock, blockTokens) {
const iC = rfindIndex(tokens, i-1, t=>t.type!=='text'||t.content);
const tokenC = iC>=0 && tokens[iC];
if (!(tokenC && tokenC.nesting === -1)) return;
// find open
const tokenO = findOpenToken(tokens, iC);
if (!tokenO) return false;
attrConcat(tokenO, token);
return true;
},
},
{
name: 'last_child',
handler(token, i, tokens, iBlock, blockTokens) {
if (i !== tokens.length-1) return;
const tokenC = find(
blockTokens, iBlock+1, t=>!t.hidden, t=>t.nesting===-1);
if (!tokenC) return;
const {level} = tokenC;
const tokenO = rfind(
blockTokens, iBlock-1, t=>t.level===level, t=>t.nesting===1);
if (!tokenO) return;
token.attrs.forEach(attr => tokenO.attrPush(attr));
const tokenLast = tokens[i-1];
if (tokenLast && tokenLast.type==='text') {
// trim end of last token
tokenLast.content = tokenLast.content.replace(/\s+$/, '');
}
return true;
},
},
];
module.exports = {inlineAttrsApplyRules};
// TODO
inlineAttrsApplyRules.unshift({
name: 'raw_table_tr',
disabled: true,
handler(token, i, tokens, iB, tokenBs) {
if (tokens.length !== 1) return;
if (tokenBs.length <= i+2) return;
if (!(
tokenBs[iB+1].type === 'td_close' &&
tokenBs[iB+2].type === 'tr_close'
)) return;
const tokenO = findOpenToken(tokenBs, iB+2);
if (!tokenO) return;
attrConcat(tokenO, token);
tokenBs.splice(iB+1, 1); // td_close
tokenBs.splice(iB-1, 1); // td_open
return true;
},
});

107
lib/utils.js Normal file
View file

@ -0,0 +1,107 @@
/**
* @param {string} s - input string
* @param {RegExp} re - regular expression for attributes
* @param {number} [limit=65536] - limit of attribute count
* @return {{attr: [string, string][]}|null}
*/
function parseAttrs(s, re, limit=65536) {
let count = 0;
/** @type {string[]} */
const classes = [];
/** @type {[string, string][]} */
const attrs = [];
let m;
while ((m = re.exec(s)) && count<limit) {
const g = m.groups;
if (g.id) attrs.push(['id', g.id]);
else if (g.class) classes.push(g.class);
else if (g.attr) {
const ql = g.q && g.q.length;
const val = ql ? g.val.slice(ql, -ql) : g.val || '';
attrs.push([g.attr, val]);
} else {
if (classes.length) attrs.push(['class', classes.join(' ')]);
return {attrs, index: m.index+m[0].length};
}
count++;
}
return null;
}
/**
* @param {Token} tokens - tokens
* @param {number} i - index of close token
* @return {Token|null} - open token
*/
function findOpenToken(tokens, i) {
const tokenC = tokens[i];
if (!tokenC) return null;
const {level} = tokenC;
for (i--; i>=0; i--) {
const token = tokens[i];
if (token.level === level) return token;
}
return null;
}
/**
* @param {Array} arr
* @param {number} startIndex
* @param {function(any, number): boolean} test
* @param {function(any, number): boolean} [constraint]
* @return {any|undefined} element;
*/
function find(arr, startIndex, test, constraint=()=>true) {
for (let i=startIndex, len=arr.length; i<len; i++) {
const elm = arr[i];
if (!constraint(elm)) return;
if (test(elm)) return elm;
}
return;
}
/**
* @param {Array} arr
* @param {number} startIndex
* @param {function(any, number): boolean} test
* @param {function(any, number): boolean} [constraint]
* @return {any|undefined} element;
*/
function rfind(arr, startIndex, test, constraint=()=>true) {
for (let i=startIndex; i>=0; i--) {
const elm = arr[i];
if (!constraint(elm)) return;
if (test(elm)) return elm;
}
return;
}
/**
* @param {Array} arr
* @param {number} startIndex
* @param {function(any, number): boolean} test
* @param {function(any, number): boolean} [constraint]
* @return {number} index ?? -1;
*/
function rfindIndex(arr, startIndex, test, constraint=()=>true) {
for (let i=startIndex; i>=0; i--) {
const elm = arr[i];
if (!constraint(elm)) return -1;
if (test(elm)) return i;
}
return -1;
}
/**
* @param {Token} dst - Destination Token
* @param {Token} src - Source Token
*/
function attrConcat(dst, src) {
const {attrs} = src;
if (attrs) attrs.forEach(attr => dst.attrPush(attr));
}
module.exports = {
parseAttrs, findOpenToken, find, rfind, rfindIndex, attrConcat,
};

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "@sup39/markdown-it-attr",
"version": "1.0.0",
"description": "A markdown-it plugin to write id, classes, and attributes",
"keywords": [
"markdown",
"markdown-it",
"markdown-it-plugin",
"attribute"
],
"repository": "sup39/markdown-it-attr",
"license": "MIT",
"main": "lib/index.js",
"scripts": {
"lint": "eslint .",
"test": "mocha"
},
"devDependencies": {
"@sup39/markdown-it-raw-table": "^1.0.0",
"eslint": "^7.17.0",
"eslint-config-google": "^0.14.0",
"markdown-it": "^12.0.4",
"mocha": "^8.2.1"
}
}

46
test/cases/attrs.txt Normal file
View file

@ -0,0 +1,46 @@
id
.
# text {#iid}
.
<h1 id="iid">text</h1>
.
class
.
# text {.aa.b.c.dd .e .fff .gg}
.
<h1 class="aa b c dd e fff gg">text</h1>
.
non-escaped attributes
.
# text {a=be style=color:red;font-size:24px c=4+5}
.
<h1 a="be" style="color:red;font-size:24px" c="4+5">text</h1>
.
escaped attributes
.
# text {onload="alert('Hello World!')" onclick='alert("{#another.=.alert}")'}
.
<h1 onload="alert('Hello World!')" onclick="alert(&quot;{#another.=.alert}&quot;)">text</h1>
.
no need to escape backslash
.
# text {no-need-to-escape=\back\slash}
.
<h1 no-need-to-escape="\back\slash">text</h1>
.
mixed
.
# text {.a#b.c d="e.f".g-h i=j-k .l}
.
<h1 id="b" d="e.f" i="j-k" class="a c g-h l">text</h1>
.

91
test/cases/common.txt Normal file
View file

@ -0,0 +1,91 @@
inline
.
1 *2*{#a} **3** **4**{#b} {#c} 6
.
<p>1 <em id="a">2</em> <strong>3</strong> <strong id="b">4</strong> {#c} 6</p>
.
header[block]
.
{#a.b}
# c d
{e=f}
## g h
.
<h1 id="a" class="b">c d</h1>
<h2 e="f">g h</h2>
.
header[inline]
.
# c d {#a.b}
## g h {e=f}
.
<h1 id="a" class="b">c d</h1>
<h2 e="f">g h</h2>
.
simple list
.
{.a}
- {#d} ab
- cd
- ef {.e}
{.b}
### header
.
<ul class="a">
<li id="d">ab</li>
<li>cd</li>
<li class="e">ef</li>
</ul>
<h3 class="b">header</h3>
.
nested list
.
{#a}
- {#b} x1
+ y1
+ {#c} y2
- x2
- x3
{#d}
+ {#e} z1
+ z2
# end
.
<ul id="a">
<li id="b">x1
<ul>
<li>y1</li>
<li id="c">y2</li>
</ul>
</li>
<li>x2</li>
<li>x3
<ul id="d">
<li id="e">z1</li>
<li>z2</li>
</ul>
</li>
</ul>
<h1>end</h1>
.
code[block]
.
{a.b#c.d}
```
code
line2
```
.
<pre><code a="" id="c" class="b d">code
line2
</code></pre>
.

21
test/cases/escape.txt Normal file
View file

@ -0,0 +1,21 @@
block
.
\{#a.b}
# c d
\{e=f}
## g h
.
<p>{#a.b}</p>
<h1>c d</h1>
<p>{e=f}</p>
<h2>g h</h2>
.
inline
.
# c d \{#a.b}
## g h \{e=f}
.
<h1>c d {#a.b}</h1>
<h2>g h {e=f}</h2>
.

121
test/cases/table.txt Normal file
View file

@ -0,0 +1,121 @@
td
.
| h1 | h2 | h3 |
| -- | -- | -- |
| c1 | c2 {.k1}| c3 |
| d1 {#m1} | d2 | d3 {.m2 m3=m4} |
.
<table>
<thead>
<tr>
<th>h1</th>
<th>h2</th>
<th>h3</th>
</tr>
</thead>
<tbody>
<tr>
<td>c1</td>
<td class="k1">c2</td>
<td>c3</td>
</tr>
<tr>
<td id="m1">d1</td>
<td>d2</td>
<td m3="m4" class="m2">d3</td>
</tr>
</tbody>
</table>
.
tr
.
| h1 | h2 | h3 |
| -- | -- | -- |
| c1 | c2 | c3 | {.r.s}
| d1 | d2 | d3 | { #t u=v }
.
<table>
<thead>
<tr>
<th>h1</th>
<th>h2</th>
<th>h3</th>
</tr>
</thead>
<tbody>
<tr class="r s">
<td>c1</td>
<td>c2</td>
<td>c3</td>
</tr>
<tr id="t" u="v">
<td>d1</td>
<td>d2</td>
<td>d3</td>
</tr>
</tbody>
</table>
.
th+td+tr+table
.
{.bd}
| h1{.a.b#c.d} | h2 | h3 |
| -- | -- | -- |
| c1 | c2 {.k1}| c3 | {#r.s}
| d1 {#m1} | d2 | d3 {.m2 m3=m4} |
.
<table class="bd">
<thead>
<tr>
<th id="c" class="a b d">h1</th>
<th>h2</th>
<th>h3</th>
</tr>
</thead>
<tbody>
<tr id="r" class="s">
<td>c1</td>
<td class="k1">c2</td>
<td>c3</td>
</tr>
<tr>
<td id="m1">d1</td>
<td>d2</td>
<td m3="m4" class="m2">d3</td>
</tr>
</tbody>
</table>
.
colspan & rowspan
.
| h1 | h2 | h3 {.ch} |
| -- | -- | -- |
| x1 | x2 {.c2} | x3 {rowspan=2 .c3} | {.r1}
| x4 {colspan=2 .c4} | {.r2}
.
<table>
<thead>
<tr>
<th>h1</th>
<th>h2</th>
<th class="ch">h3</th>
</tr>
</thead>
<tbody>
<tr class="r1">
<td>x1</td>
<td class="c2">x2</td>
<td rowspan="2" class="c3">x3</td>
</tr>
<tr class="r2">
<td colspan="2" class="c4">x4</td>
</tr>
</tbody>
</table>
.

23
test/index.js Normal file
View file

@ -0,0 +1,23 @@
const mdi = require('markdown-it');
const mrt = require('@sup39/markdown-it-raw-table');
const mia = require('..');
const test = require('./test');
describe('Attributes', () => {
const md = mdi().use(mia);
test(md, 'attrs.txt');
});
describe('Common', () => {
const md = mdi().use(mia);
test(md, 'common.txt');
});
describe('Escape', () => {
const md = mdi().use(mia);
test(md, 'escape.txt');
});
describe('Table (with @sup39/markdown-it-raw-table)', () => {
const md = mdi().use(mrt).use(mia);
// enable raw_table_tr
mia.inlineAttrsApplyRules.find(e=>e.name==='raw_table_tr').disabled = false;
test(md, 'table.txt');
});

19
test/test.js Normal file
View file

@ -0,0 +1,19 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const testCaseDir = path.join(__dirname, 'cases');
/**
* @param {MarkdownIt} md
* @param {string} fileName
*/
module.exports = function test(md, fileName) {
const raw =
fs.readFileSync(path.join(testCaseDir, fileName)).toString();
const elms = raw.split(/\n\.\n/);
for (let i=2; i<elms.length; i+=3) {
const title = elms[i-2].trim();
it(title, () => {
assert.equal(md.render(elms[i-1]), elms[i]+'\n');
});
}
};

1218
yarn.lock Normal file

File diff suppressed because it is too large Load diff