From 79ffbf95db9d0cc019d06ab93b1bfa6ba0d4f9ae Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 21 Nov 2018 05:11:00 +0900 Subject: [PATCH] Improve MFM parser (#3337) * wip * wip * Refactor * Refactor * wip * wip * wip * wip * Refactor * Refactor * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Clean up * Update misskey-flavored-markdown.ts * wip * wip * wip * wip * Update parser.ts * wip * Add new test * wip * Add new test * Add new test * wip * Refactor * Update parse.ts * Refactor * Update parser.ts * wip --- package.json | 2 + src/client/app/boot.js | 1 + .../app/common/views/components/index.ts | 2 +- .../{misskey-flavored-markdown.ts => mfm.ts} | 120 ++-- .../components/misskey-flavored-markdown.vue | 57 ++ .../views/components/welcome-timeline.vue | 2 +- src/client/app/common/views/pages/follow.vue | 2 +- .../desktop/views/components/note-detail.vue | 2 +- .../app/desktop/views/components/note.vue | 45 +- .../views/components/sub-note-content.vue | 2 +- .../desktop/views/components/user-card.vue | 2 +- .../views/pages/deck/deck.user-column.vue | 2 +- .../desktop/views/pages/user/user.header.vue | 2 +- .../mobile/views/components/note-detail.vue | 2 +- .../app/mobile/views/components/note.vue | 44 +- .../views/components/sub-note-content.vue | 2 +- src/client/app/mobile/views/pages/user.vue | 2 +- src/client/app/test/script.ts | 23 + src/client/app/test/style.styl | 6 + src/client/app/test/views/index.vue | 34 ++ src/mfm/html.ts | 232 +++---- src/mfm/parse.ts | 81 +++ src/mfm/parse/elements/big.ts | 20 - src/mfm/parse/elements/bold.ts | 20 - src/mfm/parse/elements/code.ts | 24 - src/mfm/parse/elements/emoji.regex.ts | 2 - src/mfm/parse/elements/emoji.ts | 33 - src/mfm/parse/elements/hashtag.ts | 27 - src/mfm/parse/elements/inline-code.ts | 25 - src/mfm/parse/elements/link.ts | 27 - src/mfm/parse/elements/math.ts | 20 - src/mfm/parse/elements/mention.ts | 29 - src/mfm/parse/elements/motion.ts | 20 - src/mfm/parse/elements/quote.ts | 30 - src/mfm/parse/elements/search.ts | 19 - src/mfm/parse/elements/title.ts | 21 - src/mfm/parse/elements/url.ts | 23 - src/mfm/parse/index.ts | 100 --- src/mfm/parser.ts | 256 ++++++++ ...tax-highlighter.ts => syntax-highlight.ts} | 4 +- src/remote/activitypub/renderer/note.ts | 12 - src/services/note/create.ts | 63 +- test/mfm.ts | 570 ++++++++++++------ webpack.config.ts | 1 + 44 files changed, 1097 insertions(+), 916 deletions(-) rename src/client/app/common/views/components/{misskey-flavored-markdown.ts => mfm.ts} (59%) create mode 100644 src/client/app/common/views/components/misskey-flavored-markdown.vue create mode 100644 src/client/app/test/script.ts create mode 100644 src/client/app/test/style.styl create mode 100644 src/client/app/test/views/index.vue create mode 100644 src/mfm/parse.ts delete mode 100644 src/mfm/parse/elements/big.ts delete mode 100644 src/mfm/parse/elements/bold.ts delete mode 100644 src/mfm/parse/elements/code.ts delete mode 100644 src/mfm/parse/elements/emoji.regex.ts delete mode 100644 src/mfm/parse/elements/emoji.ts delete mode 100644 src/mfm/parse/elements/hashtag.ts delete mode 100644 src/mfm/parse/elements/inline-code.ts delete mode 100644 src/mfm/parse/elements/link.ts delete mode 100644 src/mfm/parse/elements/math.ts delete mode 100644 src/mfm/parse/elements/mention.ts delete mode 100644 src/mfm/parse/elements/motion.ts delete mode 100644 src/mfm/parse/elements/quote.ts delete mode 100644 src/mfm/parse/elements/search.ts delete mode 100644 src/mfm/parse/elements/title.ts delete mode 100644 src/mfm/parse/elements/url.ts delete mode 100644 src/mfm/parse/index.ts create mode 100644 src/mfm/parser.ts rename src/mfm/{parse/core/syntax-highlighter.ts => syntax-highlight.ts} (97%) diff --git a/package.json b/package.json index b1c1d01b77..a082898f7b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/ms": "0.7.30", "@types/node": "10.12.2", "@types/oauth": "0.9.1", + "@types/parsimmon": "1.10.0", "@types/portscanner": "2.1.0", "@types/pug": "2.0.4", "@types/qrcode": "1.3.0", @@ -170,6 +171,7 @@ "on-build-webpack": "0.1.0", "os-utils": "0.0.14", "parse5": "5.1.0", + "parsimmon": "1.12.0", "portscanner": "2.2.0", "postcss-loader": "3.0.0", "progress-bar-webpack-plugin": "1.11.0", diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 76ea41c649..5e894a18d7 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -41,6 +41,7 @@ if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev'; if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth'; if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin'; + if (`${url.pathname}/`.startsWith('/test/')) app = 'test'; //#endregion // Script version diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index 8569e2cf10..b8fc7c4096 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -17,7 +17,7 @@ import forkit from './forkit.vue'; import acct from './acct.vue'; import avatar from './avatar.vue'; import nav from './nav.vue'; -import misskeyFlavoredMarkdown from './misskey-flavored-markdown'; +import misskeyFlavoredMarkdown from './misskey-flavored-markdown.vue'; import poll from './poll.vue'; import pollEditor from './poll-editor.vue'; import reactionIcon from './reaction-icon.vue'; diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/mfm.ts similarity index 59% rename from src/client/app/common/views/components/misskey-flavored-markdown.ts rename to src/client/app/common/views/components/mfm.ts index 1eb738813e..b7ff5bd487 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/mfm.ts @@ -1,11 +1,39 @@ import Vue, { VNode } from 'vue'; import { length } from 'stringz'; +import { Node } from '../../../../../mfm/parser'; import parse from '../../../../../mfm/parse'; -import getAcct from '../../../../../misc/acct/render'; import MkUrl from './url.vue'; import { concat } from '../../../../../prelude/array'; import MkFormula from './formula.vue'; import MkGoogle from './google.vue'; +import { toUnicode } from 'punycode'; +import syntaxHighlight from '../../../../../mfm/syntax-highlight'; + +function getText(tokens: Node[]): string { + let text = ''; + const extract = (tokens: Node[]) => { + tokens.filter(x => x.name === 'text').forEach(x => { + text += x.props.text; + }); + tokens.filter(x => x.children).forEach(x => { + extract(x.children); + }); + }; + extract(tokens); + return text; +} + +function getChildrenCount(tokens: Node[]): number { + let count = 0; + const extract = (tokens: Node[]) => { + tokens.filter(x => x.children).forEach(x => { + count++; + extract(x.children); + }); + }; + extract(tokens); + return count; +} export default Vue.component('misskey-flavored-markdown', { props: { @@ -21,6 +49,10 @@ export default Vue.component('misskey-flavored-markdown', { type: Boolean, default: true }, + author: { + type: Object, + default: null + }, i: { type: Object, default: null @@ -31,23 +63,24 @@ export default Vue.component('misskey-flavored-markdown', { }, render(createElement) { - let ast: any[]; + if (this.text == null || this.text == '') return; + + let ast: Node[]; if (this.ast == null) { // Parse text to ast ast = parse(this.text); } else { - ast = this.ast as any[]; + ast = this.ast as Node[]; } let bigCount = 0; let motionCount = 0; - // Parse ast to DOM - const els = concat(ast.map((token): VNode[] => { - switch (token.type) { + const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => { + switch (token.name) { case 'text': { - const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); if (this.shouldBreak) { const x = text.split('\n') @@ -60,12 +93,12 @@ export default Vue.component('misskey-flavored-markdown', { } case 'bold': { - return [createElement('b', token.bold)]; + return [createElement('b', genEl(token.children))]; } case 'big': { bigCount++; - const isLong = length(token.big) > 10; + const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5; const isMany = bigCount > 3; return (createElement as any)('strong', { attrs: { @@ -75,12 +108,12 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'tada', iteration: 'infinite' } }] - }, token.big); + }, genEl(token.children)); } case 'motion': { motionCount++; - const isLong = length(token.motion) > 10; + const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5; const isMany = motionCount > 3; return (createElement as any)('span', { attrs: { @@ -90,13 +123,14 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'rubberBand', iteration: 'infinite' } }] - }, token.motion); + }, genEl(token.children)); } case 'url': { return [createElement(MkUrl, { + key: Math.random(), props: { - url: token.content, + url: token.props.url, target: '_blank', style: 'color:var(--mfmLink);' } @@ -107,75 +141,75 @@ export default Vue.component('misskey-flavored-markdown', { return [createElement('a', { attrs: { class: 'link', - href: token.url, + href: token.props.url, target: '_blank', - title: token.url, + title: token.props.url, style: 'color:var(--mfmLink);' } - }, token.title)]; + }, genEl(token.children))]; } case 'mention': { + const host = token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host; + const canonical = host != null ? `@${token.props.username}@${toUnicode(host)}` : `@${token.props.username}`; return (createElement as any)('router-link', { + key: Math.random(), attrs: { - to: `/${token.canonical}`, - dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), + to: `/${canonical}`, + // TODO + //dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), style: 'color:var(--mfmMention);' }, directives: [{ name: 'user-preview', - value: token.canonical + value: canonical }] - }, token.canonical); + }, canonical); } case 'hashtag': { return [createElement('router-link', { + key: Math.random(), attrs: { - to: `/tags/${encodeURIComponent(token.hashtag)}`, + to: `/tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--mfmHashtag);' } - }, token.content)]; + }, `#${token.props.hashtag}`)]; } - case 'code': { + case 'blockCode': { return [createElement('pre', { class: 'code' }, [ createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.props.code) } }) ])]; } - case 'inline-code': { + case 'inlineCode': { return [createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.props.code) } })]; } case 'quote': { - const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); - if (this.shouldBreak) { - const x = text2.split('\n') - .map(t => [createElement('span', t), createElement('br')]); - x[x.length - 1].pop(); return [createElement('div', { attrs: { class: 'quote' } - }, x)]; + }, genEl(token.children))]; } else { return [createElement('span', { attrs: { class: 'quote' } - }, text2.replace(/\n/g, ' '))]; + }, genEl(token.children))]; } } @@ -184,15 +218,16 @@ export default Vue.component('misskey-flavored-markdown', { attrs: { class: 'title' } - }, token.title)]; + }, genEl(token.children))]; } case 'emoji': { const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; return [createElement('mk-emoji', { + key: Math.random(), attrs: { - emoji: token.emoji, - name: token.name + emoji: token.props.emoji, + name: token.props.name }, props: { customEmojis: this.customEmojis || customEmojis @@ -203,8 +238,9 @@ export default Vue.component('misskey-flavored-markdown', { case 'math': { //const MkFormula = () => import('./formula.vue').then(m => m.default); return [createElement(MkFormula, { + key: Math.random(), props: { - formula: token.formula + formula: token.props.formula } })]; } @@ -212,22 +248,22 @@ export default Vue.component('misskey-flavored-markdown', { case 'search': { //const MkGoogle = () => import('./google.vue').then(m => m.default); return [createElement(MkGoogle, { + key: Math.random(), props: { - q: token.query + q: token.props.query } })]; } default: { - console.log('unknown ast type:', token.type); + console.log('unknown ast type:', token.name); return []; } } })); - // el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない - const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag))); - return createElement('span', _els); + // Parse ast to DOM + return createElement('span', genEl(ast)); } }); diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..b54f376935 --- /dev/null +++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index cad09a11a6..d075f06934 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -14,7 +14,7 @@
- +
diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index 9db53fdf8a..72b0b73e01 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -9,7 +9,7 @@ {{ user | userName }} @{{ user | acct }}
- +
diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index 88108d961f..37c4093355 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -46,7 +46,7 @@
{{ $t('private') }} {{ $t('deleted') }} - +
diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue index 26615b16a6..025d489c09 100644 --- a/src/client/app/desktop/views/components/note.vue +++ b/src/client/app/desktop/views/components/note.vue @@ -27,7 +27,7 @@
{{ $t('private') }} - + RN:
@@ -223,24 +223,6 @@ export default Vue.extend({ overflow-wrap break-word color var(--noteText) - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - > .reply margin-right 8px color var(--text) @@ -322,28 +304,3 @@ export default Vue.extend({ opacity 0.7 - - diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue index 0007520e99..2a407bdcab 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -4,7 +4,7 @@ {{ $t('private') }} {{ $t('deleted') }} - + RN: ...
diff --git a/src/client/app/desktop/views/components/user-card.vue b/src/client/app/desktop/views/components/user-card.vue index 54fa15a190..c5d925fe6d 100644 --- a/src/client/app/desktop/views/components/user-card.vue +++ b/src/client/app/desktop/views/components/user-card.vue @@ -7,7 +7,7 @@ {{ user | userName }} @{{ user | acct }}
- +
diff --git a/src/client/app/desktop/views/pages/deck/deck.user-column.vue b/src/client/app/desktop/views/pages/deck/deck.user-column.vue index 90f7e2aaaa..937166cec1 100644 --- a/src/client/app/desktop/views/pages/deck/deck.user-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.user-column.vue @@ -22,7 +22,7 @@
- +
diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue index 48b5a487f4..9eacbe3914 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -14,7 +14,7 @@
- +
{{ user.profile.location }} diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue index f24cc0916f..61968a64d1 100644 --- a/src/client/app/mobile/views/components/note-detail.vue +++ b/src/client/app/mobile/views/components/note-detail.vue @@ -33,7 +33,7 @@
({{ $t('private') }}) ({{ $t('deleted') }}) - +
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index 42fb7118f8..5cfcdc0f3b 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -23,7 +23,7 @@
({{ $t('private') }}) - + RN:
@@ -188,24 +188,6 @@ export default Vue.extend({ overflow-wrap break-word color var(--noteText) - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - > .reply margin-right 8px color var(--noteText) @@ -215,15 +197,6 @@ export default Vue.extend({ font-style oblique color var(--renoteText) - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color var(--primaryForeground) - background var(--primary) - border-radius 4px - .mk-url-preview margin-top 8px @@ -289,18 +262,3 @@ export default Vue.extend({ opacity 0.7 - - diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue index f4c86f19d2..715ddd6527 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -4,7 +4,7 @@ ({{ $t('private') }}) ({{ $t('deleted') }}) - + RN: ...
diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index b7f0db6eb9..1f0551680e 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -20,7 +20,7 @@ {{ $t('follows-you') }}
- +

diff --git a/src/client/app/test/script.ts b/src/client/app/test/script.ts new file mode 100644 index 0000000000..5818cf2913 --- /dev/null +++ b/src/client/app/test/script.ts @@ -0,0 +1,23 @@ +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; + +import init from '../init'; +import Index from './views/index.vue'; + +init(launch => { + document.title = 'Misskey'; + + // Init router + const router = new VueRouter({ + mode: 'history', + base: '/test/', + routes: [ + { path: '/', component: Index }, + ] + }); + + // Launch the app + launch(router); +}); diff --git a/src/client/app/test/style.styl b/src/client/app/test/style.styl new file mode 100644 index 0000000000..ae1a28226a --- /dev/null +++ b/src/client/app/test/style.styl @@ -0,0 +1,6 @@ +@import "../app" +@import "../reset" + +html + height 100% + background var(--bg) diff --git a/src/client/app/test/views/index.vue b/src/client/app/test/views/index.vue new file mode 100644 index 0000000000..b1947ffa4a --- /dev/null +++ b/src/client/app/test/views/index.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/mfm/html.ts b/src/mfm/html.ts index cb7c7e2855..d45cc13af4 100644 --- a/src/mfm/html.ts +++ b/src/mfm/html.ts @@ -1,127 +1,135 @@ -const { lib: emojilib } = require('emojilib'); const jsdom = require('jsdom'); const { JSDOM } = jsdom; import config from '../config'; import { INote } from '../models/note'; -import { TextElement } from './parse'; +import { Node } from './parser'; import { intersperse } from '../prelude/array'; -const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = { - bold({ document }, { bold }) { - const b = document.createElement('b'); - b.textContent = bold; - document.body.appendChild(b); - }, - - big({ document }, { big }) { - const b = document.createElement('strong'); - b.textContent = big; - document.body.appendChild(b); - }, - - motion({ document }, { big }) { - const b = document.createElement('strong'); - b.textContent = big; - document.body.appendChild(b); - }, - - code({ document }, { code }) { - const pre = document.createElement('pre'); - const inner = document.createElement('code'); - inner.innerHTML = code; - pre.appendChild(inner); - document.body.appendChild(pre); - }, - - emoji({ document }, { content, emoji }) { - const found = emojilib[emoji]; - const node = document.createTextNode(found ? found.char : content); - document.body.appendChild(node); - }, - - hashtag({ document }, { hashtag }) { - const a = document.createElement('a'); - a.href = `${config.url}/tags/${hashtag}`; - a.textContent = `#${hashtag}`; - a.setAttribute('rel', 'tag'); - document.body.appendChild(a); - }, - - 'inline-code'({ document }, { code }) { - const element = document.createElement('code'); - element.textContent = code; - document.body.appendChild(element); - }, - - math({ document }, { formula }) { - const element = document.createElement('code'); - element.textContent = formula; - document.body.appendChild(element); - }, - - link({ document }, { url, title }) { - const a = document.createElement('a'); - a.href = url; - a.textContent = title; - document.body.appendChild(a); - }, - - mention({ document }, { content, username, host }, mentionedRemoteUsers) { - const a = document.createElement('a'); - const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${content}`; - a.textContent = content; - document.body.appendChild(a); - }, - - quote({ document }, { quote }) { - const blockquote = document.createElement('blockquote'); - blockquote.textContent = quote; - document.body.appendChild(blockquote); - }, - - title({ document }, { content }) { - const h1 = document.createElement('h1'); - h1.textContent = content; - document.body.appendChild(h1); - }, - - text({ document }, { content }) { - const nodes = (content as string).split('\n').map(x => document.createTextNode(x)); - for (const x of intersperse('br', nodes)) { - if (x === 'br') { - document.body.appendChild(document.createElement('br')); - } else { - document.body.appendChild(x); - } - } - }, - - url({ document }, { url }) { - const a = document.createElement('a'); - a.href = url; - a.textContent = url; - document.body.appendChild(a); - }, - - search({ document }, { content, query }) { - const a = document.createElement('a'); - a.href = `https://www.google.com/?#q=${query}`; - a.textContent = content; - document.body.appendChild(a); - } -}; - -export default (tokens: TextElement[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => { +export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => { if (tokens == null) { return null; } const { window } = new JSDOM(''); - for (const token of tokens) { - handlers[token.type](window, token, mentionedRemoteUsers); + const doc = window.document; + + function dive(nodes: Node[]): any[] { + return nodes.map(n => handlers[n.name](n)); } - return `

${window.document.body.innerHTML}

`; + const handlers: { [key: string]: (token: Node) => any } = { + bold(token) { + const el = doc.createElement('b'); + dive(token.children).forEach(child => el.appendChild(child)); + return el; + }, + + big(token) { + const el = doc.createElement('strong'); + dive(token.children).forEach(child => el.appendChild(child)); + return el; + }, + + motion(token) { + const el = doc.createElement('i'); + dive(token.children).forEach(child => el.appendChild(child)); + return el; + }, + + blockCode(token) { + const pre = doc.createElement('pre'); + const inner = doc.createElement('code'); + inner.innerHTML = token.props.code; + pre.appendChild(inner); + return pre; + }, + + emoji(token) { + return doc.createTextNode(token.props.emoji ? token.props.emoji : `:${token.props.name}:`); + }, + + hashtag(token) { + const a = doc.createElement('a'); + a.href = `${config.url}/tags/${token.props.hashtag}`; + a.textContent = `#${token.props.hashtag}`; + a.setAttribute('rel', 'tag'); + return a; + }, + + inlineCode(token) { + const el = doc.createElement('code'); + el.textContent = token.props.code; + return el; + }, + + math(token) { + const el = doc.createElement('code'); + el.textContent = token.props.formula; + return el; + }, + + link(token) { + const a = doc.createElement('a'); + a.href = token.props.url; + dive(token.children).forEach(child => a.appendChild(child)); + return a; + }, + + mention(token) { + const a = doc.createElement('a'); + const { username, host, acct } = token.props; + const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); + a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${acct}`; + a.textContent = acct; + return a; + }, + + quote(token) { + const el = doc.createElement('blockquote'); + dive(token.children).forEach(child => el.appendChild(child)); + return el; + }, + + title(token) { + const el = doc.createElement('h1'); + dive(token.children).forEach(child => el.appendChild(child)); + return el; + }, + + text(token) { + const el = doc.createElement('span'); + const nodes = (token.props.text as string).split('\n').map(x => doc.createTextNode(x)); + + for (const x of intersperse('br', nodes)) { + if (x === 'br') { + el.appendChild(doc.createElement('br')); + } else { + el.appendChild(x); + } + } + + return el; + }, + + url(token) { + const a = doc.createElement('a'); + a.href = token.props.url; + a.textContent = token.props.url; + return a; + }, + + search(token) { + const a = doc.createElement('a'); + a.href = `https://www.google.com/?#q=${token.props.query}`; + a.textContent = token.props.content; + return a; + } + }; + + dive(tokens).forEach(x => { + doc.body.appendChild(x); + }); + + return `

${doc.body.innerHTML}

`; }; diff --git a/src/mfm/parse.ts b/src/mfm/parse.ts new file mode 100644 index 0000000000..e7ae3d012e --- /dev/null +++ b/src/mfm/parse.ts @@ -0,0 +1,81 @@ +import parser, { Node } from './parser'; +import * as A from '../prelude/array'; +import * as S from '../prelude/string'; + +export default (source: string): Node[] => { + if (source == null || source == '') { + return null; + } + + let nodes: Node[] = parser.root.tryParse(source); + + const combineText = (es: Node[]): Node => + ({ name: 'text', props: { text: S.concat(es.map(e => e.props.text)) } }); + + const concatText = (nodes: Node[]): Node[] => + A.concat(A.groupOn(x => x.name, nodes).map(es => + es[0].name === 'text' ? [combineText(es)] : es + )); + + const concatTextRecursive = (es: Node[]): void => + es.filter(x => x.children).forEach(x => { + x.children = concatText(x.children); + concatTextRecursive(x.children); + }); + + nodes = concatText(nodes); + concatTextRecursive(nodes); + + function getBeforeTextNode(node: Node): Node { + if (node == null) return null; + if (node.name == 'text') return node; + if (node.children) return getBeforeTextNode(node.children[node.children.length - 1]); + return null; + } + + function getAfterTextNode(node: Node): Node { + if (node == null) return null; + if (node.name == 'text') return node; + if (node.children) return getBeforeTextNode(node.children[0]); + return null; + } + + function isBlockNode(node: Node): boolean { + return ['blockCode', 'quote', 'title'].includes(node.name); + } + + /** + * ブロック要素の前後にある改行を削除します(ブロック要素自体が改行の役割も果たすため、余計に改行されてしまうため) + * @param nodes + */ + const removeNeedlessLineBreaks = (nodes: Node[]) => { + nodes.forEach((node, i) => { + if (node.children) removeNeedlessLineBreaks(node.children); + if (isBlockNode(node)) { + const before = getBeforeTextNode(nodes[i - 1]); + const after = getAfterTextNode(nodes[i + 1]); + if (before && before.props.text.endsWith('\n')) { + before.props.text = before.props.text.substring(0, before.props.text.length - 1); + } + if (after && after.props.text.startsWith('\n')) { + after.props.text = after.props.text.substring(1); + } + } + }); + }; + + const removeEmptyTextNodes = (nodes: Node[]) => { + nodes.forEach(n => { + if (n.children) { + n.children = removeEmptyTextNodes(n.children); + } + }); + return nodes.filter(n => !(n.name == 'text' && n.props.text == '')); + }; + + removeNeedlessLineBreaks(nodes); + + nodes = removeEmptyTextNodes(nodes); + + return nodes; +}; diff --git a/src/mfm/parse/elements/big.ts b/src/mfm/parse/elements/big.ts deleted file mode 100644 index 24e8bad50e..0000000000 --- a/src/mfm/parse/elements/big.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Big - */ - -export type TextElementBig = { - type: 'big'; - content: string; - big: string; -}; - -export default function(text: string) { - const match = text.match(/^\*\*\*(.+?)\*\*\*/); - if (!match) return null; - const big = match[0]; - return { - type: 'big', - content: big, - big: match[1] - } as TextElementBig; -} diff --git a/src/mfm/parse/elements/bold.ts b/src/mfm/parse/elements/bold.ts deleted file mode 100644 index 42c9cf0e1e..0000000000 --- a/src/mfm/parse/elements/bold.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Bold - */ - -export type TextElementBold = { - type: 'bold'; - content: string; - bold: string; -}; - -export default function(text: string) { - const match = text.match(/^\*\*(.+?)\*\*/); - if (!match) return null; - const bold = match[0]; - return { - type: 'bold', - content: bold, - bold: match[1] - } as TextElementBold; -} diff --git a/src/mfm/parse/elements/code.ts b/src/mfm/parse/elements/code.ts deleted file mode 100644 index 63a535fc55..0000000000 --- a/src/mfm/parse/elements/code.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Code (block) - */ - -import genHtml from '../core/syntax-highlighter'; - -export type TextElementCode = { - type: 'code'; - content: string; - code: string; - html: string; -}; - -export default function(text: string) { - const match = text.match(/^```([\s\S]+?)```/); - if (!match) return null; - const code = match[0]; - return { - type: 'code', - content: code, - code: match[1], - html: genHtml(match[1].trim()) - } as TextElementCode; -} diff --git a/src/mfm/parse/elements/emoji.regex.ts b/src/mfm/parse/elements/emoji.regex.ts deleted file mode 100644 index a5c6b71825..0000000000 --- a/src/mfm/parse/elements/emoji.regex.ts +++ /dev/null @@ -1,2 +0,0 @@ -// https://github.com/twitter/twemoji/blob/fc458b467c1bd706acd8653028ee8ab3e6562ce3/2/scripts/regex -export const emojiRegex = /^((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/; diff --git a/src/mfm/parse/elements/emoji.ts b/src/mfm/parse/elements/emoji.ts deleted file mode 100644 index 6c09ddf5c0..0000000000 --- a/src/mfm/parse/elements/emoji.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Emoji - */ - -import { emojiRegex } from "./emoji.regex"; - -export type TextElementEmoji = { - type: 'emoji'; - content: string; - emoji?: string; - name?: string; -}; - -export default function(text: string) { - const name = text.match(/^:([a-zA-Z0-9+_-]+):/); - if (name) { - return { - type: 'emoji', - content: name[0], - name: name[1] - } as TextElementEmoji; - } - const unicode = text.match(emojiRegex); - if (unicode) { - const [content] = unicode; - return { - type: 'emoji', - content, - emoji: content - } as TextElementEmoji; - } - return null; -} diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts deleted file mode 100644 index df07de6645..0000000000 --- a/src/mfm/parse/elements/hashtag.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Hashtag - */ - -export type TextElementHashtag = { - type: 'hashtag'; - content: string; - hashtag: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - if (!(/^\s#[^\s\.,!\?#]+/.test(text) || (isBegin && /^#[^\s\.,!\?#]+/.test(text)))) return null; - const isHead = text.startsWith('#'); - const hashtag = text.match(/^\s?#[^\s\.,!\?#]+/)[0]; - const res: any[] = !isHead ? [{ - type: 'text', - content: text[0] - }] : []; - res.push({ - type: 'hashtag', - content: isHead ? hashtag : hashtag.substr(1), - hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) - }); - return res as TextElementHashtag[]; -} diff --git a/src/mfm/parse/elements/inline-code.ts b/src/mfm/parse/elements/inline-code.ts deleted file mode 100644 index efacd734cb..0000000000 --- a/src/mfm/parse/elements/inline-code.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Code (inline) - */ - -import genHtml from '../core/syntax-highlighter'; - -export type TextElementInlineCode = { - type: 'inline-code'; - content: string; - code: string; - html: string; -}; - -export default function(text: string) { - const match = text.match(/^`(.+?)`/); - if (!match) return null; - if (match[1].includes('´')) return null; - const code = match[0]; - return { - type: 'inline-code', - content: code, - code: match[1], - html: genHtml(match[1]) - } as TextElementInlineCode; -} diff --git a/src/mfm/parse/elements/link.ts b/src/mfm/parse/elements/link.ts deleted file mode 100644 index 972fce3810..0000000000 --- a/src/mfm/parse/elements/link.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Link - */ - -export type TextElementLink = { - type: 'link'; - content: string; - title: string; - url: string; - silent: boolean; -}; - -export default function(text: string) { - const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/); - if (!match) return null; - const silent = text.startsWith('?'); - const link = match[0]; - const title = match[1]; - const url = match[2]; - return { - type: 'link', - content: link, - title: title, - url: url, - silent: silent - } as TextElementLink; -} diff --git a/src/mfm/parse/elements/math.ts b/src/mfm/parse/elements/math.ts deleted file mode 100644 index f2b6c5f479..0000000000 --- a/src/mfm/parse/elements/math.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Math - */ - -export type TextElementMath = { - type: 'math'; - content: string; - formula: string; -}; - -export default function(text: string) { - const match = text.match(/^\\\((.+?)\\\)/); - if (!match) return null; - const math = match[0]; - return { - type: 'math', - content: math, - formula: match[1] - } as TextElementMath; -} diff --git a/src/mfm/parse/elements/mention.ts b/src/mfm/parse/elements/mention.ts deleted file mode 100644 index 7a609e5d34..0000000000 --- a/src/mfm/parse/elements/mention.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Mention - */ -import parseAcct from '../../../misc/acct/parse'; -import { toUnicode } from 'punycode'; - -export type TextElementMention = { - type: 'mention'; - content: string; - canonical: string; - username: string; - host: string; -}; - -export default function(text: string, before: string) { - const match = text.match(/^@[a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9])?/i); - if (!match) return null; - if (/[a-zA-Z0-9]$/.test(before)) return null; - const mention = match[0]; - const { username, host } = parseAcct(mention.substr(1)); - const canonical = host != null ? `@${username}@${toUnicode(host)}` : mention; - return { - type: 'mention', - content: mention, - canonical, - username, - host - } as TextElementMention; -} diff --git a/src/mfm/parse/elements/motion.ts b/src/mfm/parse/elements/motion.ts deleted file mode 100644 index c6500e7be0..0000000000 --- a/src/mfm/parse/elements/motion.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Motion - */ - -export type TextElementMotion = { - type: 'motion'; - content: string; - motion: string; -}; - -export default function(text: string) { - const match = text.match(/^\(\(\((.+?)\)\)\)/) || text.match(/^(.+?)<\/motion>/); - if (!match) return null; - const motion = match[0]; - return { - type: 'motion', - content: motion, - motion: match[1] - } as TextElementMotion; -} diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts deleted file mode 100644 index 969c1fb4a9..0000000000 --- a/src/mfm/parse/elements/quote.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Quoted text - */ - -export type TextElementQuote = { - type: 'quote'; - content: string; - quote: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) || - (isBegin ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null); - - if (!match) return null; - - const quote = match[1] - .split('\n') - .map(line => line.replace(/^>+/g, '').trim()) - .join('\n') - .trim(); - - return { - type: 'quote', - content: match[0], - quote: quote, - } as TextElementQuote; -} diff --git a/src/mfm/parse/elements/search.ts b/src/mfm/parse/elements/search.ts deleted file mode 100644 index f51844b079..0000000000 --- a/src/mfm/parse/elements/search.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Search - */ - -export type TextElementSearch = { - type: 'search'; - content: string; - query: string; -}; - -export default function(text: string) { - const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i); - if (!match) return null; - return { - type: 'search', - content: match[0], - query: match[1] - }; -} diff --git a/src/mfm/parse/elements/title.ts b/src/mfm/parse/elements/title.ts deleted file mode 100644 index a9922c8aca..0000000000 --- a/src/mfm/parse/elements/title.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Title - */ - -export type TextElementTitle = { - type: 'title'; - content: string; - title: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - const match = isBegin ? text.match(/^(【|\[)(.+?)(】|])\n/) : text.match(/^\n(【|\[)(.+?)(】|])\n/); - if (!match) return null; - return { - type: 'title', - content: match[0], - title: match[2] - } as TextElementTitle; -} diff --git a/src/mfm/parse/elements/url.ts b/src/mfm/parse/elements/url.ts deleted file mode 100644 index a16f67f2c2..0000000000 --- a/src/mfm/parse/elements/url.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * URL - */ - -export type TextElementUrl = { - type: 'url'; - content: string; - url: string; -}; - -export default function(text: string, before: string) { - const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/); - if (!match) return null; - let url = match[0]; - if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.')); - if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(',')); - if (url.endsWith(')') && before.endsWith('(')) url = url.substr(0, url.lastIndexOf(')')); - return { - type: 'url', - content: url, - url: url - } as TextElementUrl; -} diff --git a/src/mfm/parse/index.ts b/src/mfm/parse/index.ts deleted file mode 100644 index 7697bb6e36..0000000000 --- a/src/mfm/parse/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Misskey Text Analyzer - */ - -import { TextElementBold } from './elements/bold'; -import { TextElementBig } from './elements/big'; -import { TextElementCode } from './elements/code'; -import { TextElementEmoji } from './elements/emoji'; -import { TextElementHashtag } from './elements/hashtag'; -import { TextElementInlineCode } from './elements/inline-code'; -import { TextElementMath } from './elements/math'; -import { TextElementLink } from './elements/link'; -import { TextElementMention } from './elements/mention'; -import { TextElementQuote } from './elements/quote'; -import { TextElementSearch } from './elements/search'; -import { TextElementTitle } from './elements/title'; -import { TextElementUrl } from './elements/url'; -import { TextElementMotion } from './elements/motion'; -import { groupOn } from '../../prelude/array'; -import * as A from '../../prelude/array'; -import * as S from '../../prelude/string'; - -const elements = [ - require('./elements/big'), - require('./elements/bold'), - require('./elements/title'), - require('./elements/url'), - require('./elements/link'), - require('./elements/mention'), - require('./elements/hashtag'), - require('./elements/code'), - require('./elements/inline-code'), - require('./elements/math'), - require('./elements/quote'), - require('./elements/emoji'), - require('./elements/search'), - require('./elements/motion') -].map(element => element.default as TextElementProcessor); - -export type TextElement = { type: 'text', content: string } - | TextElementBold - | TextElementBig - | TextElementCode - | TextElementEmoji - | TextElementHashtag - | TextElementInlineCode - | TextElementMath - | TextElementLink - | TextElementMention - | TextElementQuote - | TextElementSearch - | TextElementTitle - | TextElementUrl - | TextElementMotion; -export type TextElementProcessor = (text: string, before: string) => TextElement | TextElement[]; - -export default (source: string): TextElement[] => { - if (source == null || source == '') { - return null; - } - - const tokens: TextElement[] = []; - - function push(token: TextElement) { - if (token != null) { - tokens.push(token); - source = source.substr(token.content.length); - } - } - - // パース - while (source != '') { - const parsed = elements.some(el => { - let _tokens = el(source, tokens.map(token => token.content).join('')); - if (_tokens) { - if (!Array.isArray(_tokens)) { - _tokens = [_tokens]; - } - _tokens.forEach(push); - return true; - } else { - return false; - } - }); - - if (!parsed) { - push({ - type: 'text', - content: source[0] - }); - } - } - - const combineText = (es: TextElement[]): TextElement => - ({ type: 'text', content: S.concat(es.map(e => e.content)) }); - - return A.concat(groupOn(x => x.type, tokens).map(es => - es[0].type === 'text' ? [combineText(es)] : es - )); -}; diff --git a/src/mfm/parser.ts b/src/mfm/parser.ts new file mode 100644 index 0000000000..5f89696c20 --- /dev/null +++ b/src/mfm/parser.ts @@ -0,0 +1,256 @@ +import * as P from 'parsimmon'; +import parseAcct from '../misc/acct/parse'; +import { toUnicode } from 'punycode'; + +const emojiRegex = /((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/; + +export type Node = { + name: string, + children?: Node[], + props?: any; +}; + +function _makeNode(name: string, children?: Node[], props?: any): Node { + return children ? { + name, + children, + props + } : { + name, + props + }; +} + +function makeNode(name: string, props?: any): Node { + return _makeNode(name, null, props); +} + +function makeNodeWithChildren(name: string, children: Node[], props?: any): Node { + return _makeNode(name, children, props); +} + +const newline = P((input, i) => { + if (i == 0 || input[i] == '\n' || input[i - 1] == '\n') { + return P.makeSuccess(i, null); + } else { + return P.makeFailure(i, 'not newline'); + } +}); + +const mfm = P.createLanguage({ + root: r => P.alt( + r.big, + r.bold, + r.motion, + r.url, + r.link, + r.mention, + r.hashtag, + r.emoji, + r.blockCode, + r.inlineCode, + r.quote, + r.math, + r.search, + r.title, + r.text + ).atLeast(1), + + text: () => P.any.map(x => makeNode('text', { text: x })), + + //#region Big + big: r => + P.regexp(/^\*\*\*([\s\S]+?)\*\*\*/, 1) + .map(x => makeNodeWithChildren('big', P.alt( + r.mention, + r.emoji, + r.text + ).atLeast(1).tryParse(x))), + //#endregion + + //#region Block code + blockCode: r => + newline.then( + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^```(.+?)?\n([\s\S]+?)\n```(\n|$)/i); + if (!match) return P.makeFailure(i, 'not a blockCode'); + return P.makeSuccess(i + match[0].length, makeNode('blockCode', { code: match[2], lang: match[1] ? match[1].trim() : null })); + }) + ), + //#endregion + + //#region Bold + bold: r => + P.regexp(/\*\*([\s\S]+?)\*\*/, 1) + .map(x => makeNodeWithChildren('bold', P.alt( + r.mention, + r.emoji, + r.text + ).atLeast(1).tryParse(x))), + //#endregion + + //#region Emoji + emoji: r => + P.alt( + P.regexp(/:([a-z0-9_+-]+):/i, 1) + .map(x => makeNode('emoji', { + name: x + })), + P.regexp(emojiRegex) + .map(x => makeNode('emoji', { + emoji: x + })), + ), + //#endregion + + //#region Hashtag + hashtag: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^#([^\s\.,!\?#]+)/i); + if (!match) return P.makeFailure(i, 'not a hashtag'); + if (input[i - 1] != ' ' && input[i - 1] != null) return P.makeFailure(i, 'require space before "#"'); + return P.makeSuccess(i + match[0].length, makeNode('hashtag', { hashtag: match[1] })); + }), + //#endregion + + //#region Inline code + inlineCode: r => + P.regexp(/`(.+?)`/, 1) + .map(x => makeNode('inlineCode', { code: x })), + //#endregion + + //#region Link + link: r => + P.seqObj( + ['silent', P.string('?').fallback(null).map(x => x != null)] as any, + P.string('['), + ['text', P.regexp(/[^\n\[\]]+/)] as any, + P.string(']'), + P.string('('), + ['url', r.url] as any, + P.string(')'), + ) + .map((x: any) => { + return makeNodeWithChildren('link', P.alt( + r.big, + r.bold, + r.motion, + r.emoji, + r.text + ).atLeast(1).tryParse(x.text), { + silent: x.silent, + url: x.url.props.url + }); + }), + //#endregion + + //#region Math + math: r => + P.regexp(/\\\((.+?)\\\)/, 1) + .map(x => makeNode('math', { formula: x })), + //#endregion + + //#region Mention + mention: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^@[a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9])?/i); + if (!match) return P.makeFailure(i, 'not a mention'); + if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a mention'); + return P.makeSuccess(i + match[0].length, match[0]); + }) + .map(x => { + const { username, host } = parseAcct(x.substr(1)); + const canonical = host != null ? `@${username}@${toUnicode(host)}` : x; + return makeNode('mention', { + canonical, username, host, acct: x + }); + }), + //#endregion + + //#region Motion + motion: r => + P.alt(P.regexp(/\(\(\(([\s\S]+?)\)\)\)/, 1), P.regexp(/(.+?)<\/motion>/, 1)) + .map(x => makeNodeWithChildren('motion', P.alt( + r.bold, + r.mention, + r.emoji, + r.text + ).atLeast(1).tryParse(x))), + //#endregion + + //#region Quote + quote: r => + newline.then(P((input, i) => { + const text = input.substr(i); + if (!text.match(/^>[\s\S]+?/)) return P.makeFailure(i, 'not a quote'); + const quote: string[] = []; + text.split('\n').some(line => { + if (line.startsWith('>')) { + quote.push(line); + return false; + } else { + return true; + } + }); + const qInner = quote.join('\n').replace(/^>/gm, '').replace(/^ /gm, ''); + if (qInner == '') return P.makeFailure(i, 'not a quote'); + const contents = r.root.tryParse(qInner); + return P.makeSuccess(i + quote.join('\n').length, makeNodeWithChildren('quote', contents)); + })), + //#endregion + + //#region Search + search: r => + newline.then(P((input, i) => { + const text = input.substr(i); + const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i); + if (!match) return P.makeFailure(i, 'not a search'); + return P.makeSuccess(i + match[0].length, makeNode('search', { query: match[1], content: match[0].trim() })); + })), + //#endregion + + //#region Title + title: r => + newline.then(P((input, i) => { + const text = input.substr(i); + const match = text.match(/^((【|\[)(.+?)(】|]))(\n|$)/); + if (!match) return P.makeFailure(i, 'not a title'); + const q = match[1].trim().substring(1, match[1].length - 1); + const contents = P.alt( + r.big, + r.bold, + r.motion, + r.url, + r.link, + r.mention, + r.hashtag, + r.emoji, + r.inlineCode, + r.text + ).atLeast(1).tryParse(q); + return P.makeSuccess(i + match[0].length, makeNodeWithChildren('title', contents)); + })), + //#endregion + + //#region URL + url: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/); + if (!match) return P.makeFailure(i, 'not a url'); + let url = match[0]; + const before = input[i - 1]; + if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.')); + if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(',')); + if (url.endsWith(')') && before == '(') url = url.substr(0, url.lastIndexOf(')')); + if (url.endsWith(']') && before == '[') url = url.substr(0, url.lastIndexOf(']')); + return P.makeSuccess(i + url.length, url); + }) + .map(x => makeNode('url', { url: x })), + //#endregion +}); + +export default mfm; diff --git a/src/mfm/parse/core/syntax-highlighter.ts b/src/mfm/syntax-highlight.ts similarity index 97% rename from src/mfm/parse/core/syntax-highlighter.ts rename to src/mfm/syntax-highlight.ts index 83aac89f1b..3a2b90588b 100644 --- a/src/mfm/parse/core/syntax-highlighter.ts +++ b/src/mfm/syntax-highlight.ts @@ -1,4 +1,4 @@ -import { capitalize, toUpperCase } from "../../../prelude/string"; +import { capitalize, toUpperCase } from "../prelude/string"; function escape(text: string) { return text @@ -308,7 +308,7 @@ const elements: Element[] = [ ]; // specify lang is todo -export default (source: string, lang?: string) => { +export default (source: string, lang?: string): string => { let code = source; let html = ''; diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index ec66fe41ff..d6e2d612c7 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -7,7 +7,6 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file'; import Note, { INote } from '../../../models/note'; import User from '../../../models/user'; import toHtml from '../misc/get-note-html'; -import parseMfm from '../../../mfm/parse'; import Emoji, { IEmoji } from '../../../models/emoji'; export default async function renderNote(note: INote, dive = true): Promise { @@ -95,17 +94,6 @@ export default async function renderNote(note: INote, dive = true): Promise text += `\n\nRE: ${url}`; } - // 省略されたメンションのホストを復元する - if (text != null && text != '') { - text = parseMfm(text).map(x => { - if (x.type == 'mention' && x.host == null) { - return `${x.content}@${config.host}`; - } else { - return x.content; - } - }).join(''); - } - const content = toHtml(Object.assign({}, note, { text })); const emojis = await getEmojis(note.emojis); diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 0fd983d6c2..b512fe2dda 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -21,8 +21,6 @@ import Meta from '../../models/meta'; import config from '../../config'; import registerHashtag from '../register-hashtag'; import isQuote from '../../misc/is-quote'; -import { TextElementMention } from '../../mfm/parse/elements/mention'; -import { TextElementHashtag } from '../../mfm/parse/elements/hashtag'; import notesChart from '../../chart/notes'; import perUserNotesChart from '../../chart/per-user-notes'; @@ -30,7 +28,7 @@ import { erase, unique } from '../../prelude/array'; import insertNoteUnread from './unread'; import registerInstance from '../register-instance'; import Instance from '../../models/instance'; -import { TextElementEmoji } from '../../mfm/parse/elements/emoji'; +import { Node } from '../../mfm/parser'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -162,7 +160,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< const emojis = extractEmojis(tokens); - const mentionedUsers = data.apMentions || await extractMentionedUsers(tokens); + const mentionedUsers = data.apMentions || await extractMentionedUsers(user, tokens); if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) { mentionedUsers.push(await User.findOne({ _id: data.reply.userId })); @@ -460,21 +458,41 @@ async function insertNote(user: IUser, data: Option, tags: string[], emojis: str } function extractHashtags(tokens: ReturnType): string[] { + const hashtags: string[] = []; + + const extract = (tokens: Node[]) => { + tokens.filter(x => x.name === 'hashtag').forEach(x => { + if (x.props.hashtag.length <= 100) { + hashtags.push(x.props.hashtag); + } + }); + tokens.filter(x => x.children).forEach(x => { + extract(x.children); + }); + }; + // Extract hashtags - const hashtags = tokens - .filter(t => t.type == 'hashtag') - .map(t => (t as TextElementHashtag).hashtag) - .filter(tag => tag.length <= 100); + extract(tokens); return unique(hashtags); } function extractEmojis(tokens: ReturnType): string[] { + const emojis: string[] = []; + + const extract = (tokens: Node[]) => { + tokens.filter(x => x.name === 'emoji').forEach(x => { + if (x.props.name && x.props.name.length <= 100) { + emojis.push(x.props.name); + } + }); + tokens.filter(x => x.children).forEach(x => { + extract(x.children); + }); + }; + // Extract emojis - const emojis = tokens - .filter(t => t.type == 'emoji' && t.name) - .map(t => (t as TextElementEmoji).name) - .filter(emoji => emoji.length <= 100); + extract(tokens); return unique(emojis); } @@ -638,16 +656,27 @@ function incNotesCount(user: IUser) { } } -async function extractMentionedUsers(tokens: ReturnType): Promise { +async function extractMentionedUsers(user: IUser, tokens: ReturnType): Promise { if (tokens == null) return []; - const mentionTokens = tokens - .filter(t => t.type == 'mention') as TextElementMention[]; + const mentions: any[] = []; + + const extract = (tokens: Node[]) => { + tokens.filter(x => x.name === 'mention').forEach(x => { + mentions.push(x.props); + }); + tokens.filter(x => x.children).forEach(x => { + extract(x.children); + }); + }; + + // Extract hashtags + extract(tokens); let mentionedUsers = - erase(null, await Promise.all(mentionTokens.map(async m => { + erase(null, await Promise.all(mentions.map(async m => { try { - return await resolveUser(m.username, m.host); + return await resolveUser(m.username, m.host ? m.host : user.host); } catch (e) { return null; } diff --git a/test/mfm.ts b/test/mfm.ts index 017144545a..f020ffd5a5 100644 --- a/test/mfm.ts +++ b/test/mfm.ts @@ -6,102 +6,158 @@ import * as assert from 'assert'; import analyze from '../src/mfm/parse'; import toHtml from '../src/mfm/html'; -import syntaxhighlighter from '../src/mfm/parse/core/syntax-highlighter'; + +function _node(name: string, children: any[], props: any) { + return children ? { name, children, props } : { name, props }; +} + +function node(name: string, props?: any) { + return _node(name, null, props); +} + +function nodeWithChildren(name: string, children: any[], props?: any) { + return _node(name, children, props); +} + +function text(text: string) { + return node('text', { text }); +} describe('Text', () => { it('can be analyzed', () => { const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr'); assert.deepEqual([ - { type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null }, - { type: 'text', content: ' ' }, - { type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }, - { type: 'text', content: ' お腹ペコい ' }, - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'text', content: ' ' }, - { type: 'hashtag', content: '#yryr', hashtag: 'yryr' } + node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }), + text(' '), + node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }), + text(' お腹ペコい '), + node('emoji', { name: 'cat' }), + text(' '), + node('hashtag', { hashtag: 'yryr' }), ], tokens); }); - it('can be inverted', () => { - const text = '@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr'; - assert.equal(analyze(text).map(x => x.content).join(''), text); - }); - describe('elements', () => { - it('bold', () => { - const tokens = analyze('**Strawberry** Pasta'); - assert.deepEqual([ - { type: 'bold', content: '**Strawberry**', bold: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens); + describe('bold', () => { + it('simple', () => { + const tokens = analyze('**foo**'); + assert.deepEqual([ + nodeWithChildren('bold', [ + text('foo') + ]), + ], tokens); + }); + + it('with other texts', () => { + const tokens = analyze('bar**foo**bar'); + assert.deepEqual([ + text('bar'), + nodeWithChildren('bold', [ + text('foo') + ]), + text('bar'), + ], tokens); + }); }); it('big', () => { const tokens = analyze('***Strawberry*** Pasta'); assert.deepEqual([ - { type: 'big', content: '***Strawberry***', big: 'Strawberry' }, - { type: 'text', content: ' Pasta' } + nodeWithChildren('big', [ + text('Strawberry') + ]), + text(' Pasta'), ], tokens); }); - it('motion', () => { - const tokens1 = analyze('(((Strawberry))) Pasta'); - assert.deepEqual([ - { type: 'motion', content: '(((Strawberry)))', motion: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens1); + describe('motion', () => { + it('by triple brackets', () => { + const tokens = analyze('(((foo)))'); + assert.deepEqual([ + nodeWithChildren('motion', [ + text('foo') + ]), + ], tokens); + }); - const tokens2 = analyze('Strawberry Pasta'); - assert.deepEqual([ - { type: 'motion', content: 'Strawberry', motion: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens2); + it('by triple brackets (with other texts)', () => { + const tokens = analyze('bar(((foo)))bar'); + assert.deepEqual([ + text('bar'), + nodeWithChildren('motion', [ + text('foo') + ]), + text('bar'), + ], tokens); + }); + + it('by tag', () => { + const tokens = analyze('foo'); + assert.deepEqual([ + nodeWithChildren('motion', [ + text('foo') + ]), + ], tokens); + }); + + it('by tag (with other texts)', () => { + const tokens = analyze('barfoobar'); + assert.deepEqual([ + text('bar'), + nodeWithChildren('motion', [ + text('foo') + ]), + text('bar'), + ], tokens); + }); }); describe('mention', () => { it('local', () => { - const tokens = analyze('@himawari お腹ペコい'); + const tokens = analyze('@himawari foo'); assert.deepEqual([ - { type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null }, - { type: 'text', content: ' お腹ペコい' } + node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }), + text(' foo') ], tokens); }); it('remote', () => { - const tokens = analyze('@hima_sub@namori.net お腹ペコい'); + const tokens = analyze('@hima_sub@namori.net foo'); assert.deepEqual([ - { type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }, - { type: 'text', content: ' お腹ペコい' } + node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }), + text(' foo') ], tokens); }); it('remote punycode', () => { - const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah お腹ペコい'); + const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah foo'); assert.deepEqual([ - { type: 'mention', content: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }, - { type: 'text', content: ' お腹ペコい' } + node('mention', { acct: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }), + text(' foo') ], tokens); }); it('ignore', () => { const tokens = analyze('idolm@ster'); assert.deepEqual([ - { type: 'text', content: 'idolm@ster' } + text('idolm@ster') ], tokens); const tokens2 = analyze('@a\n@b\n@c'); assert.deepEqual([ - { type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null }, - { type: 'text', content: '\n' }, - { type: 'mention', content: '@b', canonical: '@b', username: 'b', host: null }, - { type: 'text', content: '\n' }, - { type: 'mention', content: '@c', canonical: '@c', username: 'c', host: null } + node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }), + text('\n'), + node('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }), + text('\n'), + node('mention', { acct: '@c', canonical: '@c', username: 'c', host: null }) ], tokens2); const tokens3 = analyze('**x**@a'); assert.deepEqual([ - { type: 'bold', content: '**x**', bold: 'x' }, - { type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null } + nodeWithChildren('bold', [ + text('x') + ]), + node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }) ], tokens3); }); }); @@ -109,172 +165,294 @@ describe('Text', () => { it('hashtag', () => { const tokens1 = analyze('Strawberry Pasta #alice'); assert.deepEqual([ - { type: 'text', content: 'Strawberry Pasta ' }, - { type: 'hashtag', content: '#alice', hashtag: 'alice' } + text('Strawberry Pasta '), + node('hashtag', { hashtag: 'alice' }) ], tokens1); const tokens2 = analyze('Foo #bar, baz #piyo.'); assert.deepEqual([ - { type: 'text', content: 'Foo ' }, - { type: 'hashtag', content: '#bar', hashtag: 'bar' }, - { type: 'text', content: ', baz ' }, - { type: 'hashtag', content: '#piyo', hashtag: 'piyo' }, - { type: 'text', content: '.' } + text('Foo '), + node('hashtag', { hashtag: 'bar' }), + text(', baz '), + node('hashtag', { hashtag: 'piyo' }), + text('.'), ], tokens2); const tokens3 = analyze('#Foo!'); assert.deepEqual([ - { type: 'hashtag', content: '#Foo', hashtag: 'Foo' }, - { type: 'text', content: '!' }, + node('hashtag', { hashtag: 'Foo' }), + text('!'), ], tokens3); }); - it('quote', () => { - const tokens1 = analyze('> foo\nbar\nbaz'); - assert.deepEqual([ - { type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' } - ], tokens1); + describe('quote', () => { + it('basic', () => { + const tokens1 = analyze('> foo'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]) + ], tokens1); - const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter'); - assert.deepEqual([ - { type: 'text', content: 'before' }, - { type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' }, - { type: 'text', content: 'after' } - ], tokens2); + const tokens2 = analyze('>foo'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]) + ], tokens2); + }); - const tokens3 = analyze('piyo> foo\nbar\nbaz'); - assert.deepEqual([ - { type: 'text', content: 'piyo> foo\nbar\nbaz' } - ], tokens3); + it('series', () => { + const tokens = analyze('> foo\n\n> bar'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]), + nodeWithChildren('quote', [ + text('bar') + ]), + ], tokens); + }); - const tokens4 = analyze('> foo\n> bar\n> baz'); - assert.deepEqual([ - { type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' } - ], tokens4); + it('trailing line break', () => { + const tokens1 = analyze('> foo\n'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]), + ], tokens1); - const tokens5 = analyze('"\nfoo\nbar\nbaz\n"'); - assert.deepEqual([ - { type: 'quote', content: '"\nfoo\nbar\nbaz\n"', quote: 'foo\nbar\nbaz' } - ], tokens5); + const tokens2 = analyze('> foo\n\n'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]), + text('\n') + ], tokens2); + }); + + it('multiline', () => { + const tokens1 = analyze('>foo\n>bar'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo\nbar') + ]) + ], tokens1); + + const tokens2 = analyze('> foo\n> bar'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo\nbar') + ]) + ], tokens2); + }); + + it('multiline with trailing line break', () => { + const tokens1 = analyze('> foo\n> bar\n'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo\nbar') + ]), + ], tokens1); + + const tokens2 = analyze('> foo\n> bar\n\n'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo\nbar') + ]), + text('\n') + ], tokens2); + }); + + it('with before and after texts', () => { + const tokens = analyze('before\n> foo\nafter'); + assert.deepEqual([ + text('before'), + nodeWithChildren('quote', [ + text('foo') + ]), + text('after'), + ], tokens); + }); + + it('require line break before ">"', () => { + const tokens = analyze('foo>bar'); + assert.deepEqual([ + text('foo>bar'), + ], tokens); + }); + + it('nested', () => { + const tokens = analyze('>> foo\n> bar'); + assert.deepEqual([ + nodeWithChildren('quote', [ + nodeWithChildren('quote', [ + text('foo') + ]), + text('bar') + ]) + ], tokens); + }); + + it('trim line breaks', () => { + const tokens = analyze('foo\n\n>a\n>>b\n>>\n>>>\n>>>c\n>>>\n>d\n\n'); + assert.deepEqual([ + text('foo\n'), + nodeWithChildren('quote', [ + text('a'), + nodeWithChildren('quote', [ + text('b\n'), + nodeWithChildren('quote', [ + text('\nc\n') + ]) + ]), + text('d') + ]), + text('\n'), + ], tokens); + }); }); describe('url', () => { it('simple', () => { const tokens = analyze('https://example.com'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com', - url: 'https://example.com' - }], tokens); + assert.deepEqual([ + node('url', { url: 'https://example.com' }) + ], tokens); }); it('ignore trailing period', () => { const tokens = analyze('https://example.com.'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com', - url: 'https://example.com' - }, { - type: 'text', content: '.' - }], tokens); + assert.deepEqual([ + node('url', { url: 'https://example.com' }), + text('.') + ], tokens); }); it('with comma', () => { const tokens = analyze('https://example.com/foo?bar=a,b'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo?bar=a,b', - url: 'https://example.com/foo?bar=a,b' - }], tokens); + assert.deepEqual([ + node('url', { url: 'https://example.com/foo?bar=a,b' }) + ], tokens); }); it('ignore trailing comma', () => { const tokens = analyze('https://example.com/foo, bar'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo', - url: 'https://example.com/foo' - }, { - type: 'text', content: ', bar' - }], tokens); + assert.deepEqual([ + node('url', { url: 'https://example.com/foo' }), + text(', bar') + ], tokens); }); it('with brackets', () => { const tokens = analyze('https://example.com/foo(bar)'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo(bar)', - url: 'https://example.com/foo(bar)' - }], tokens); + assert.deepEqual([ + node('url', { url: 'https://example.com/foo(bar)' }) + ], tokens); }); it('ignore parent brackets', () => { const tokens = analyze('(https://example.com/foo)'); - assert.deepEqual([{ - type: 'text', content: '(' - }, { - type: 'url', - content: 'https://example.com/foo', - url: 'https://example.com/foo' - }, { - type: 'text', content: ')' - }], tokens); + assert.deepEqual([ + text('('), + node('url', { url: 'https://example.com/foo' }), + text(')') + ], tokens); }); it('ignore parent brackets with internal brackets', () => { const tokens = analyze('(https://example.com/foo(bar))'); - assert.deepEqual([{ - type: 'text', content: '(' - }, { - type: 'url', - content: 'https://example.com/foo(bar)', - url: 'https://example.com/foo(bar)' - }, { - type: 'text', content: ')' - }], tokens); + assert.deepEqual([ + text('('), + node('url', { url: 'https://example.com/foo(bar)' }), + text(')') + ], tokens); }); }); it('link', () => { - const tokens = analyze('[ひまさく](https://himasaku.net)'); - assert.deepEqual([{ - type: 'link', - content: '[ひまさく](https://himasaku.net)', - title: 'ひまさく', - url: 'https://himasaku.net', - silent: false - }], tokens); + const tokens = analyze('[foo](https://example.com)'); + assert.deepEqual([ + nodeWithChildren('link', [ + text('foo') + ], { url: 'https://example.com', silent: false }) + ], tokens); }); it('emoji', () => { const tokens1 = analyze(':cat:'); assert.deepEqual([ - { type: 'emoji', content: ':cat:', name: 'cat' } + node('emoji', { name: 'cat' }) ], tokens1); const tokens2 = analyze(':cat::cat::cat:'); assert.deepEqual([ - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'emoji', content: ':cat:', name: 'cat' } + node('emoji', { name: 'cat' }), + node('emoji', { name: 'cat' }), + node('emoji', { name: 'cat' }) ], tokens2); const tokens3 = analyze('🍎'); assert.deepEqual([ - { type: 'emoji', content: '🍎', emoji: '🍎' } + node('emoji', { emoji: '🍎' }) ], tokens3); }); - it('block code', () => { - const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```'); - assert.equal(tokens[0].type, 'code'); - assert.equal(tokens[0].content, '```\nvar x = "Strawberry Pasta";\n```'); + describe('block code', () => { + it('simple', () => { + const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```'); + assert.deepEqual([ + node('blockCode', { code: 'var x = "Strawberry Pasta";', lang: null }) + ], tokens); + }); + + it('can specify language', () => { + const tokens = analyze('``` json\n{ "x": 42 }\n```'); + assert.deepEqual([ + node('blockCode', { code: '{ "x": 42 }', lang: 'json' }) + ], tokens); + }); + + it('require line break before "```"', () => { + const tokens = analyze('before```\nfoo\n```'); + assert.deepEqual([ + text('before'), + node('inlineCode', { code: '`' }), + text('\nfoo\n'), + node('inlineCode', { code: '`' }) + ], tokens); + }); + + it('series', () => { + const tokens = analyze('```\nfoo\n```\n```\nbar\n```\n```\nbaz\n```'); + assert.deepEqual([ + node('blockCode', { code: 'foo', lang: null }), + node('blockCode', { code: 'bar', lang: null }), + node('blockCode', { code: 'baz', lang: null }), + ], tokens); + }); + + it('ignore internal marker', () => { + const tokens = analyze('```\naaa```bbb\n```'); + assert.deepEqual([ + node('blockCode', { code: 'aaa```bbb', lang: null }) + ], tokens); + }); + + it('trim after line break', () => { + const tokens = analyze('```\nfoo\n```\nbar'); + assert.deepEqual([ + node('blockCode', { code: 'foo', lang: null }), + text('bar') + ], tokens); + }); }); it('inline code', () => { const tokens = analyze('`var x = "Strawberry Pasta";`'); - assert.equal(tokens[0].type, 'inline-code'); - assert.equal(tokens[0].content, '`var x = "Strawberry Pasta";`'); + assert.deepEqual([ + node('inlineCode', { code: 'var x = "Strawberry Pasta";' }) + ], tokens); }); it('math', () => { @@ -282,82 +460,88 @@ describe('Text', () => { const text = `\\(${fomula}\\)`; const tokens = analyze(text); assert.deepEqual([ - { type: 'math', content: text, formula: fomula } + node('math', { formula: fomula }) ], tokens); }); it('search', () => { const tokens1 = analyze('a b c 検索'); assert.deepEqual([ - { type: 'search', content: 'a b c 検索', query: 'a b c' } + node('search', { content: 'a b c 検索', query: 'a b c' }) ], tokens1); const tokens2 = analyze('a b c Search'); assert.deepEqual([ - { type: 'search', content: 'a b c Search', query: 'a b c' } + node('search', { content: 'a b c Search', query: 'a b c' }) ], tokens2); const tokens3 = analyze('a b c search'); assert.deepEqual([ - { type: 'search', content: 'a b c search', query: 'a b c' } + node('search', { content: 'a b c search', query: 'a b c' }) ], tokens3); const tokens4 = analyze('a b c SEARCH'); assert.deepEqual([ - { type: 'search', content: 'a b c SEARCH', query: 'a b c' } + node('search', { content: 'a b c SEARCH', query: 'a b c' }) ], tokens4); }); - it('title', () => { - const tokens1 = analyze('【yee】\nhaw'); - assert.deepEqual( - { type: 'title', content: '【yee】\n', title: 'yee' } - , tokens1[0]); + describe('title', () => { + it('simple', () => { + const tokens = analyze('【foo】'); + assert.deepEqual([ + nodeWithChildren('title', [ + text('foo') + ]) + ], tokens); + }); - const tokens2 = analyze('[yee]\nhaw'); - assert.deepEqual( - { type: 'title', content: '[yee]\n', title: 'yee' } - , tokens2[0]); + it('require line break', () => { + const tokens = analyze('a【foo】'); + assert.deepEqual([ + text('a【foo】') + ], tokens); + }); - const tokens3 = analyze('a [a]\nb [b]\nc [c]'); - assert.deepEqual( - { type: 'text', content: 'a [a]\nb [b]\nc [c]' } - , tokens3[0]); - - const tokens4 = analyze('foo\n【bar】\nbuzz'); - assert.deepEqual([ - { type: 'text', content: 'foo' }, - { type: 'title', content: '\n【bar】\n', title: 'bar' }, - { type: 'text', content: 'buzz' }, - ], tokens4); - }); - }); - - describe('syntax highlighting', () => { - it('comment', () => { - const html1 = syntaxhighlighter('// Strawberry pasta'); - assert.equal(html1, '// Strawberry pasta'); - - const html2 = syntaxhighlighter('x // x\ny // y'); - assert.equal(html2, 'x // x\ny // y'); - }); - - it('regexp', () => { - const html = syntaxhighlighter('/.*/'); - assert.equal(html, '/.*/'); - }); - - it('slash', () => { - const html = syntaxhighlighter('/'); - assert.equal(html, '/'); + it('with before and after texts', () => { + const tokens = analyze('before\n【foo】\nafter'); + assert.deepEqual([ + text('before'), + nodeWithChildren('title', [ + text('foo') + ]), + text('after') + ], tokens); + }); }); }); describe('toHtml', () => { it('br', () => { const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

'; + const output = '

foo
bar
baz

'; assert.equal(toHtml(analyze(input)), output); }); }); + + it('code block with quote', () => { + const tokens = analyze('> foo\n```\nbar\n```'); + assert.deepEqual([ + nodeWithChildren('quote', [ + text('foo') + ]), + node('blockCode', { code: 'bar', lang: null }) + ], tokens); + }); + + it('quote between two code blocks', () => { + const tokens = analyze('```\nbefore\n```\n> foo\n```\nafter\n```'); + assert.deepEqual([ + node('blockCode', { code: 'before', lang: null }), + nodeWithChildren('quote', [ + text('foo') + ]), + node('blockCode', { code: 'after', lang: null }) + ], tokens); + }); }); diff --git a/webpack.config.ts b/webpack.config.ts index fd552dd21a..aed417ea52 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -38,6 +38,7 @@ module.exports = { dev: './src/client/app/dev/script.ts', auth: './src/client/app/auth/script.ts', admin: './src/client/app/admin/script.ts', + test: './src/client/app/test/script.ts', sw: './src/client/app/sw.js' }, module: {