Skip to content
Snippets Groups Projects
Commit d61275bf authored by Kasper Seweryn's avatar Kasper Seweryn 🥞
Browse files

Add sanitized html and markdown renderer components

parent cd0e3642
No related branches found
No related tags found
1 merge request!1Implement all components
Pipeline #23546 failed with stages
in 10 minutes and 33 seconds
......@@ -16,10 +16,14 @@
},
"dependencies": {
"@vueuse/core": "^9.2.0",
"dompurify": "^2.4.0",
"showdown": "^2.1.0",
"vue": "^3.2.38"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.3",
"@types/dompurify": "^2.3.4",
"@types/showdown": "^2.0.0",
"@vitejs/plugin-vue": "^3.0.3",
"@vitest/coverage-c8": "^0.22.1",
"@vue/test-utils": "^2.0.2",
......
......@@ -27,3 +27,7 @@ export { default as FwInput } from './input/Input.vue'
// Pills
export { default as FwPill } from './pill/Pill.vue'
// Utils
export { default as FwSanitizedHtml } from './utils/SanitizedHtml.vue'
export { default as FwMarkdown } from './utils/Markdown.vue'
<script setup lang="ts">
import { useVModel, useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import { FwButton } from '~/components'
import { nextTick, computed, type ComputedRef } from 'vue'
import { nextTick, computed, ref, type ComputedRef } from 'vue'
interface Events {
(e: 'update:modelValue', value: string): void
......@@ -18,6 +18,7 @@ const props = withDefaults(defineProps<Props>(), { max: Infinity })
const value = useVModel(props, 'modelValue', emit)
const { undo, redo, commit, last } = useManualRefHistory(value)
const { textarea } = useTextareaAutosize({ input: value })
const preview = ref(false)
watchDebounced(value, (value) => {
if (value !== last.value.snapshot) {
......@@ -174,8 +175,10 @@ const link = async () => {
<template>
<div
:class="{ 'has-preview': preview }"
class="funkwhale textarea"
>
<fw-markdown :md="value" class="preview" />
<textarea
ref="textarea"
@click="updateLineNumber"
......@@ -195,7 +198,7 @@ const link = async () => {
id="textarea_id"
/>
<div class="textarea-buttons">
<fw-button icon="bi-eye" secondary />
<fw-button @click="preview = !preview" icon="bi-eye" secondary :is-active="preview" />
<div class="separator" />
......
......@@ -23,6 +23,7 @@
}
}
&.has-preview,
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
background-color: var(--fw-bg-color) !important;
......@@ -34,12 +35,31 @@
}
}
&.has-preview > .preview {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
> .preview {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 40px;
background-color: inherit;
opacity: 0;
transform: translateY(.5rem);
pointer-events: none;
transition: all .2s ease;
}
> textarea {
display: block;
width: 100%;
min-height: 240px;
padding: 8px 12px 40px;
font-family: $font-main;
font-family: monospace;
background: transparent;
}
......
<script setup lang="ts">
import { FwSanitizedHtml } from '~/components'
import { computed } from 'vue'
import showdown from 'showdown'
interface Props {
md: string
}
const props = defineProps<Props>()
showdown.extension('openExternalInNewTab', {
type: 'output',
regex: /<a.+?href.+">/g,
replace (text: string) {
const matches = text.match(/href="(.+)">/) ?? []
const url = matches[1] ?? './'
if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) {
return text
}
const { hostname } = new URL(url)
return hostname !== location.hostname
? text.replace(matches[0], `href="${url}" target="_blank" rel="noopener noreferrer">`)
: text
}
})
showdown.extension('linkifyTags', {
type: 'language',
regex: /#[^\W]+/g,
replace (text: string) {
return `<a href="/library/tags/${text.slice(1)}">${text}</a>`
}
})
const markdown = new showdown.Converter({
extensions: ['openExternalInNewTab', 'linkifyTags'],
ghMentions: true,
ghMentionsLink: '/@{u}',
simplifiedAutoLink: true,
openLinksInNewWindow: false,
simpleLineBreaks: true,
strikethrough: true,
tables: true,
tasklists: true,
underline: true,
noHeaderId: true,
headerLevelStart: 3,
literalMidWordUnderscores: true,
excludeTrailingPunctuationFromURLs: true,
encodeEmails: true,
emoji: true
})
const html = computed(() => markdown.makeHtml(props.md))
</script>
<template>
<fw-sanitized-html :html="html" />
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { computed, h } from 'vue'
interface Props {
tag?: string
html: string
}
const props = withDefaults(defineProps<Props>(), {
tag: 'div'
})
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// set all elements owning target to target=_blank
if ('target' in node) {
node.setAttribute('target', '_blank')
}
})
const html = computed(() => DOMPurify.sanitize(props.html))
const root = () => h(props.tag, { innerHTML: html.value })
</script>
<template>
<root />
</template>
......@@ -263,6 +263,13 @@
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
"@types/dompurify@^2.3.4":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg==
dependencies:
"@types/trusted-types" "*"
"@types/istanbul-lib-coverage@^2.0.1":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
......@@ -273,6 +280,16 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.16.tgz#e3733f46797b9df9e853ca9f719c8a6f7b84cd26"
integrity sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA==
"@types/showdown@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2"
integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA==
"@types/trusted-types@*":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
"@types/web-bluetooth@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz#d60330046a6ed8a13b4a53df3813c44942ebdf72"
......@@ -704,6 +721,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
commander@^9.0.0:
version "9.4.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c"
integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
......@@ -792,6 +814,11 @@ domexception@^4.0.0:
dependencies:
webidl-conversions "^7.0.0"
dompurify@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
......@@ -1535,6 +1562,13 @@ shiki@^0.11.1:
vscode-oniguruma "^1.6.1"
vscode-textmate "^6.0.0"
showdown@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.1.0.tgz#1251f5ed8f773f0c0c7bfc8e6fd23581f9e545c5"
integrity sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==
dependencies:
commander "^9.0.0"
signal-exit@^3.0.2:
version "3.0.7"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment