diff --git a/components/common/RichContent.ts b/components/common/RichContent.ts new file mode 100644 index 00000000..c8f2018c --- /dev/null +++ b/components/common/RichContent.ts @@ -0,0 +1,11 @@ +export default defineComponent({ + props: { + content: { + type: String, + required: true, + }, + }, + setup(props) { + return () => contentToVNode(props.content) + }, +}) diff --git a/components/publish/PublishWidget.vue b/components/publish/PublishWidget.vue index d15d0ba1..9181a35b 100644 --- a/components/publish/PublishWidget.vue +++ b/components/publish/PublishWidget.vue @@ -18,7 +18,12 @@ async function publish() { <template> <div flex flex-col gap-4 :class="isSending ? ' pointer-events-none' : ''"> - <textarea v-model="draftPost" p2 border-rounded w-full h-40 color-black placeholder="What's on your mind?" /> + <textarea + v-model="draftPost" + placeholder="What's on your mind?" + p2 border-rounded w-full h-40 + bg-gray:10 outline-none border="~ border" + /> <div flex justify-end> <button h-9 w-22 bg-primary border-rounded :disabled="draftPost === ''" @click="publish"> Publish! diff --git a/components/status/StatusBody.vue b/components/status/StatusBody.vue index 8c2daf3d..687e8093 100644 --- a/components/status/StatusBody.vue +++ b/components/status/StatusBody.vue @@ -1,15 +1,15 @@ <script setup lang="ts"> import type { Status } from 'masto' -defineProps<{ +const { status } = defineProps<{ status: Status }>() - -// TODO: parse and interop content (link, emojis) </script> <template> - <div class="status-body" v-html="sanitize(status.content)" /> + <div class="status-body"> + <CommonRichContent :content="status.content" /> + </div> </template> <style lang="postcss"> diff --git a/composables/content.ts b/composables/content.ts new file mode 100644 index 00000000..e1229bca --- /dev/null +++ b/composables/content.ts @@ -0,0 +1,72 @@ +import type { DefaultTreeAdapterMap } from 'parse5' +import { parseFragment, serialize } from 'parse5' +import type { VNode } from 'vue' +import { Fragment, h } from 'vue' +import { RouterLink } from 'vue-router' + +type Node = DefaultTreeAdapterMap['childNode'] +type Element = DefaultTreeAdapterMap['element'] + +const UserLinkRE = /^https?:\/\/([^/]+)\/@([^/]+)$/ +const TagLinkRE = /^https?:\/\/([^/]+)\/tags\/([^/]+)$/ + +export function defaultHandle(el: Element) { + // Redirect mentions to the user page + if (el.tagName === 'a' && el.attrs.find(i => i.name === 'class' && i.value.includes('mention'))) { + const href = el.attrs.find(i => i.name === 'href') + if (href) { + const matchUser = href.value.match(UserLinkRE) + if (matchUser) { + const [, server, username] = matchUser + href.value = `/@${username}@${server}` + } + const matchTag = href.value.match(TagLinkRE) + if (matchTag) { + const [, , name] = matchTag + href.value = `/tags/${name}` + } + } + } + return el +} + +export function contentToVNode( + content: string, + handle: (node: Element) => Element | undefined | null | void = defaultHandle, +): VNode { + const tree = parseFragment(content) + return h(Fragment, tree.childNodes.map(n => treeToVNode(n, handle))) +} + +export function treeToVNode( + input: Node, + handle: (node: Element) => Element | undefined | null | void = defaultHandle, +): VNode | string | null { + if (input.nodeName === '#text') + // @ts-expect-error casing + return input.value + + if ('childNodes' in input) { + const node = handle(input) + if (node == null) + return null + + const attrs = Object.fromEntries(node.attrs.map(i => [i.name, i.value])) + if (node.nodeName === 'a' && (attrs.href?.startsWith('/') || attrs.href?.startsWith('.'))) { + attrs.to = attrs.href + delete attrs.href + delete attrs.target + return h( + RouterLink as any, + attrs, + node.childNodes.map(n => treeToVNode(n, handle)), + ) + } + return h( + node.nodeName, + attrs, + node.childNodes.map(n => treeToVNode(n, handle)), + ) + } + return null +} diff --git a/package.json b/package.json index 13ac2c00..c6be45f3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "fs-extra": "^10.1.0", "masto": "^4.6.5", "nuxt": "^3.0.0", + "parse5": "^7.1.1", "pinia": "^2.0.23", "postcss-nested": "^6.0.0", "rollup-plugin-node-polyfills": "^0.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736d8c0b..01b20e78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,7 @@ specifiers: fs-extra: ^10.1.0 masto: ^4.6.5 nuxt: ^3.0.0 + parse5: ^7.1.1 pinia: ^2.0.23 postcss-nested: ^6.0.0 rollup-plugin-node-polyfills: ^0.2.1 @@ -41,6 +42,7 @@ devDependencies: fs-extra: 10.1.0 masto: 4.6.5 nuxt: 3.0.0_e3uo4sehh4zr4i6m57mkkxxv7y + parse5: 7.1.1 pinia: 2.0.23_typescript@4.9.3 postcss-nested: 6.0.0 rollup-plugin-node-polyfills: 0.2.1 @@ -5396,6 +5398,12 @@ packages: parse-path: 7.0.0 dev: true + /parse5/7.1.1: + resolution: {integrity: sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg==} + dependencies: + entities: 4.4.0 + dev: true + /parseurl/1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'}