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 `:${name}:` 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 +\\":nuxt:\\" +" +`; + +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) github.com/ayoayco/astro-react

') + 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, + } +}