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', () => {