From 79ffbf95db9d0cc019d06ab93b1bfa6ba0d4f9ae Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 21 Nov 2018 05:11:00 +0900 Subject: 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 --- src/client/app/boot.js | 1 + src/client/app/common/views/components/index.ts | 2 +- src/client/app/common/views/components/mfm.ts | 269 +++++++++++++++++++++ .../views/components/misskey-flavored-markdown.ts | 233 ------------------ .../views/components/misskey-flavored-markdown.vue | 57 +++++ .../common/views/components/welcome-timeline.vue | 2 +- src/client/app/common/views/pages/follow.vue | 2 +- .../app/desktop/views/components/note-detail.vue | 2 +- src/client/app/desktop/views/components/note.vue | 45 +--- .../desktop/views/components/sub-note-content.vue | 2 +- .../app/desktop/views/components/user-card.vue | 2 +- .../desktop/views/pages/deck/deck.user-column.vue | 2 +- .../app/desktop/views/pages/user/user.header.vue | 2 +- .../app/mobile/views/components/note-detail.vue | 2 +- src/client/app/mobile/views/components/note.vue | 44 +--- .../mobile/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 +++ 20 files changed, 403 insertions(+), 331 deletions(-) create mode 100644 src/client/app/common/views/components/mfm.ts delete mode 100644 src/client/app/common/views/components/misskey-flavored-markdown.ts 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 (limited to 'src/client/app') 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/mfm.ts b/src/client/app/common/views/components/mfm.ts new file mode 100644 index 0000000000..b7ff5bd487 --- /dev/null +++ b/src/client/app/common/views/components/mfm.ts @@ -0,0 +1,269 @@ +import Vue, { VNode } from 'vue'; +import { length } from 'stringz'; +import { Node } from '../../../../../mfm/parser'; +import parse from '../../../../../mfm/parse'; +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: { + text: { + type: String, + required: true + }, + ast: { + type: [], + required: false + }, + shouldBreak: { + type: Boolean, + default: true + }, + author: { + type: Object, + default: null + }, + i: { + type: Object, + default: null + }, + customEmojis: { + required: false, + } + }, + + render(createElement) { + 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 Node[]; + } + + let bigCount = 0; + let motionCount = 0; + + const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => { + switch (token.name) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (this.shouldBreak) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return [createElement('span', text.replace(/\n/g, ' '))]; + } + } + + case 'bold': { + return [createElement('b', genEl(token.children))]; + } + + case 'big': { + bigCount++; + const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5; + const isMany = bigCount > 3; + return (createElement as any)('strong', { + attrs: { + style: `display: inline-block; font-size: ${ isMany ? '100%' : '150%' };` + }, + directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { + name: 'animate-css', + value: { classes: 'tada', iteration: 'infinite' } + }] + }, genEl(token.children)); + } + + case 'motion': { + motionCount++; + const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5; + const isMany = motionCount > 3; + return (createElement as any)('span', { + attrs: { + style: 'display: inline-block;' + }, + directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { + name: 'animate-css', + value: { classes: 'rubberBand', iteration: 'infinite' } + }] + }, genEl(token.children)); + } + + case 'url': { + return [createElement(MkUrl, { + key: Math.random(), + props: { + url: token.props.url, + target: '_blank', + style: 'color:var(--mfmLink);' + } + })]; + } + + case 'link': { + return [createElement('a', { + attrs: { + class: 'link', + href: token.props.url, + target: '_blank', + title: token.props.url, + style: 'color:var(--mfmLink);' + } + }, 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: `/${canonical}`, + // TODO + //dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), + style: 'color:var(--mfmMention);' + }, + directives: [{ + name: 'user-preview', + value: canonical + }] + }, canonical); + } + + case 'hashtag': { + return [createElement('router-link', { + key: Math.random(), + attrs: { + to: `/tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--mfmHashtag);' + } + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [createElement('pre', { + class: 'code' + }, [ + createElement('code', { + domProps: { + innerHTML: syntaxHighlight(token.props.code) + } + }) + ])]; + } + + case 'inlineCode': { + return [createElement('code', { + domProps: { + innerHTML: syntaxHighlight(token.props.code) + } + })]; + } + + case 'quote': { + if (this.shouldBreak) { + return [createElement('div', { + attrs: { + class: 'quote' + } + }, genEl(token.children))]; + } else { + return [createElement('span', { + attrs: { + class: 'quote' + } + }, genEl(token.children))]; + } + } + + case 'title': { + return [createElement('div', { + attrs: { + class: 'title' + } + }, genEl(token.children))]; + } + + case 'emoji': { + const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + return [createElement('mk-emoji', { + key: Math.random(), + attrs: { + emoji: token.props.emoji, + name: token.props.name + }, + props: { + customEmojis: this.customEmojis || customEmojis + } + })]; + } + + case 'math': { + //const MkFormula = () => import('./formula.vue').then(m => m.default); + return [createElement(MkFormula, { + key: Math.random(), + props: { + formula: token.props.formula + } + })]; + } + + case 'search': { + //const MkGoogle = () => import('./google.vue').then(m => m.default); + return [createElement(MkGoogle, { + key: Math.random(), + props: { + q: token.props.query + } + })]; + } + + default: { + console.log('unknown ast type:', token.name); + + return []; + } + } + })); + + // Parse ast to DOM + return createElement('span', genEl(ast)); + } +}); diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/misskey-flavored-markdown.ts deleted file mode 100644 index 1eb738813e..0000000000 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ /dev/null @@ -1,233 +0,0 @@ -import Vue, { VNode } from 'vue'; -import { length } from 'stringz'; -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'; - -export default Vue.component('misskey-flavored-markdown', { - props: { - text: { - type: String, - required: true - }, - ast: { - type: [], - required: false - }, - shouldBreak: { - type: Boolean, - default: true - }, - i: { - type: Object, - default: null - }, - customEmojis: { - required: false, - } - }, - - render(createElement) { - let ast: any[]; - - if (this.ast == null) { - // Parse text to ast - ast = parse(this.text); - } else { - ast = this.ast as any[]; - } - - let bigCount = 0; - let motionCount = 0; - - // Parse ast to DOM - const els = concat(ast.map((token): VNode[] => { - switch (token.type) { - case 'text': { - const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); - - if (this.shouldBreak) { - const x = text.split('\n') - .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); - x[x.length - 1].pop(); - return x; - } else { - return [createElement('span', text.replace(/\n/g, ' '))]; - } - } - - case 'bold': { - return [createElement('b', token.bold)]; - } - - case 'big': { - bigCount++; - const isLong = length(token.big) > 10; - const isMany = bigCount > 3; - return (createElement as any)('strong', { - attrs: { - style: `display: inline-block; font-size: ${ isMany ? '100%' : '150%' };` - }, - directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { - name: 'animate-css', - value: { classes: 'tada', iteration: 'infinite' } - }] - }, token.big); - } - - case 'motion': { - motionCount++; - const isLong = length(token.motion) > 10; - const isMany = motionCount > 3; - return (createElement as any)('span', { - attrs: { - style: 'display: inline-block;' - }, - directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { - name: 'animate-css', - value: { classes: 'rubberBand', iteration: 'infinite' } - }] - }, token.motion); - } - - case 'url': { - return [createElement(MkUrl, { - props: { - url: token.content, - target: '_blank', - style: 'color:var(--mfmLink);' - } - })]; - } - - case 'link': { - return [createElement('a', { - attrs: { - class: 'link', - href: token.url, - target: '_blank', - title: token.url, - style: 'color:var(--mfmLink);' - } - }, token.title)]; - } - - case 'mention': { - return (createElement as any)('router-link', { - attrs: { - to: `/${token.canonical}`, - dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), - style: 'color:var(--mfmMention);' - }, - directives: [{ - name: 'user-preview', - value: token.canonical - }] - }, token.canonical); - } - - case 'hashtag': { - return [createElement('router-link', { - attrs: { - to: `/tags/${encodeURIComponent(token.hashtag)}`, - style: 'color:var(--mfmHashtag);' - } - }, token.content)]; - } - - case 'code': { - return [createElement('pre', { - class: 'code' - }, [ - createElement('code', { - domProps: { - innerHTML: token.html - } - }) - ])]; - } - - case 'inline-code': { - return [createElement('code', { - domProps: { - innerHTML: token.html - } - })]; - } - - 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)]; - } else { - return [createElement('span', { - attrs: { - class: 'quote' - } - }, text2.replace(/\n/g, ' '))]; - } - } - - case 'title': { - return [createElement('div', { - attrs: { - class: 'title' - } - }, token.title)]; - } - - case 'emoji': { - const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; - return [createElement('mk-emoji', { - attrs: { - emoji: token.emoji, - name: token.name - }, - props: { - customEmojis: this.customEmojis || customEmojis - } - })]; - } - - case 'math': { - //const MkFormula = () => import('./formula.vue').then(m => m.default); - return [createElement(MkFormula, { - props: { - formula: token.formula - } - })]; - } - - case 'search': { - //const MkGoogle = () => import('./google.vue').then(m => m.default); - return [createElement(MkGoogle, { - props: { - q: token.query - } - })]; - } - - default: { - console.log('unknown ast type:', token.type); - - 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); - } -}); 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 @@ + + + + + -- cgit v1.2.3-freya