diff --git a/.gitignore b/.gitignore index 467d4664..0abf0b44 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist .output .nuxt .env + +public/shiki diff --git a/components/common/CommonRichContent.ts b/components/common/CommonRichContent.ts index 4422cc51..a72bdd06 100644 --- a/components/common/CommonRichContent.ts +++ b/components/common/CommonRichContent.ts @@ -18,7 +18,7 @@ export default defineComponent({ return () => h( 'div', { class: 'rich-content' }, - contentToVNode(props.content, undefined, emojiObject), + contentToVNode(props.content, emojiObject), ) }, }) diff --git a/components/content/ContentCode.vue b/components/content/ContentCode.vue new file mode 100644 index 00000000..942ef15a --- /dev/null +++ b/components/content/ContentCode.vue @@ -0,0 +1,22 @@ + + + diff --git a/composables/content.ts b/composables/content.ts index 7e786ca2..9aace7ae 100644 --- a/composables/content.ts +++ b/composables/content.ts @@ -1,14 +1,19 @@ import type { Emoji } from 'masto' import type { DefaultTreeAdapterMap } from 'parse5' import { parseFragment } from 'parse5' -import type { VNode } from 'vue' -import { Fragment, h } from 'vue' +import type { Component, VNode } from 'vue' +import { Fragment, h, isVNode } from 'vue' import { RouterLink } from 'vue-router' +import ContentCode from '~/components/content/ContentCode.vue' type Node = DefaultTreeAdapterMap['childNode'] type Element = DefaultTreeAdapterMap['element'] -export function defaultHandle(el: 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'))) { const href = el.attrs.find(i => i.name === 'href') @@ -26,36 +31,58 @@ export function defaultHandle(el: Element) { } } } - return el + 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 handleNode(el: Element) { + return handleBlocks(el) || handleMention(el) || el } export function contentToVNode( content: string, - handle: (node: Element) => Element | undefined | null | void = defaultHandle, customEmojis: Record = {}, ): VNode { - content = content.trim().replace(/:([\w-]+?):/g, (_, name) => { - const emoji = customEmojis[name] - if (emoji) - return `${name}` - return `:${name}:` - }) + content = content + .trim() + // handle custom emojis + .replace(/:([\w-]+?):/g, (_, name) => { + const emoji = customEmojis[name] + if (emoji) + return `${name}` + return `:${name}:` + }) + // handle codeblocks + .replace(/

(```|~~~)([\s\S]+?)\1(\s|)*<\/p>/g, (_1, _2, raw) => { + const plain = htmlToText(`

${raw}

`).trim() + const [lang, ...rest] = plain.split(/\n/) + return `` + }) + const tree = parseFragment(content) - return h(Fragment, tree.childNodes.map(n => treeToVNode(n, handle))) + return h(Fragment, tree.childNodes.map(n => treeToVNode(n))) } 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) + const node = handleNode(input) if (node == null) return null + if (isVNode(node)) + return node const attrs = Object.fromEntries(node.attrs.map(i => [i.name, i.value])) if (node.nodeName === 'a' && (attrs.href?.startsWith('/') || attrs.href?.startsWith('.'))) { @@ -65,14 +92,38 @@ export function treeToVNode( return h( RouterLink as any, attrs, - () => node.childNodes.map(n => treeToVNode(n, handle)), + () => node.childNodes.map(treeToVNode), ) } return h( node.nodeName, attrs, - node.childNodes.map(n => treeToVNode(n, handle)), + node.childNodes.map(treeToVNode), ) } return null } + +function htmlToText(html: string) { + const tree = parseFragment(html) + return tree.childNodes.map(n => treeToText(n)).join('') +} + +function treeToText(input: Node): string { + let pre = '' + + if (input.nodeName === '#text') + // @ts-expect-error casing + return input.value + + if (input.nodeName === 'br') + return '\n' + + if (input.nodeName === 'p') + pre = '\n' + + if ('childNodes' in input) + return pre + input.childNodes.map(n => treeToText(n)).join('') + + return pre +} diff --git a/composables/shiki.ts b/composables/shiki.ts new file mode 100644 index 00000000..975f84ca --- /dev/null +++ b/composables/shiki.ts @@ -0,0 +1,37 @@ +import type { Highlighter, Lang } from 'shiki' + +export const shiki = ref() + +const registeredLang = ref(new Map()) +let shikiImport: Promise | undefined + +export function highlightCode(code: string, lang: Lang) { + if (!shikiImport) { + shikiImport = import('shiki') + .then(async (r) => { + r.setCDN('/shiki/') + shiki.value = await r.getHighlighter({ + themes: [ + 'vitesse-dark', + 'vitesse-light', + ], + }) + }) + } + + if (!shiki.value) + return code + + if (!registeredLang.value.get(lang)) { + shiki.value.loadLanguage(lang) + .then(() => { + registeredLang.value.set(lang, true) + }) + return code + } + + return shiki.value.codeToHtml(code, { + lang, + theme: isDark.value ? 'vitesse-dark' : 'vitesse-light', + }) +} diff --git a/composables/utils.ts b/composables/utils.ts index a2eaf2a0..f29426ec 100644 --- a/composables/utils.ts +++ b/composables/utils.ts @@ -23,3 +23,4 @@ export function getDataUrlFromArr(arr: Uint8ClampedArray, w: number, h: number) export function emojisArrayToObject(emojis: Emoji[]) { return Object.fromEntries(emojis.map(i => [i.shortcode, i])) } + diff --git a/nuxt.config.ts b/nuxt.config.ts index d999c021..6ca8185a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -20,12 +20,12 @@ export default defineNuxtConfig({ }, vite: { define: { - __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + '__BUILD_TIME__': JSON.stringify(new Date().toISOString()), + 'process.env.VSCODE_TEXTMATE_DEBUG': 'false', }, build: { target: 'esnext', }, - }, postcss: { plugins: { diff --git a/package.json b/package.json index c8093823..032d0317 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "node .output/server/index.mjs", "lint": "eslint .", "postinstall": "nuxi prepare", + "prepare": "esno scripts/prepare.ts", "generate": "nuxi generate" }, "devDependencies": { @@ -18,6 +19,7 @@ "@iconify-json/twemoji": "^1.1.5", "@pinia/nuxt": "^0.4.3", "@types/fs-extra": "^9.0.13", + "@types/js-yaml": "^4.0.5", "@types/sanitize-html": "^2.6.2", "@types/wicg-file-system-access": "^2020.9.5", "@unocss/nuxt": "^0.46.5", @@ -28,6 +30,7 @@ "esno": "^0.16.3", "form-data": "^4.0.0", "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", "masto": "^4.6.6", "nuxt": "^3.0.0", "parse5": "^7.1.1", @@ -35,6 +38,8 @@ "postcss-nested": "^6.0.0", "rollup-plugin-node-polyfills": "^0.2.1", "sanitize-html": "^2.7.3", + "shiki": "^0.11.1", + "theme-vitesse": "^0.6.0", "typescript": "^4.9.3", "ufo": "^1.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9083aff2..2851da24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,8 @@ lockfileVersion: 5.4 +overrides: + debug: 4.3.4 + specifiers: '@antfu/eslint-config': ^0.30.1 '@iconify-json/carbon': ^1.1.10 @@ -8,6 +11,7 @@ specifiers: '@iconify-json/twemoji': ^1.1.5 '@pinia/nuxt': ^0.4.3 '@types/fs-extra': ^9.0.13 + '@types/js-yaml': ^4.0.5 '@types/sanitize-html': ^2.6.2 '@types/wicg-file-system-access': ^2020.9.5 '@unocss/nuxt': ^0.46.5 @@ -18,6 +22,7 @@ specifiers: esno: ^0.16.3 form-data: ^4.0.0 fs-extra: ^10.1.0 + js-yaml: ^4.1.0 masto: ^4.6.6 nuxt: ^3.0.0 parse5: ^7.1.1 @@ -25,6 +30,8 @@ specifiers: postcss-nested: ^6.0.0 rollup-plugin-node-polyfills: ^0.2.1 sanitize-html: ^2.7.3 + shiki: ^0.11.1 + theme-vitesse: ^0.6.0 typescript: ^4.9.3 ufo: ^1.0.0 @@ -36,6 +43,7 @@ devDependencies: '@iconify-json/twemoji': 1.1.5 '@pinia/nuxt': 0.4.3_typescript@4.9.3 '@types/fs-extra': 9.0.13 + '@types/js-yaml': 4.0.5 '@types/sanitize-html': 2.6.2 '@types/wicg-file-system-access': 2020.9.5 '@unocss/nuxt': 0.46.5 @@ -46,6 +54,7 @@ devDependencies: esno: 0.16.3 form-data: 4.0.0 fs-extra: 10.1.0 + js-yaml: 4.1.0 masto: 4.6.6 nuxt: 3.0.0_e3uo4sehh4zr4i6m57mkkxxv7y parse5: 7.1.1 @@ -53,6 +62,8 @@ devDependencies: postcss-nested: 6.0.0 rollup-plugin-node-polyfills: 0.2.1 sanitize-html: 2.7.3 + shiki: 0.11.1 + theme-vitesse: 0.6.0 typescript: 4.9.3 ufo: 1.0.0 @@ -1255,6 +1266,10 @@ packages: '@types/node': 18.7.23 dev: true + /@types/js-yaml/4.0.5: + resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} + dev: true + /@types/json-schema/7.0.11: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true @@ -2883,28 +2898,6 @@ packages: engines: {node: '>= 12'} dev: true - /debug/2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.0.0 - dev: true - - /debug/3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.3 - dev: true - /debug/4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -3432,7 +3425,7 @@ packages: /eslint-import-resolver-node/0.3.6: resolution: {integrity: sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==} dependencies: - debug: 3.2.7 + debug: 4.3.4 resolve: 1.22.1 transitivePeerDependencies: - supports-color @@ -3460,7 +3453,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 5.42.1_e3uo4sehh4zr4i6m57mkkxxv7y - debug: 3.2.7 + debug: 4.3.4 eslint: 8.27.0 eslint-import-resolver-node: 0.3.6 transitivePeerDependencies: @@ -3518,7 +3511,7 @@ packages: '@typescript-eslint/parser': 5.42.1_e3uo4sehh4zr4i6m57mkkxxv7y array-includes: 3.1.5 array.prototype.flat: 1.3.0 - debug: 2.6.9 + debug: 4.3.4 doctrine: 2.1.0 eslint: 8.27.0 eslint-import-resolver-node: 0.3.6 @@ -5160,10 +5153,6 @@ packages: engines: {node: '>=10'} dev: true - /ms/2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: true - /ms/2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true @@ -6497,7 +6486,7 @@ packages: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} dependencies: - debug: 2.6.9 + debug: 4.3.4 depd: 2.0.0 destroy: 1.2.0 encodeurl: 1.0.2 @@ -6566,6 +6555,14 @@ packages: engines: {node: '>=8'} dev: true + /shiki/0.11.1: + resolution: {integrity: sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA==} + dependencies: + jsonc-parser: 3.2.0 + vscode-oniguruma: 1.6.2 + vscode-textmate: 6.0.0 + dev: true + /side-channel/1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -6882,6 +6879,11 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /theme-vitesse/0.6.0: + resolution: {integrity: sha512-/XEZFGXLTK/AlWSe9t+NIXB1tP3yqdzugcSJJ2Fg0KYM1PcoL/zWs5AuaEcCFt1pfi/9Og++tzOdiU2aKf/+Xw==} + engines: {vscode: ^1.43.0} + dev: true + /through/2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true @@ -7502,6 +7504,14 @@ packages: vscode-languageserver-protocol: 3.16.0 dev: true + /vscode-oniguruma/1.6.2: + resolution: {integrity: sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==} + dev: true + + /vscode-textmate/6.0.0: + resolution: {integrity: sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ==} + dev: true + /vscode-uri/3.0.6: resolution: {integrity: sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ==} dev: true diff --git a/scripts/prepare.ts b/scripts/prepare.ts new file mode 100644 index 00000000..f7a2fe7d --- /dev/null +++ b/scripts/prepare.ts @@ -0,0 +1,10 @@ +import { copy } from 'fs-extra' + +const dereference = process.platform === 'win32' ? true : undefined + +await copy('node_modules/shiki/', 'public/shiki/', { + dereference, + filter: src => src === 'node_modules/shiki/' || src.includes('languages') || src.includes('dist'), +}) +await copy('node_modules/theme-vitesse/themes', 'public/shiki/themes', { dereference }) +await copy('node_modules/theme-vitesse/themes', 'node_modules/shiki/themes', { overwrite: true, dereference }) diff --git a/styles/global.css b/styles/global.css index 7441eb18..79dba27d 100644 --- a/styles/global.css +++ b/styles/global.css @@ -1,5 +1,5 @@ * { - scrollbar-color: #5555 var(--c-border); + scrollbar-color: #8885 var(--c-border); } ::-webkit-scrollbar { @@ -16,12 +16,12 @@ } ::-webkit-scrollbar-thumb { - background: #555; + background: #8885; border-radius: 1px; } ::-webkit-scrollbar-thumb:hover { - background: #666; + background: #8886; } /* Force vertical scrollbar to be always visible to avoid layout shift while loading the content */ @@ -52,4 +52,12 @@ html { p { --at-apply: my-2; } + + .code-block { + --at-apply: bg-code text-0.9rem p3 rounded overflow-auto leading-1.6em; + + .shiki { + background: transparent !important; + } + } } diff --git a/styles/vars.css b/styles/vars.css index e38e7a1a..57644227 100644 --- a/styles/vars.css +++ b/styles/vars.css @@ -4,6 +4,7 @@ --c-border: #88888820; --c-bg-base: #fff; --c-bg-active: #f6f6f6; + --c-bg-code: #00000006; --c-text-base: #222; --c-text-secondary: #888; } @@ -11,5 +12,6 @@ .dark { --c-bg-base: #111; --c-bg-active: #151515; + --c-bg-code: #ffffff06; --c-text-base: #fff; } diff --git a/unocss.config.ts b/unocss.config.ts index 6faa57b0..5a3b742e 100644 --- a/unocss.config.ts +++ b/unocss.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'border-base': 'border-$c-border', 'bg-base': 'bg-$c-bg-base', 'bg-active': 'bg-$c-bg-active', + 'bg-code': 'bg-$c-bg-code', 'text-base': 'text-$c-text-base', 'text-secondary': 'text-$c-text-secondary', 'interact-disabled': 'disabled:opacity-50 disabled:pointer-events-none disabled:saturate-0',