feat: support codeblock

This commit is contained in:
Anthony Fu 2022-11-24 11:42:03 +08:00
parent 4885b165df
commit 0a8841f4f4
13 changed files with 201 additions and 52 deletions

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ dist
.output
.nuxt
.env
public/shiki

View file

@ -18,7 +18,7 @@ export default defineComponent({
return () => h(
'div',
{ class: 'rich-content' },
contentToVNode(props.content, undefined, emojiObject),
contentToVNode(props.content, emojiObject),
)
},
})

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
const props = defineProps<{
code: string
lang: string
}>()
const raw = computed(() => decodeURIComponent(props.code).replace(/&#39;/g, '\''))
const langMap: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
vue: 'html',
}
const hightlighted = computed(() => {
return highlightCode(raw.value, langMap[props.lang] || props.lang as any)
})
</script>
<template>
<pre class="code-block" v-html="hightlighted" />
</template>

View file

@ -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<string, Component> = {
'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<string, Emoji> = {},
): VNode {
content = content.trim().replace(/:([\w-]+?):/g, (_, name) => {
content = content
.trim()
// handle custom emojis
.replace(/:([\w-]+?):/g, (_, name) => {
const emoji = customEmojis[name]
if (emoji)
return `<img src="${emoji.url}" alt="${name}" class="custom-emoji" />`
return `:${name}:`
})
// handle codeblocks
.replace(/<p>(```|~~~)([\s\S]+?)\1(\s|<br\s?\/?>)*<\/p>/g, (_1, _2, raw) => {
const plain = htmlToText(`<p>${raw}</p>`).trim()
const [lang, ...rest] = plain.split(/\n/)
return `<custom-code lang="${lang || ''}" code="${encodeURIComponent(rest.join('\n'))}" />`
})
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
}

37
composables/shiki.ts Normal file
View file

@ -0,0 +1,37 @@
import type { Highlighter, Lang } from 'shiki'
export const shiki = ref<Highlighter>()
const registeredLang = ref(new Map<string, boolean>())
let shikiImport: Promise<void> | 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',
})
}

View file

@ -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]))
}

View file

@ -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: {

View file

@ -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"
}

View file

@ -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

10
scripts/prepare.ts Normal file
View file

@ -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 })

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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',