diff --git a/components/publish/PublishWidget.vue b/components/publish/PublishWidget.vue index 7d554fec..af29e2e0 100644 --- a/components/publish/PublishWidget.vue +++ b/components/publish/PublishWidget.vue @@ -3,7 +3,7 @@ import type { CreateStatusParams, StatusVisibility } from 'masto' import { fileOpen } from 'browser-fs-access' import { useDropZone } from '@vueuse/core' import { EditorContent } from '@tiptap/vue-3' -import type { Draft } from '~/composables/statusDrafts' +import type { Draft } from '~/types' const { draftKey, diff --git a/composables/content.ts b/composables/content-parse.ts similarity index 54% rename from composables/content.ts rename to composables/content-parse.ts index 76a18b84..1075e8f7 100644 --- a/composables/content.ts +++ b/composables/content-parse.ts @@ -1,47 +1,6 @@ import type { Emoji } from 'masto' import type { Node } from 'ultrahtml' import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml' -import type { VNode } from 'vue' -import { Fragment, h, isVNode } from 'vue' -import { RouterLink } from 'vue-router' -import ContentCode from '~/components/content/ContentCode.vue' -import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue' - -function handleMention(el: Node) { - // Redirect mentions to the user page - if (el.name === 'a' && el.attributes.class?.includes('mention')) { - const href = el.attributes.href - if (href) { - const matchUser = href.match(UserLinkRE) - if (matchUser) { - const [, server, username] = matchUser - const handle = `@${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}` - el.attributes.href = `/${server}/@${username}` - return h(AccountHoverWrapper, { handle, class: 'inline-block' }, () => nodeToVNode(el)) - } - const matchTag = href.match(TagLinkRE) - if (matchTag) { - const [, , name] = matchTag - el.attributes.href = `/${currentServer.value}/tags/${name}` - } - } - } - return undefined -} - -function handleCodeBlock(el: Node) { - if (el.name === 'pre' && el.children[0]?.name === 'code') { - const codeEl = el.children[0] as Node - const classes = codeEl.attributes.class as string - const lang = classes?.split(/\s/g).find(i => i.startsWith('language-'))?.replace('language-', '') - const code = codeEl.children[0] ? treeToText(codeEl.children[0]) : '' - return h(ContentCode, { lang, code: encodeURIComponent(code) }) - } -} - -function handleNode(el: Node) { - return handleCodeBlock(el) || handleMention(el) || el -} const decoder = document.createElement('textarea') function decode(text: string) { @@ -103,58 +62,6 @@ export async function convertMastodonHTML(html: string, customEmojis: Record = {}, -): VNode { - const tree = parseMastodonHTML(content, customEmojis) - return h(Fragment, (tree.children as Node[]).map(n => treeToVNode(n))) -} - -function nodeToVNode(node: Node): VNode | string | null { - if (node.type === TEXT_NODE) - return node.value - - if ('children' in node) { - if (node.name === 'a' && (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.'))) { - node.attributes.to = node.attributes.href - delete node.attributes.href - delete node.attributes.target - return h( - RouterLink as any, - node.attributes, - () => node.children.map(treeToVNode), - ) - } - return h( - node.name, - node.attributes, - node.children.map(treeToVNode), - ) - } - return null -} - -function treeToVNode( - input: Node, -): VNode | string | null { - if (input.type === TEXT_NODE) - return input.value as string - - if ('children' in input) { - const node = handleNode(input) - if (node == null) - return null - if (isVNode(node)) - return node - return nodeToVNode(node) - } - return null -} - export function htmlToText(html: string) { const tree = parse(html) return (tree.children as Node[]).map(n => treeToText(n)).join('').trim() diff --git a/composables/content-render.ts b/composables/content-render.ts new file mode 100644 index 00000000..337a851f --- /dev/null +++ b/composables/content-render.ts @@ -0,0 +1,96 @@ +import type { Emoji } from 'masto' +import { TEXT_NODE } from 'ultrahtml' +import type { Node } from 'ultrahtml' +import { Fragment, h, isVNode } from 'vue' +import type { VNode } from 'vue' +import { RouterLink } from 'vue-router' +import { parseMastodonHTML } from './content-parse' +import ContentCode from '~/components/content/ContentCode.vue' +import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue' + +/** +* Raw HTML to VNodes +*/ +export function contentToVNode( + content: string, + customEmojis: Record = {}, +): VNode { + const tree = parseMastodonHTML(content, customEmojis) + return h(Fragment, (tree.children as Node[]).map(n => treeToVNode(n))) +} + +export function nodeToVNode(node: Node): VNode | string | null { + if (node.type === TEXT_NODE) + return node.value + + if ('children' in node) { + if (node.name === 'a' && (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.'))) { + node.attributes.to = node.attributes.href + delete node.attributes.href + delete node.attributes.target + return h( + RouterLink as any, + node.attributes, + () => node.children.map(treeToVNode), + ) + } + return h( + node.name, + node.attributes, + node.children.map(treeToVNode), + ) + } + return null +} +function treeToVNode( + input: Node, +): VNode | string | null { + if (input.type === TEXT_NODE) + return input.value as string + + if ('children' in input) { + const node = handleNode(input) + if (node == null) + return null + if (isVNode(node)) + return node + return nodeToVNode(node) + } + return null +} + +function handleMention(el: Node) { + // Redirect mentions to the user page + if (el.name === 'a' && el.attributes.class?.includes('mention')) { + const href = el.attributes.href + if (href) { + const matchUser = href.match(UserLinkRE) + if (matchUser) { + const [, server, username] = matchUser + const handle = `@${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}` + el.attributes.href = `/${server}/@${username}` + return h(AccountHoverWrapper, { handle, class: 'inline-block' }, () => nodeToVNode(el)) + } + const matchTag = href.match(TagLinkRE) + if (matchTag) { + const [, , name] = matchTag + el.attributes.href = `/${currentServer.value}/tags/${name}` + } + } + } + return undefined +} + +function handleCodeBlock(el: Node) { + if (el.name === 'pre' && el.children[0]?.name === 'code') { + const codeEl = el.children[0] as Node + const classes = codeEl.attributes.class as string + const lang = classes?.split(/\s/g).find(i => i.startsWith('language-'))?.replace('language-', '') + const code = codeEl.children[0] ? treeToText(codeEl.children[0]) : '' + return h(ContentCode, { lang, code: encodeURIComponent(code) }) + } +} + +function handleNode(el: Node) { + return handleCodeBlock(el) || handleMention(el) || el +} diff --git a/composables/dialog.ts b/composables/dialog.ts index 0afd2df5..3caa25bc 100644 --- a/composables/dialog.ts +++ b/composables/dialog.ts @@ -1,5 +1,5 @@ import type { Attachment, StatusEdit } from 'masto' -import type { Draft } from './statusDrafts' +import type { Draft } from '~/types' import { STORAGE_KEY_FIRST_VISIT, STORAGE_KEY_ZEN_MODE } from '~/constants' export const mediaPreviewList = ref([]) diff --git a/composables/statusDrafts.ts b/composables/statusDrafts.ts index d41cd310..f21e448f 100644 --- a/composables/statusDrafts.ts +++ b/composables/statusDrafts.ts @@ -1,16 +1,6 @@ -import type { Account, Attachment, CreateStatusParams, Status } from 'masto' +import type { Account, Status } from 'masto' import { STORAGE_KEY_DRAFTS } from '~/constants' -import type { Mutable } from '~/types/utils' - -export interface Draft { - editingStatus?: Status - initialText?: string - params: Omit, 'status'> & { - status?: Exclude - } - attachments: Attachment[] -} -export type DraftMap = Record +import type { Draft, DraftMap } from '~/types' export const currentUserDrafts = useUserLocalStorage(STORAGE_KEY_DRAFTS, () => ({})) diff --git a/composables/users.ts b/composables/users.ts index bd93e5f5..1fedb793 100644 --- a/composables/users.ts +++ b/composables/users.ts @@ -147,7 +147,11 @@ export const useNotifications = () => { watch(currentUser, disconnect) connect() - return { notifications: computed(() => id ? notifications[id]?.[1] ?? 0 : 0), disconnect, clearNotifications } + return { + notifications: computed(() => id ? notifications[id]?.[1] ?? 0 : 0), + disconnect, + clearNotifications, + } } export function checkLogin() { @@ -188,7 +192,8 @@ export function clearUserLocalStorage(account?: Account) { return const id = `${account.acct}@${currentUser.value?.server}` - userLocalStorages.forEach((storage) => { + // @ts-expect-error bind value to the function + ;(useUserLocalStorage._ as Map>>).forEach((storage) => { if (storage.value[id]) delete storage.value[id] }) diff --git a/package.json b/package.json index c31ac376..a8a69900 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:mocked": "nuxi dev --port 5314 --dotenv .env.mock", "start": "PORT=5314 node .output/server/index.mjs", "lint": "eslint .", + "typecheck": "nuxi typecheck", "prepare": "esno scripts/prepare.ts", "generate": "nuxi generate", "test:unit": "vitest", diff --git a/tests/content-rich.test.ts b/tests/content-rich.test.ts index 395d3a9e..0ffe9cf1 100644 --- a/tests/content-rich.test.ts +++ b/tests/content-rich.test.ts @@ -6,7 +6,7 @@ import type { Emoji } from 'masto' import { describe, expect, it, vi } from 'vitest' import { renderToString } from 'vue/server-renderer' import { format } from 'prettier' -import { contentToVNode } from '~/composables/content' +import { contentToVNode } from '~/composables/content-render' describe('content-rich', () => { it('empty', async () => { @@ -85,7 +85,11 @@ vi.mock('vue-router', () => { } }) -vi.mock('../components/content/ContentCode.vue', () => { +vi.mock('~/composables/dialog.ts', () => { + return {} +}) + +vi.mock('~/components/content/ContentCode.vue', () => { return { default: defineComponent({ props: { @@ -95,7 +99,6 @@ vi.mock('../components/content/ContentCode.vue', () => { }, lang: { type: String, - required: true, }, }, setup(props) { @@ -106,9 +109,10 @@ vi.mock('../components/content/ContentCode.vue', () => { } }) -vi.mock('../components/account/AccountHoverWrapper.vue', () => { +vi.mock('~/components/account/AccountHoverWrapper.vue', () => { return { default: defineComponent({ + props: ['handle', 'class'], setup(_, { slots }) { return () => slots?.default?.() }, diff --git a/tests/html-parse.test.ts b/tests/html-parse.test.ts index 96737be8..354cbacd 100644 --- a/tests/html-parse.test.ts +++ b/tests/html-parse.test.ts @@ -5,7 +5,7 @@ import type { Emoji } from 'masto' import { describe, expect, it } from 'vitest' import { format } from 'prettier' import { render as renderTree } from 'ultrahtml' -import { parseMastodonHTML, treeToText } from '~/composables/content' +import { parseMastodonHTML, treeToText } from '~/composables/content-parse' describe('html-parse', () => { it('empty', async () => { diff --git a/tests/html-to-text.test.ts b/tests/html-to-text.test.ts index aeb80ad5..04a65914 100644 --- a/tests/html-to-text.test.ts +++ b/tests/html-to-text.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, expect, it } from 'vitest' -import { htmlToText } from '../composables/content' +import { htmlToText } from '~/composables/content-parse' describe('html-to-text', () => { it('inline code', () => { diff --git a/types/index.ts b/types/index.ts index e9fa2bea..eb90e569 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,4 +1,5 @@ -import type { Account, AccountCredentials, Emoji, Instance, Notification, Status } from 'masto' +import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, Notification, Status } from 'masto' +import type { Mutable } from './utils' export interface AppInfo { id: string @@ -46,3 +47,13 @@ export interface GroupedLikeNotifications { export type NotificationSlot = GroupedNotifications | GroupedLikeNotifications | Notification export type TranslateFn = ReturnType['t'] + +export interface Draft { + editingStatus?: Status + initialText?: string + params: Omit, 'status'> & { + status?: Exclude + } + attachments: Attachment[] +} +export type DraftMap = Record