diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..1237b21 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 16.13.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e4887d4..36856b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ --> ## 0.23.4 (unreleased) +### Features +- Add remote media syntax for links (#127) + ### Improvements - Improve generation of brackets for links (#126) diff --git a/docs/syntax.md b/docs/syntax.md index 763ed72..698c7f9 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -535,19 +535,24 @@ http://hoge.jp/abc

Inline: リンク

## 形式 -silent=false +type='plain' ``` [Misskey.io](https://misskey.io/) ``` -silent=true +type='plain' with special characters +``` +[#藍ちゃファンクラブ]() +``` + +type='silent' ``` ?[Misskey.io](https://misskey.io/) ``` -Special characters +type='embed' ``` -[#藍ちゃファンクラブ]() +![Misskey.io](https://misskey.io/) ``` ## 詳細 @@ -559,7 +564,7 @@ Special characters { type: 'link', props: { - silent: false, + type: 'plain' url: { type: 'url', props: { diff --git a/etc/mfm-js.api.md b/etc/mfm-js.api.md index 385e1f9..444d604 100644 --- a/etc/mfm-js.api.md +++ b/etc/mfm-js.api.md @@ -38,7 +38,7 @@ export function inspect(nodes: MfmNode[], action: (node: MfmNode) => void): void export const ITALIC: (children: MfmInline[]) => NodeType<'italic'>; // @public (undocumented) -export const LINK: (silent: boolean, url: MfmUrl, children: MfmInline[]) => NodeType<'link'>; +export const LINK: (type: 'plain' | 'silent' | 'embed', url: MfmUrl, children: MfmInline[]) => NodeType<'link'>; // @public (undocumented) export const MATH_BLOCK: (formula: string) => NodeType<'mathBlock'>; @@ -127,7 +127,7 @@ export type MfmItalic = { export type MfmLink = { type: 'link'; props: { - silent: boolean; + type: 'plain' | 'silent' | 'embed'; url: MfmUrl; }; children: MfmInline[]; diff --git a/src/internal/parser.ts b/src/internal/parser.ts index 342f5e7..3385e8b 100644 --- a/src/internal/parser.ts +++ b/src/internal/parser.ts @@ -462,7 +462,7 @@ export const language = P.createLanguage({ P.str('.'), arg.sep(P.str(','), 1), ], 1).map(pairs => { - const result: Args = { }; + const result: Args = {}; for (const pair of pairs) { result[pair.k] = pair.v; } @@ -644,7 +644,7 @@ export const language = P.createLanguage({ const closeLabel = P.str(']'); return P.seq([ notLinkLabel, - P.alt([P.str('?['), P.str('[')]), + P.alt([P.str('?['), P.str('!['), P.str('[')]), P.seq([ P.notMatch(P.alt([closeLabel, newLine])), nest(labelInline), @@ -654,10 +654,15 @@ export const language = P.createLanguage({ P.alt([r.urlAlt, r.url]), P.str(')'), ]).map(result => { - const silent = (result[1] === '?['); + const mapping: {[key: string]: M.MfmLink['props']['type']} = { + '?[': 'silent', + '![': 'embed', + '[': 'plain', + }; + const type: M.MfmLink['props']['type'] = mapping[result[1]]; const label = result[2]; const url: M.MfmUrl = result[5]; - return M.LINK(silent, url, mergeText(label)); + return M.LINK(type, url, mergeText(label)); }); }, diff --git a/src/internal/util.ts b/src/internal/util.ts index d043429..079afd3 100644 --- a/src/internal/util.ts +++ b/src/internal/util.ts @@ -1,4 +1,4 @@ -import { isMfmBlock, MfmInline, MfmNode, MfmText, TEXT } from '../node'; +import { isMfmBlock, MfmInline, MfmNode, MfmText, MfmLink, TEXT } from '../node'; export function mergeText(nodes: ((T extends MfmInline ? MfmInline : MfmNode) | string)[]): (T | MfmText)[] { const dest: (T | MfmText)[] = []; @@ -91,8 +91,12 @@ export function stringifyNode(node: MfmNode): string { } } case 'link': { - const prefix = node.props.silent ? '?' : ''; - return `${ prefix }[${ stringifyTree(node.children) }](${ stringifyNode(node.props.url) })`; + const prefixMapping: {[key in MfmLink['props']['type']]: string} = { + 'silent': '?', + 'embed': '!', + 'plain': '', + }; + return `${ prefixMapping[node.props.type] }[${ stringifyTree(node.children) }](${ stringifyNode(node.props.url) })`; } case 'fn': { const argFields = Object.keys(node.props.args).map(key => { diff --git a/src/node.ts b/src/node.ts index 7942e96..042f825 100644 --- a/src/node.ts +++ b/src/node.ts @@ -156,12 +156,12 @@ export const N_URL = (value: string, brackets?: boolean): NodeType<'url'> => { export type MfmLink = { type: 'link'; props: { - silent: boolean; + type: 'plain' | 'silent' | 'embed'; url: MfmUrl; }; children: MfmInline[]; }; -export const LINK = (silent: boolean, url: MfmUrl, children: MfmInline[]): NodeType<'link'> => { return { type: 'link', props: { silent, url }, children }; }; +export const LINK = (type: 'plain' | 'silent' | 'embed', url: MfmUrl, children: MfmInline[]): NodeType<'link'> => { return { type: 'link', props: { type, url }, children }; }; export type MfmFn = { type: 'fn'; diff --git a/test/api.ts b/test/api.ts index 0f6bd0b..3c97202 100644 --- a/test/api.ts +++ b/test/api.ts @@ -142,6 +142,21 @@ after`; assert.strictEqual(mfm.toString(mfm.parse(input)), '?[Ai](https://github.com/syuilo/ai)'); }); + test('silent bracket link', () => { + const input = '?[#藍ちゃファンクラブ]()'; + assert.strictEqual(mfm.toString(mfm.parse(input)), '?[#藍ちゃファンクラブ]()'); + }); + + test('image link', () => { + const input = '![Ai logo](https://raw.githubusercontent.com/syuilo/ai/master/ai.svg)'; + assert.strictEqual(mfm.toString(mfm.parse(input)), '![Ai logo](https://raw.githubusercontent.com/syuilo/ai/master/ai.svg)'); + }); + + test('image bracket link', () => { + const input = '![#藍ちゃファンクラブ]()'; + assert.strictEqual(mfm.toString(mfm.parse(input)), '![#藍ちゃファンクラブ]()'); + }); + test('fn', () => { const input = '$[tada Hello]'; assert.strictEqual(mfm.toString(mfm.parse(input)), '$[tada Hello]'); diff --git a/test/parser.ts b/test/parser.ts index fc2a9d6..b46fb3d 100644 --- a/test/parser.ts +++ b/test/parser.ts @@ -1043,7 +1043,7 @@ hoge`; const input = '[official instance](https://misskey.io/@ai).'; const output = [ LINK( - false, + 'plain', N_URL('https://misskey.io/@ai'), [TEXT('official instance')] ), @@ -1056,7 +1056,7 @@ hoge`; const input = '?[official instance](https://misskey.io/@ai).'; const output = [ LINK( - true, + 'silent', N_URL('https://misskey.io/@ai'), [TEXT('official instance')] ), @@ -1069,7 +1069,7 @@ hoge`; const input = '[#藍ちゃファンクラブ]().'; const output = [ LINK( - false, + 'plain', N_URL('https://misskey.io/explore/tags/藍ちゃファンクラブ', true), [TEXT('#藍ちゃファンクラブ')] ), @@ -1086,13 +1086,52 @@ hoge`; assert.deepStrictEqual(mfm.parse(input), output); }); + test('embed flag', () => { + const input = '![image](https://raw.githubusercontent.com/syuilo/ai/master/ai.svg).'; + const output = [ + LINK( + 'embed', + N_URL('https://raw.githubusercontent.com/syuilo/ai/master/ai.svg'), + [TEXT('image')] + ), + TEXT('.') + ]; + assert.deepStrictEqual(mfm.parse(input), output); + }); + + test('with angle brackets silent url', () => { + const input = '?[image]().'; + const output = [ + LINK( + 'silent', + N_URL('https://raw.githubusercontent.com/syuilo/ai/master/ai.svg', true), + [TEXT('image')] + ), + TEXT('.') + ]; + assert.deepStrictEqual(mfm.parse(input), output); + }); + + test('with angle brackets embed url', () => { + const input = '![image]().'; + const output = [ + LINK( + 'embed', + N_URL('https://raw.githubusercontent.com/syuilo/ai/master/ai.svg', true), + [TEXT('image')] + ), + TEXT('.') + ]; + assert.deepStrictEqual(mfm.parse(input), output); + }); + describe('cannot nest a url in a link label', () => { test('basic', () => { const input = 'official instance: [https://misskey.io/@ai](https://misskey.io/@ai).'; const output = [ TEXT('official instance: '), LINK( - false, + 'plain', N_URL('https://misskey.io/@ai'), [TEXT('https://misskey.io/@ai')] ), @@ -1100,12 +1139,13 @@ hoge`; ]; assert.deepStrictEqual(mfm.parse(input), output); }); + test('nested', () => { const input = 'official instance: [https://misskey.io/@ai**https://misskey.io/@ai**](https://misskey.io/@ai).'; const output = [ TEXT('official instance: '), LINK( - false, + 'plain', N_URL('https://misskey.io/@ai'), [ TEXT('https://misskey.io/@ai'), @@ -1126,7 +1166,7 @@ hoge`; const output = [ TEXT('official instance: '), LINK( - false, + 'plain', N_URL('https://misskey.io/@ai'), [TEXT('[https://misskey.io/@ai')] ), @@ -1136,12 +1176,13 @@ hoge`; ]; assert.deepStrictEqual(mfm.parse(input), output); }); + test('nested', () => { const input = 'official instance: [**[https://misskey.io/@ai](https://misskey.io/@ai)**](https://misskey.io/@ai).'; const output = [ TEXT('official instance: '), LINK( - false, + 'plain', N_URL('https://misskey.io/@ai'), [ BOLD([ @@ -1159,18 +1200,19 @@ hoge`; const input = '[@example](https://example.com)'; const output = [ LINK( - false, + 'plain', N_URL('https://example.com'), [TEXT('@example')] ), ]; assert.deepStrictEqual(mfm.parse(input), output); }); + test('nested', () => { const input = '[@example**@example**](https://example.com)'; const output = [ LINK( - false, + 'plain', N_URL('https://example.com'), [ TEXT('@example'), @@ -1188,7 +1230,7 @@ hoge`; const input = '[foo](https://example.com/foo(bar))'; const output = [ LINK( - false, + 'plain', N_URL('https://example.com/foo(bar)'), [TEXT('foo')] ), @@ -1201,7 +1243,7 @@ hoge`; const output = [ TEXT('('), LINK( - false, + 'plain', N_URL('https://example.com/foo(bar)'), [TEXT('foo')] ), @@ -1215,7 +1257,7 @@ hoge`; const output = [ TEXT('[test] foo '), LINK( - false, + 'plain', N_URL('https://example.com'), [TEXT('bar')] ),