From db5a022f3bce895de8c621c781b07dc2d0b7400a Mon Sep 17 00:00:00 2001
From: Anthony Fu
Date: Wed, 30 Nov 2022 13:27:24 +0800
Subject: [PATCH] feat: rework content handling to support inline markdown
---
composables/content.ts | 90 +++++++++++++------
composables/statusDrafts.ts | 2 +-
styles/global.css | 2 +-
tests/__snapshots__/content-rich.test.ts.snap | 13 ++-
tests/__snapshots__/html-parse.test.ts.snap | 73 +++++++++++++++
tests/html-parse.test.ts | 66 ++++++++++++++
6 files changed, 216 insertions(+), 30 deletions(-)
create mode 100644 tests/__snapshots__/html-parse.test.ts.snap
create mode 100644 tests/html-parse.test.ts
diff --git a/composables/content.ts b/composables/content.ts
index a5c3246c..3aa9d502 100644
--- a/composables/content.ts
+++ b/composables/content.ts
@@ -1,7 +1,7 @@
import type { Emoji } from 'masto'
import type { DefaultTreeAdapterMap } from 'parse5'
-import { parseFragment } from 'parse5'
-import type { Component, VNode } from 'vue'
+import { parseFragment, serialize } from 'parse5'
+import type { VNode } from 'vue'
import { Fragment, h, isVNode } from 'vue'
import { RouterLink } from 'vue-router'
import ContentCode from '~/components/content/ContentCode.vue'
@@ -9,10 +9,6 @@ import ContentCode from '~/components/content/ContentCode.vue'
type Node = DefaultTreeAdapterMap['childNode']
type Element = DefaultTreeAdapterMap['element']
-const CUSTOM_BLOCKS: Record = {
- 'custom-code': ContentCode,
-}
-
function handleMention(el: Element) {
// Redirect mentions to the user page
if (el.tagName === 'a' && el.attrs.find(i => i.name === 'class' && i.value.includes('mention'))) {
@@ -34,48 +30,92 @@ function handleMention(el: Element) {
return undefined
}
-function handleBlocks(el: Element) {
- if (el.tagName in CUSTOM_BLOCKS) {
- const block = CUSTOM_BLOCKS[el.tagName]
- const attrs = Object.fromEntries(el.attrs.map(i => [i.name, i.value]))
- return h(block, attrs, () => el.childNodes.map(treeToVNode))
+function handleCodeBlock(el: Element) {
+ if (el.tagName === 'pre' && el.childNodes[0]?.nodeName === 'code') {
+ const codeEl = el.childNodes[0] as Element
+ const classes = codeEl.attrs.find(i => i.name === 'class')?.value
+ const lang = classes?.split(/\s/g).find(i => i.startsWith('language-'))?.replace('language-', '')
+ const code = treeToText(codeEl.childNodes[0])
+ return h(ContentCode, { lang, code: encodeURIComponent(code) })
}
}
function handleNode(el: Element) {
- return handleBlocks(el) || handleMention(el) || el
+ return handleCodeBlock(el) || handleMention(el) || el
}
-export function contentToVNode(
- content: string,
- customEmojis: Record = {},
-): VNode {
- content = content
- .trim()
- // handle custom emojis
+/**
+ * Parse raw HTML form Mastodon server to AST,
+ * with interop of custom emojis and inline Markdown syntax
+ */
+export function parseMastodonHTML(html: string, customEmojis: Record = {}) {
+ const processed = html
+ // custom emojis
.replace(/:([\w-]+?):/g, (_, name) => {
const emoji = customEmojis[name]
if (emoji)
return ``
return `:${name}:`
})
- // handle code frames
+ // handle code blocks
.replace(/>(```|~~~)([\s\S]+?)\1/g, (_1, _2, raw) => {
const plain = htmlToText(raw)
const [lang, ...code] = plain.split('\n')
- return `>`
+ const classes = lang ? ` class="language-${lang}"` : ''
+ return `>${code.join('\n')}
`
})
- const tree = parseFragment(content)
+ const tree = parseFragment(processed)
+
+ function walk(node: Node) {
+ if ('childNodes' in node)
+ node.childNodes = node.childNodes.flatMap(n => walk(n))
+
+ if (node.nodeName === '#text') {
+ // @ts-expect-error casing
+ const text = node.value as string
+ const converted = text
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\*(.*?)\*/g, '$1')
+ .replace(/~~(.*?)~~/g, '$1')
+ .replace(/__(.*?)__/g, '$1')
+ .replace(/`([^`]+?)`/g, '$1
')
+
+ if (converted !== text)
+ return parseFragment(converted).childNodes
+ }
+ return [node]
+ }
+
+ tree.childNodes = tree.childNodes.flatMap(n => walk(n))
+
+ return tree
+}
+
+export function convertMastodonHTML(html: string, customEmojis: Record = {}) {
+ const tree = parseMastodonHTML(html, customEmojis)
+ return serialize(tree)
+}
+
+/**
+ * Raw HTML to VNodes
+ */
+export function contentToVNode(
+ content: string,
+ customEmojis: Record = {},
+): VNode {
+ const tree = parseMastodonHTML(content, customEmojis)
return h(Fragment, tree.childNodes.map(n => treeToVNode(n)))
}
-export function treeToVNode(
+function treeToVNode(
input: Node,
): VNode | string | null {
- if (input.nodeName === '#text')
+ if (input.nodeName === '#text') {
// @ts-expect-error casing
- return input.value
+ const text = input.value as string
+ return text
+ }
if ('childNodes' in input) {
const node = handleNode(input)
diff --git a/composables/statusDrafts.ts b/composables/statusDrafts.ts
index 6bde5c66..1aa85393 100644
--- a/composables/statusDrafts.ts
+++ b/composables/statusDrafts.ts
@@ -42,7 +42,7 @@ export function getDefaultDraft(options: Partial att.id),
visibility: status.visibility,
attachments: status.mediaAttachments,
diff --git a/styles/global.css b/styles/global.css
index 7ce18f2d..d3588037 100644
--- a/styles/global.css
+++ b/styles/global.css
@@ -68,7 +68,7 @@ body {
--at-apply: my-2;
}
code {
- --at-apply: bg-code text-code px1 py0.5 rounded text-sm;
+ --at-apply: bg-code text-code px1 py0.5 rounded text-0.9em;
}
pre code {
--at-apply: text-base bg-transparent px0 py0 rounded-none;
diff --git a/tests/__snapshots__/content-rich.test.ts.snap b/tests/__snapshots__/content-rich.test.ts.snap
index 534df3ac..15932808 100644
--- a/tests/__snapshots__/content-rich.test.ts.snap
+++ b/tests/__snapshots__/content-rich.test.ts.snap
@@ -1,20 +1,27 @@
// Vitest Snapshot v1
exports[`content-rich > code frame 1`] = `
-"Testing code block
import { useMouse, usePreferredDark } from '@vueuse/core'
+"Testing code block
+
+
+import { useMouse, usePreferredDark } from '@vueuse/core'
// tracks mouse position
const { x, y } = useMouse()
// is the user prefers dark theme
-const isDark = usePreferredDark()
"
+const isDark = usePreferredDark()
+
+"
`;
exports[`content-rich > code frame 2 1`] = `
"
Testing
-
const a = hello
+const a = hello
+
"
`;
diff --git a/tests/__snapshots__/html-parse.test.ts.snap b/tests/__snapshots__/html-parse.test.ts.snap
new file mode 100644
index 00000000..9955b476
--- /dev/null
+++ b/tests/__snapshots__/html-parse.test.ts.snap
@@ -0,0 +1,73 @@
+// Vitest Snapshot v1
+
+exports[`html-parse > code frame 1`] = `
+"Testing code block
+
+import { useMouse, usePreferredDark } from '@vueuse/core'
+
+// tracks mouse position
+const { x, y } = useMouse()
+// is the user prefers dark theme
+const isDark = usePreferredDark()
+
+"
+`;
+
+exports[`html-parse > code frame 2 1`] = `
+"
+ @antfu
+ Testing
+
+const a = hello
+
+"
+`;
+
+exports[`html-parse > custom emoji 1`] = `
+"Daniel Roe
+
+"
+`;
+
+exports[`html-parse > empty 1`] = `""`;
+
+exports[`html-parse > inline markdown 1`] = `
+"text code
bold italic
+
+code block
+
+"
+`;
+
+exports[`html-parse > link + mention 1`] = `
+"
+ Happy 🤗 we’re now using
+ @vitest
+ (migrated from chai+mocha)
+ https://github.com/ayoayco/astro-reactive-library/pull/203
+
+"
+`;
diff --git a/tests/html-parse.test.ts b/tests/html-parse.test.ts
new file mode 100644
index 00000000..306fbc86
--- /dev/null
+++ b/tests/html-parse.test.ts
@@ -0,0 +1,66 @@
+import type { Emoji } from 'masto'
+import { describe, expect, it } from 'vitest'
+import { format } from 'prettier'
+import { serialize } from 'parse5'
+import { parseMastodonHTML } from '~/composables/content'
+
+describe('html-parse', () => {
+ it('empty', async () => {
+ const { formatted } = await render('')
+ expect(formatted).toMatchSnapshot()
+ })
+
+ it('link + mention', async () => {
+ // https://fosstodon.org/@ayo/109383002937620723
+ const { formatted } = await render('Happy 🤗 we’re now using @vitest (migrated from chai+mocha) https://github.com/ayoayco/astro-reactive-library/pull/203
')
+ expect(formatted).toMatchSnapshot()
+ })
+
+ it('custom emoji', async () => {
+ const { formatted } = await render('Daniel Roe :nuxt:', {
+ nuxt: {
+ shortcode: 'nuxt',
+ url: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/original/c96ba3cb0e0e1eac.png',
+ staticUrl: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/static/c96ba3cb0e0e1eac.png',
+ visibleInPicker: true,
+ },
+ })
+ expect(formatted).toMatchSnapshot()
+ })
+
+ it('code frame', async () => {
+ // https://mas.to/@antfu/109396489827394721
+ const { formatted } = await render('Testing code block
```ts
import { useMouse, usePreferredDark } from '@vueuse/core'
// tracks mouse position
const { x, y } = useMouse()
// is the user prefers dark theme
const isDark = usePreferredDark()
```
')
+ expect(formatted).toMatchSnapshot()
+ })
+
+ it('code frame 2', async () => {
+ const { formatted } = await render('@antfu Testing
```ts
const a = hello
```
')
+ expect(formatted).toMatchSnapshot()
+ })
+
+ it('inline markdown', async () => {
+ const { formatted } = await render('text `code` **bold** *italic*
```js
code block
```
')
+ expect(formatted).toMatchSnapshot()
+ })
+})
+
+async function render(content: string, emojis?: Record) {
+ const node = parseMastodonHTML(content, emojis)
+ const html = serialize(node)
+ let formatted = ''
+
+ try {
+ formatted = format(html, {
+ parser: 'html',
+ })
+ }
+ catch (e) {
+ formatted = html
+ }
+
+ return {
+ html,
+ formatted,
+ }
+}