Comments now support rich text formatting display (#7674)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.21.x

#### What this PR does / why we need it:

Comments now support rich text format display.

Still need to:

1. Test for XSS vulnerabilities
2. Optimize content styling
3. Editor

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7671

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
评论内容支持以富文本格式显示
```
pull/7681/head
Ryan Wang 2025-08-12 14:26:47 +08:00 committed by GitHub
parent 535fe01624
commit 09cd1f7f74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 403 additions and 88 deletions

View File

@ -19,9 +19,10 @@ import { useQueryClient } from "@tanstack/vue-query";
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
import { computed, ref, useTemplateRef } from "vue";
import { useI18n } from "vue-i18n";
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
import { useSubjectRef } from "../composables/use-subject-ref";
import CommentEditor from "./CommentEditor.vue";
import OwnerButton from "./OwnerButton.vue";
import ReplyFormItems from "./ReplyFormItems.vue";
const props = withDefaults(
defineProps<{
@ -48,10 +49,19 @@ const creationTime = computed(() => {
);
});
const newReply = ref("");
const editorContent = ref("");
const editorCharacterCount = ref(0);
function onCommentEditorUpdate(value: {
content: string;
characterCount: number;
}) {
editorContent.value = value.content;
editorCharacterCount.value = value.characterCount;
}
async function handleApprove() {
if (!newReply.value) {
if (!editorCharacterCount.value) {
await coreApiClient.content.comment.patchComment({
name: props.comment.comment.metadata.name,
jsonPatchInner: [
@ -71,8 +81,8 @@ async function handleApprove() {
await consoleApiClient.content.comment.createReply({
name: props.comment?.comment.metadata.name as string,
replyRequest: {
raw: newReply.value,
content: newReply.value,
raw: editorContent.value,
content: editorContent.value,
allowNotification: true,
quoteReply: undefined,
},
@ -88,6 +98,8 @@ const { subjectRefResult } = useSubjectRef(props.comment);
const websiteOfAnonymous = computed(() => {
return props.comment.comment.spec.owner.annotations?.["website"];
});
const { data: contentProvider } = useContentProviderExtensionPoint();
</script>
<template>
<VModal
@ -165,20 +177,17 @@ const websiteOfAnonymous = computed(() => {
<VDescriptionItem
:label="$t('core.comment.comment_detail_modal.fields.content')"
>
<pre class="whitespace-pre-wrap break-words text-sm text-gray-900">{{
comment.comment.spec.content
}}</pre>
<component
:is="contentProvider?.component"
:content="comment.comment.spec.content"
/>
</VDescriptionItem>
<HasPermission :permissions="['system:comments:manage']">
<VDescriptionItem
v-if="!comment.comment.spec.approved"
:label="$t('core.comment.detail_modal.fields.new_reply')"
>
<ReplyFormItems
:required="false"
:auto-focus="false"
@update="newReply = $event"
/>
<CommentEditor @update="onCommentEditorUpdate" />
</VDescriptionItem>
</HasPermission>
</VDescription>
@ -192,7 +201,7 @@ const websiteOfAnonymous = computed(() => {
@click="handleApprove"
>
{{
newReply
editorCharacterCount > 0
? $t("core.comment.operations.reply_and_approve.button")
: $t("core.comment.operations.approve.button")
}}

View File

@ -0,0 +1,65 @@
<script lang="ts" setup>
import { usePluginModuleStore } from "@/stores/plugin";
import { VLoading } from "@halo-dev/components";
import type { CommentEditorProvider } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import { markRaw } from "vue";
import DefaultCommentEditor from "./DefaultCommentEditor.vue";
withDefaults(
defineProps<{
autoFocus?: boolean;
}>(),
{
autoFocus: false,
}
);
const defaultProvider: CommentEditorProvider = {
component: markRaw(DefaultCommentEditor),
};
const { pluginModules } = usePluginModuleStore();
const emit = defineEmits<{
(event: "update", value: { content: string; characterCount: number }): void;
}>();
const { data: provider, isLoading } = useQuery({
queryKey: ["core:comment:provider"],
queryFn: async () => {
const result: CommentEditorProvider[] = [];
for (const pluginModule of pluginModules) {
const callbackFunction =
pluginModule?.extensionPoints?.["comment:editor:replace"];
if (typeof callbackFunction !== "function") {
continue;
}
const item = await callbackFunction();
result.push(item);
}
if (result.length) {
return result[0];
}
return defaultProvider;
},
});
function onUpdate(value: { content: string; characterCount: number }) {
emit("update", value);
}
</script>
<template>
<VLoading v-if="isLoading" />
<component
:is="provider?.component"
v-else
:auto-focus="autoFocus"
@update="onUpdate"
/>
</template>

View File

@ -27,6 +27,7 @@ import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, markRaw, provide, ref, type Ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { useCommentLastReadTimeMutate } from "../composables/use-comment-last-readtime-mutate";
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
import { useSubjectRef } from "../composables/use-subject-ref";
import CommentDetailModal from "./CommentDetailModal.vue";
import OwnerButton from "./OwnerButton.vue";
@ -257,6 +258,8 @@ const { operationItems } = useOperationItemExtensionPoint<ListedComment>(
},
])
);
const { data: contentProvider } = useContentProviderExtensionPoint();
</script>
<template>
@ -305,10 +308,10 @@ const { operationItems } = useOperationItemExtensionPoint<ListedComment>(
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</div>
<pre
class="whitespace-pre-wrap break-words text-sm text-gray-900"
>{{ comment?.comment?.spec.content }}</pre
>
<component
:is="contentProvider?.component"
:content="comment?.comment?.spec.content"
/>
<div class="flex items-center gap-3 text-xs">
<span
class="cursor-pointer select-none text-gray-700 hover:text-gray-900"

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import sanitizeHtml from "sanitize-html";
defineProps<{
content: string;
}>();
</script>
<template>
<div
class="comment-content markdown-body whitespace-pre-wrap rounded-lg !bg-transparent !text-sm !text-gray-900"
v-html="sanitizeHtml(content)"
></div>
</template>
<style scoped lang="scss">
.comment-content :deep(ul) {
list-style: disc !important;
}
.comment-content :deep(ol) {
list-style: decimal !important;
}
</style>

View File

@ -2,22 +2,19 @@
import { setFocus } from "@/formkit/utils/focus";
import i18n from "@emoji-mart/data/i18n/zh.json";
import { IconMotionLine, VDropdown } from "@halo-dev/components";
import { Picker } from "emoji-mart";
import { onMounted, ref, watch } from "vue";
const props = withDefaults(
defineProps<{
required?: boolean;
autoFocus?: boolean;
}>(),
{
required: true,
autoFocus: true,
}
);
const emit = defineEmits<{
(e: "update", value: string): void;
(event: "update", value: { content: string; characterCount: number }): void;
}>();
const emojiPickerRef = ref<HTMLElement | null>(null);
@ -27,6 +24,7 @@ const handleCreateEmojiPicker = async () => {
return;
}
const { Picker } = await import("emoji-mart");
const data = await import("@emoji-mart/data");
const emojiPicker = new Picker({
@ -56,7 +54,10 @@ onMounted(() => {
watch(
() => raw.value,
(value) => {
emit("update", value);
emit("update", {
content: value,
characterCount: value.length,
});
}
);
</script>
@ -69,7 +70,6 @@ watch(
:validation-label="$t('core.comment.reply_modal.fields.content.label')"
:rows="6"
value=""
:validation="['length:0,1024', required ? 'required' : ''].join('|')"
></FormKit>
<div class="flex w-full justify-end sm:max-w-lg">
<VDropdown :classes="['!p-0']" @show="handleCreateEmojiPicker">

View File

@ -5,7 +5,7 @@ import { consoleApiClient } from "@halo-dev/api-client";
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import ReplyFormItems from "./ReplyFormItems.vue";
import CommentEditor from "./CommentEditor.vue";
const { t } = useI18n();
@ -27,16 +27,18 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof VModal> | null>(null);
const isSubmitting = ref(false);
const characterCount = ref(0);
const content = ref("");
const onSubmit = async (data: { raw: string }) => {
const handleSubmit = async () => {
try {
isSubmitting.value = true;
await consoleApiClient.content.comment.createReply({
name: props.comment?.comment.metadata.name as string,
replyRequest: {
raw: data.raw,
content: data.raw,
raw: content.value,
content: content.value,
allowNotification: true,
quoteReply: props.reply?.reply.metadata.name,
},
@ -53,35 +55,33 @@ const onSubmit = async (data: { raw: string }) => {
isSubmitting.value = false;
}
};
function onUpdate(value: { content: string; characterCount: number }) {
content.value = value.content;
characterCount.value = value.characterCount;
}
</script>
<template>
<VModal
ref="modal"
:title="$t('core.comment.reply_modal.title')"
:width="500"
:width="600"
:mount-to-body="true"
:centered="false"
@close="emit('close')"
>
<FormKit
id="create-reply-form"
name="create-reply-form"
type="form"
:config="{ validationVisibility: 'submit' }"
:classes="{
form: '!divide-none',
}"
@submit="onSubmit"
>
<ReplyFormItems />
</FormKit>
<div>
<CommentEditor :auto-focus="true" @update="onUpdate" />
</div>
<template #footer>
<VSpace>
<SubmitButton
:loading="isSubmitting"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('create-reply-form')"
:disabled="characterCount === 0"
@submit="handleSubmit"
>
</SubmitButton>
<VButton @click="modal?.close()">

View File

@ -18,11 +18,13 @@ import {
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
import sanitizeHtml from "sanitize-html";
import { computed, ref, useTemplateRef } from "vue";
import { useI18n } from "vue-i18n";
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
import { useSubjectRef } from "../composables/use-subject-ref";
import CommentEditor from "./CommentEditor.vue";
import OwnerButton from "./OwnerButton.vue";
import ReplyFormItems from "./ReplyFormItems.vue";
const props = withDefaults(
defineProps<{
@ -53,10 +55,19 @@ const creationTime = computed(() => {
);
});
const newReply = ref("");
const editorContent = ref("");
const editorCharacterCount = ref(0);
function onCommentEditorUpdate(value: {
content: string;
characterCount: number;
}) {
editorContent.value = value.content;
editorCharacterCount.value = value.characterCount;
}
async function handleApprove() {
if (!newReply.value) {
if (!editorCharacterCount.value) {
await coreApiClient.content.reply.patchReply({
name: props.reply.reply.metadata.name,
jsonPatchInner: [
@ -76,8 +87,8 @@ async function handleApprove() {
await consoleApiClient.content.comment.createReply({
name: props.comment?.comment.metadata.name as string,
replyRequest: {
raw: newReply.value,
content: newReply.value,
raw: editorContent.value,
content: editorContent.value,
allowNotification: true,
quoteReply: props.reply.reply.metadata.name,
},
@ -95,6 +106,8 @@ const { subjectRefResult } = useSubjectRef(props.comment);
const websiteOfAnonymous = computed(() => {
return props.reply.reply.spec.owner.annotations?.["website"];
});
const { data: contentProvider } = useContentProviderExtensionPoint();
</script>
<template>
<VModal
@ -173,34 +186,40 @@ const websiteOfAnonymous = computed(() => {
:label="$t('core.comment.reply_detail_modal.fields.original_comment')"
>
<OwnerButton :owner="comment.owner" />
<pre
class="mt-2 whitespace-pre-wrap break-words text-sm text-gray-900"
>{{ comment.comment.spec.content }}</pre
>
<div class="mt-2">
<component
:is="contentProvider?.component"
:content="comment.comment.spec.content"
/>
</div>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.comment.reply_detail_modal.fields.content')"
>
<pre
class="whitespace-pre-wrap break-words text-sm text-gray-900"
><span
v-if="quoteReply"
v-tooltip="`${quoteReply.owner.displayName}: ${quoteReply.reply.spec.content}`"
class="mr-1 inline-flex cursor-pointer flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span>
</span><br v-if="quoteReply" />{{ reply?.reply.spec.content }}</pre>
<div>
<span
v-if="quoteReply"
v-tooltip="{
content: sanitizeHtml(
`${quoteReply.owner.displayName}: ${quoteReply.reply.spec.content}`
),
html: true,
}"
class="mr-1 inline-flex cursor-pointer flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span> </span
><br v-if="quoteReply" /><component
:is="contentProvider?.component"
:content="reply?.reply.spec.content"
/>
</div>
</VDescriptionItem>
<VDescriptionItem
v-if="!reply.reply.spec.approved"
:label="$t('core.comment.detail_modal.fields.new_reply')"
>
<ReplyFormItems
:required="false"
:auto-focus="false"
@update="newReply = $event"
/>
<CommentEditor @update="onCommentEditorUpdate" />
</VDescriptionItem>
</VDescription>
</div>
@ -212,7 +231,7 @@ const websiteOfAnonymous = computed(() => {
@click="handleApprove"
>
{{
newReply
editorCharacterCount > 0
? $t("core.comment.operations.reply_and_approve.button")
: $t("core.comment.operations.approve.button")
}}

View File

@ -21,6 +21,7 @@ import { useQueryClient } from "@tanstack/vue-query";
import { computed, inject, markRaw, ref, type Ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { useCommentLastReadTimeMutate } from "../composables/use-comment-last-readtime-mutate";
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
import OwnerButton from "./OwnerButton.vue";
import ReplyCreationModal from "./ReplyCreationModal.vue";
import ReplyDetailModal from "./ReplyDetailModal.vue";
@ -193,6 +194,8 @@ const { operationItems } = useOperationItemExtensionPoint<ListedReply>(
},
])
);
const { data: contentProvider } = useContentProviderExtensionPoint();
</script>
<template>
@ -227,18 +230,21 @@ const { operationItems } = useOperationItemExtensionPoint<ListedReply>(
{{ $t("core.comment.text.replied_below") }}
</span>
</div>
<pre
class="whitespace-pre-wrap break-words text-sm text-gray-900"
><a
v-if="quoteReply"
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
href="javascript:void(0)"
@mouseenter="handleShowQuoteReply(true)"
@mouseleave="handleShowQuoteReply(false)"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span>
</a><br v-if="quoteReply" />{{ reply?.reply.spec.content }}</pre>
<div>
<a
v-if="quoteReply"
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
href="javascript:void(0)"
@mouseenter="handleShowQuoteReply(true)"
@mouseleave="handleShowQuoteReply(false)"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span> </a
><br v-if="quoteReply" /><component
:is="contentProvider?.component"
:content="reply?.reply.spec.content"
/>
</div>
<HasPermission :permissions="['system:comments:manage']">
<div class="flex items-center gap-3 text-xs">
<span

View File

@ -0,0 +1,38 @@
import { usePluginModuleStore } from "@/stores/plugin";
import type { CommentContentProvider } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import { markRaw } from "vue";
import DefaultCommentContent from "../components/DefaultCommentContent.vue";
export function useContentProviderExtensionPoint() {
const defaultProvider: CommentContentProvider = {
component: markRaw(DefaultCommentContent),
};
const { pluginModules } = usePluginModuleStore();
return useQuery({
queryKey: ["core:comment:list-item:content:provider"],
queryFn: async () => {
const result: CommentContentProvider[] = [];
for (const pluginModule of pluginModules) {
const callbackFunction =
pluginModule?.extensionPoints?.["comment:list-item:content:replace"];
if (typeof callbackFunction !== "function") {
continue;
}
const item = await callbackFunction();
result.push(item);
}
if (result.length) {
return result[0];
}
return defaultProvider;
},
});
}

View File

@ -3,6 +3,7 @@ import HasPermission from "@/components/permission/HasPermission.vue";
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import CommentDetailModal from "@console/modules/contents/comments/components/CommentDetailModal.vue";
import OwnerButton from "@console/modules/contents/comments/components/OwnerButton.vue";
import { useContentProviderExtensionPoint } from "@console/modules/contents/comments/composables/use-content-provider-extension-point";
import { useSubjectRef } from "@console/modules/contents/comments/composables/use-subject-ref";
import { coreApiClient, type ListedComment } from "@halo-dev/api-client";
import {
@ -63,6 +64,8 @@ const handleDelete = async () => {
},
});
};
const { data: contentProvider } = useContentProviderExtensionPoint();
</script>
<template>
@ -100,10 +103,10 @@ const handleDelete = async () => {
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</div>
<pre
class="line-clamp-4 whitespace-pre-wrap break-words text-sm text-gray-900"
>{{ comment?.comment?.spec.content }}</pre
>
<component
:is="contentProvider?.component"
:content="comment?.comment?.spec.content"
/>
<HasPermission :permissions="['system:comments:manage']">
<div class="flex items-center gap-3 text-xs">
<span

View File

@ -0,0 +1,29 @@
# 评论列表内容显示扩展点
用于替换 Halo 在 Console 的默认评论列表内容显示组件。
> 注意:
> 此扩展点并非通用扩展点,由于 Halo 早期设定Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本渲染的功能,所以为了保持 Console 的评论列表内容显示与前台一致,所以专为此插件提供了替换输入框的扩展点。
## 定义方式
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import CommentContent from "./components/CommentContent.vue";
export default definePlugin({
extensionPoints: {
"comment:list-item:content:replace": () => {
return {
component: markRaw(CommentContent),
};
},
},
});
```
其中,组件需要包含的 props 如下:
1. `content`:评论内容,`html` 格式。

View File

@ -0,0 +1,32 @@
# 评论编辑器扩展点
用于替换 Halo 在 Console 的默认评论输入框。
> 注意:
> 此扩展点并非通用扩展点,由于 Halo 早期设定Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本编辑器的功能,所以为了保持 Console 的评论输入框与前台一致,所以专为此插件提供了替换输入框的扩展点。
## 定义方式
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import CommentEditor from "./components/CommentEditor.vue";
export default definePlugin({
extensionPoints: {
"comment:editor:replace": () => {
return {
component: markRaw(CommentEditor),
};
},
},
});
```
其中,组件需要包含的 props 如下:
1. `autoFocus`:是否自动聚焦,需要在组件中判断是否为 `true`,然后聚焦输入框。
需要定义的 emit 如下:
1. `(event: "update", value: { content: string; characterCount: number })`:向调用方传递内容和字符数更新的事件。

View File

@ -104,6 +104,7 @@
"pretty-bytes": "^6.0.0",
"qrcode": "^1.5.3",
"qs": "^6.11.1",
"sanitize-html": "^2.17.0",
"short-unique-id": "^5.0.2",
"transliteration": "^2.3.5",
"ua-parser-js": "^1.0.38",
@ -127,6 +128,7 @@
"@types/node": "^18.11.19",
"@types/object-hash": "^3.0.6",
"@types/qs": "^6.9.7",
"@types/sanitize-html": "^2.16.0",
"@types/ua-parser-js": "^0.7.39",
"@typescript/native-preview": "7.0.0-dev.20250619.1",
"@vitejs/plugin-vue": "^6.0.1",

View File

@ -1,6 +1,7 @@
export * from "./core/plugins";
export * from "./states/attachment-selector";
export * from "./states/backup";
export * from "./states/comment";
export * from "./states/comment-subject-ref";
export * from "./states/editor";
export * from "./states/entity";

View File

@ -0,0 +1,9 @@
import type { Component, Raw } from "vue";
export interface CommentEditorProvider {
component: Raw<Component>;
}
export interface CommentContentProvider {
component: Raw<Component>;
}

View File

@ -1,4 +1,8 @@
import type { BackupTab } from "@/states/backup";
import type {
CommentContentProvider,
CommentEditorProvider,
} from "@/states/comment";
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { EntityFieldItem } from "@/states/entity";
import type { OperationItem } from "@/states/operation";
@ -50,6 +54,14 @@ export interface ExtensionPoint {
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
"comment:editor:replace"?: () =>
| CommentEditorProvider
| Promise<CommentEditorProvider>;
"comment:list-item:content:replace"?: () =>
| CommentContentProvider
| Promise<CommentContentProvider>;
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
"plugin:installation:tabs:create"?: () =>

View File

@ -198,6 +198,9 @@ importers:
qs:
specifier: ^6.11.1
version: 6.11.1
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
short-unique-id:
specifier: ^5.0.2
version: 5.0.2
@ -262,6 +265,9 @@ importers:
'@types/qs':
specifier: ^6.9.7
version: 6.9.7
'@types/sanitize-html':
specifier: ^2.16.0
version: 2.16.0
'@types/ua-parser-js':
specifier: ^0.7.39
version: 0.7.39
@ -4452,6 +4458,9 @@ packages:
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/sanitize-html@2.16.0':
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
'@types/scheduler@0.16.8':
resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
@ -6116,6 +6125,9 @@ packages:
dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
@ -6123,9 +6135,16 @@ packages:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@ -6987,6 +7006,9 @@ packages:
engines: {node: '>=12'}
hasBin: true
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
@ -8397,6 +8419,9 @@ packages:
parse-path@7.0.0:
resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse-url@8.1.0:
resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==}
@ -9389,6 +9414,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
sass-embedded-android-arm64@1.83.0:
resolution: {integrity: sha512-GBiCvM4a2rkWBLdYDxI6XYnprfk5U5c81g69RC2X6kqPuzxzx8qTArQ9M6keFK4+iDQ5N9QTwFCr0KbZTn+ZNQ==}
engines: {node: '>=14.0.0'}
@ -10568,8 +10596,8 @@ packages:
vue-component-type-helpers@2.0.19:
resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==}
vue-component-type-helpers@3.0.4:
resolution: {integrity: sha512-WtR3kPk8vqKYfCK/HGyT47lK/T3FaVyWxaCNuosaHYE8h9/k0lYRZ/PI/+T/z2wP+uuNKmL6z30rOcBboOu/YA==}
vue-component-type-helpers@3.0.5:
resolution: {integrity: sha512-uoNZaJ+a1/zppa/Vgmi8zIOP2PHXDN2rT8NyF+zQRK6ZG94lNB9prcV0GdLJbY9i9lrD47JOVIH92SaiA7oJ1A==}
vue-demi@0.13.11:
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
@ -15484,7 +15512,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.16(typescript@5.8.3)
vue-component-type-helpers: 3.0.4
vue-component-type-helpers: 3.0.5
transitivePeerDependencies:
- encoding
- supports-color
@ -15962,6 +15990,10 @@ snapshots:
'@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.0':
dependencies:
htmlparser2: 8.0.2
'@types/scheduler@0.16.8': {}
'@types/semver@7.3.13': {}
@ -17837,18 +17869,34 @@ snapshots:
domhandler: 4.3.1
entities: 2.2.0
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@4.3.1:
dependencies:
domelementtype: 2.3.0
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@2.8.0:
dependencies:
dom-serializer: 1.4.1
domelementtype: 2.3.0
domhandler: 4.3.1
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@ -18951,6 +18999,13 @@ snapshots:
relateurl: 0.2.7
terser: 5.37.0
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
http-cache-semantics@4.1.1: {}
http-errors@2.0.0:
@ -20380,6 +20435,8 @@ snapshots:
dependencies:
protocols: 2.0.1
parse-srcset@1.0.2: {}
parse-url@8.1.0:
dependencies:
parse-path: 7.0.0
@ -21444,6 +21501,15 @@ snapshots:
safer-buffer@2.1.2: {}
sanitize-html@2.17.0:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.6
sass-embedded-android-arm64@1.83.0:
optional: true
@ -22700,7 +22766,7 @@ snapshots:
vue-component-type-helpers@2.0.19: {}
vue-component-type-helpers@3.0.4: {}
vue-component-type-helpers@3.0.5: {}
vue-demi@0.13.11(vue@3.5.16(typescript@5.8.3)):
dependencies:

View File

@ -88,7 +88,6 @@ export function createViteConfig(options: Options) {
"vue-grid-layout",
"transliteration",
"vue-draggable-plus",
"emoji-mart",
"colorjs.io",
"overlayscrollbars",
"overlayscrollbars-vue",