diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 06cec51f..cd40042c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
- node-version: [12.x, 14.x, 15.x]
+ node-version: [12.x, 16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
diff --git a/docs/syntax.md b/docs/syntax.md
index 589ff37f..1d8b2cea 100644
--- a/docs/syntax.md
+++ b/docs/syntax.md
@@ -349,6 +349,7 @@ _italic_
## 詳細
- インライン構文。
- 内容には改行を含めることができない。
+- 内容には「´」を含めることができない。
@@ -447,12 +448,14 @@ _italic_
- 内容には`.` `,` `!` `?` `'` `"` `#` `:` `/` `【` `】` を含めることができない。
- 括弧は対になっている時のみ内容に含めることができる。対象: `()` `[]` `「」`
- `#`の前の文字が(改行、スペース、無し、[a-zA-Z0-9]に一致しない)のいずれかの場合にハッシュタグとして認識する。
+- 内容が数字のみの場合はハッシュタグとして認識しない。
URL
## 形式
+構文1:
```
https://misskey.io/@ai
```
@@ -461,6 +464,15 @@ https://misskey.io/@ai
http://hoge.jp/abc
```
+構文2:
+```
+
+```
+
+```
+
+```
+
## ノード
```js
{
@@ -473,10 +485,15 @@ http://hoge.jp/abc
## 詳細
- インライン構文。
+
+構文1のみ:
- 内容には`[.,a-z0-9_/:%#@$&?!~=+-]i`にマッチする文字を使用できる。
- 内容には対になっている括弧を使用できる。対象: `(` `)` `[` `]`
- `.`や`,`は最後の文字にできない。
+構文2のみ:
+- 内容には改行、スペース以外の文字を使用できる。
+
リンク
diff --git a/package.json b/package.json
index 01cad0b5..bc18a455 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mfm-js",
- "version": "0.16.5",
+ "version": "0.17.0",
"description": "An MFM parser implementation with PEG.js",
"main": "./built/index.js",
"types": "./built/index.d.ts",
diff --git a/src/parser.pegjs b/src/parser.pegjs
index b22092bb..342cfcb0 100644
--- a/src/parser.pegjs
+++ b/src/parser.pegjs
@@ -291,7 +291,7 @@ strike
// inline: inlineCode
inlineCode
- = "`" content:$(!"`" c:CHAR { return c; })+ "`"
+ = "`" content:$(![`´] c:CHAR { return c; })+ "`"
{
return INLINE_CODE(content);
}
@@ -341,7 +341,13 @@ hashtag
}
hashtagContent
- = (hashtagBracketPair / hashtagChar)+ { return text(); }
+ = !(invalidHashtagContent !hashtagContentPart) hashtagContentPart+ { return text(); }
+
+invalidHashtagContent
+ = [0-9]+
+
+hashtagContentPart
+ = hashtagBracketPair / hashtagChar
hashtagBracketPair
= "(" hashtagContent* ")"
@@ -354,7 +360,7 @@ hashtagChar
// inline: URL
url
- = "<" url:urlFormat ">"
+ = "<" url:altUrlFormat ">"
{
return N_URL(url);
}
@@ -364,14 +370,11 @@ url
}
urlFormat
- = "http" "s"? "://" urlContent
+ = "http" "s"? "://" urlContentPart+
{
return text();
}
-urlContent
- = urlContentPart+
-
urlContentPart
= urlBracketPair
/ [.,] &urlContentPart // last char is neither "." nor ",".
@@ -381,6 +384,12 @@ urlBracketPair
= "(" urlContentPart* ")"
/ "[" urlContentPart* "]"
+altUrlFormat
+ = "http" "s"? "://" (!(">" / _) CHAR)+
+{
+ return text();
+}
+
// inline: link
link
diff --git a/test/parser.ts b/test/parser.ts
index 51845eeb..fb0663fc 100644
--- a/test/parser.ts
+++ b/test/parser.ts
@@ -1,7 +1,7 @@
import assert from 'assert';
import * as mfm from '../built/index';
import {
- TEXT, CENTER, FN, UNI_EMOJI, MENTION, EMOJI_CODE, HASHTAG, N_URL, BOLD, SMALL, ITALIC, STRIKE, QUOTE, MATH_BLOCK, SEARCH, CODE_BLOCK, LINK
+ TEXT, CENTER, FN, UNI_EMOJI, MENTION, EMOJI_CODE, HASHTAG, N_URL, BOLD, SMALL, ITALIC, STRIKE, QUOTE, MATH_BLOCK, SEARCH, CODE_BLOCK, LINK, INLINE_CODE
} from '../built/index';
describe('PlainParser', () => {
@@ -24,6 +24,26 @@ describe('PlainParser', () => {
assert.deepStrictEqual(mfm.parsePlain(input), output);
});
});
+
+ describe('emoji', () => {
+ it('basic', () => {
+ const input = ':foo:';
+ const output = [EMOJI_CODE('foo')];
+ assert.deepStrictEqual(mfm.parsePlain(input), output);
+ });
+
+ it('between texts', () => {
+ const input = 'foo:bar:baz';
+ const output = [TEXT('foo'), EMOJI_CODE('bar'), TEXT('baz')];
+ assert.deepStrictEqual(mfm.parsePlain(input), output);
+ });
+ });
+
+ it('disallow other syntaxes', () => {
+ const input = 'foo **bar** baz';
+ const output = [TEXT('foo **bar** baz')];
+ assert.deepStrictEqual(mfm.parsePlain(input), output);
+ });
});
describe('FullParser', () => {
@@ -173,16 +193,19 @@ describe('FullParser', () => {
const output = [CODE_BLOCK('abc', null)];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
it('コードブロックには複数行のコードを入力できる', () => {
const input = '```\na\nb\nc\n```';
const output = [CODE_BLOCK('a\nb\nc', null)];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
it('コードブロックは言語を指定できる', () => {
const input = '```js\nconst a = 1;\n```';
const output = [CODE_BLOCK('const a = 1;', 'js')];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
it('ブロックの前後にあるテキストが正しく解釈される', () => {
const input = 'abc\n```\nconst abc = 1;\n```\n123';
const output = [
@@ -192,6 +215,21 @@ describe('FullParser', () => {
];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
+ it('ignore internal marker', () => {
+ const input = '```\naaa```bbb\n```';
+ const output = [CODE_BLOCK('aaa```bbb', null)];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('trim after line break', () => {
+ const input = '```\nfoo\n```\nbar';
+ const output = [
+ CODE_BLOCK('foo', null),
+ TEXT('bar'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
});
describe('mathBlock', () => {
@@ -506,7 +544,25 @@ describe('FullParser', () => {
// strike
- // inlineCode
+ describe('inlineCode', () => {
+ it('basic', () => {
+ const input = '`var x = "Strawberry Pasta";`';
+ const output = [INLINE_CODE('var x = "Strawberry Pasta";')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('disallow line break', () => {
+ const input = '`foo\nbar`';
+ const output = [TEXT('`foo\nbar`')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('disallow ´', () => {
+ const input = '`foo´bar`';
+ const output = [TEXT('`foo´bar`')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+ });
// mathInline
@@ -589,10 +645,108 @@ describe('FullParser', () => {
output = [TEXT('あいう'), HASHTAG('abc')];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
+ it('ignore comma and period', () => {
+ const input = 'Foo #bar, baz #piyo.';
+ const output = [TEXT('Foo '), HASHTAG('bar'), TEXT(', baz '), HASHTAG('piyo'), TEXT('.')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore exclamation mark', () => {
+ const input = '#Foo!';
+ const output = [HASHTAG('Foo'), TEXT('!')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore colon', () => {
+ const input = '#Foo:';
+ const output = [HASHTAG('Foo'), TEXT(':')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore single quote', () => {
+ const input = '#Foo\'';
+ const output = [HASHTAG('Foo'), TEXT('\'')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore double quote', () => {
+ const input = '#Foo"';
+ const output = [HASHTAG('Foo'), TEXT('"')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore square bracket', () => {
+ const input = '#Foo]';
+ const output = [HASHTAG('Foo'), TEXT(']')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore slash', () => {
+ const input = '#foo/bar';
+ const output = [HASHTAG('foo'), TEXT('/bar')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('allow including number', () => {
+ const input = '#foo123';
+ const output = [HASHTAG('foo123')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with brackets "()"', () => {
+ const input = '(#foo)';
+ const output = [TEXT('('), HASHTAG('foo'), TEXT(')')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with brackets "「」"', () => {
+ const input = '「#foo」';
+ const output = [TEXT('「'), HASHTAG('foo'), TEXT('」')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with mixed brackets', () => {
+ const input = '「#foo(bar)」';
+ const output = [TEXT('「'), HASHTAG('foo(bar)'), TEXT('」')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with brackets "()" (space before)', () => {
+ const input = '(bar #foo)';
+ const output = [TEXT('(bar '), HASHTAG('foo'), TEXT(')')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with brackets "「」" (space before)', () => {
+ const input = '「bar #foo」';
+ const output = [TEXT('「bar '), HASHTAG('foo'), TEXT('」')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('disallow number only', () => {
+ const input = '#123';
+ const output = [TEXT('#123')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('disallow number only (with brackets)', () => {
+ const input = '(#123)';
+ const output = [TEXT('(#123)')];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
});
describe('url', () => {
it('basic', () => {
+ const input = 'https://misskey.io/@ai';
+ const output = [
+ N_URL('https://misskey.io/@ai'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with other texts', () => {
const input = 'official instance: https://misskey.io/@ai.';
const output = [
TEXT('official instance: '),
@@ -601,6 +755,105 @@ describe('FullParser', () => {
];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
+ it('ignore trailing period', () => {
+ const input = 'https://misskey.io/@ai.';
+ const output = [
+ N_URL('https://misskey.io/@ai'),
+ TEXT('.')
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore trailing periods', () => {
+ const input = 'https://misskey.io/@ai...';
+ const output = [
+ N_URL('https://misskey.io/@ai'),
+ TEXT('...')
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with comma', () => {
+ const input = 'https://example.com/foo?bar=a,b';
+ const output = [
+ N_URL('https://example.com/foo?bar=a,b'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore trailing comma', () => {
+ const input = 'https://example.com/foo, bar';
+ const output = [
+ N_URL('https://example.com/foo'),
+ TEXT(', bar')
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with brackets', () => {
+ const input = 'https://example.com/foo(bar)';
+ const output = [
+ N_URL('https://example.com/foo(bar)'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore parent brackets', () => {
+ const input = '(https://example.com/foo)';
+ const output = [
+ TEXT('('),
+ N_URL('https://example.com/foo'),
+ TEXT(')'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore parent brackets (2)', () => {
+ const input = '(foo https://example.com/foo)';
+ const output = [
+ TEXT('(foo '),
+ N_URL('https://example.com/foo'),
+ TEXT(')'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore parent brackets with internal brackets', () => {
+ const input = '(https://example.com/foo(bar))';
+ const output = [
+ TEXT('('),
+ N_URL('https://example.com/foo(bar)'),
+ TEXT(')'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore parent []', () => {
+ const input = 'foo [https://example.com/foo] bar';
+ const output = [
+ TEXT('foo ['),
+ N_URL('https://example.com/foo'),
+ TEXT('] bar'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('ignore non-ascii characters contained url without angle brackets', () => {
+ const input = 'https://大石泉すき.example.com';
+ const output = [
+ TEXT('https://大石泉すき.example.com'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('match non-ascii characters contained url with angle brackets', () => {
+ const input = '';
+ const output = [
+ N_URL('https://大石泉すき.example.com'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
});
describe('link', () => {
@@ -649,6 +902,28 @@ describe('FullParser', () => {
];
assert.deepStrictEqual(mfm.parse(input), output);
});
+
+ it('with brackets', () => {
+ const input = '[foo](https://example.com/foo(bar))';
+ const output = [
+ LINK(false, 'https://example.com/foo(bar)', [
+ TEXT('foo')
+ ]),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
+
+ it('with parent brackets', () => {
+ const input = '([foo](https://example.com/foo(bar)))';
+ const output = [
+ TEXT('('),
+ LINK(false, 'https://example.com/foo(bar)', [
+ TEXT('foo')
+ ]),
+ TEXT(')'),
+ ];
+ assert.deepStrictEqual(mfm.parse(input), output);
+ });
});
describe('fn v1', () => {