diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 33afd4d216..c3718b10c4 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -38,6 +38,7 @@ import { IconButton, IntlFormatted, LoadingBar, + MarkdownBody, NewsArticleCard, NotificationPanel, PopupNotificationPanel, @@ -53,7 +54,6 @@ import { useHostingIntercom, useVIntl, } from '@modrinth/ui' -import { renderString } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { convertFileSrc, invoke } from '@tauri-apps/api/core' @@ -1963,10 +1963,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) :header="criticalErrorMessage.header" class="m-6 mb-0" > -
+ - + @@ -407,11 +407,12 @@ import { IconButton, injectModrinthClient, injectNotificationManager, + MarkdownBody, ProjectStatusBadge, useFormatDateTime, useRelativeTime, } from '@modrinth/ui' -import { getUserLink, renderString } from '@modrinth/utils' +import { getUserLink } from '@modrinth/utils' import { markAsRead } from '~/helpers/platform-notifications' import { getProjectLink, getVersionLink } from '~/helpers/projects' diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index fcdd9591c8..8dad56b0d0 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -96,8 +96,10 @@ import type { Nag, NagContext, NagStatus } from '@modrinth/moderation' import { nags } from '@modrinth/moderation' import { Button, IconButton } from '@modrinth/ui' import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui' +import type { MarkdownDocument } from '@modrinth/utils' +import { parseModrinthMarkdown } from '@modrinth/utils' import type { Component } from 'vue' -import { computed } from 'vue' +import { computed, ref, watch } from 'vue' interface Tags { rejectedStatuses: string[] @@ -176,12 +178,22 @@ const emit = defineEmits<{ setProcessing: [processing: boolean] }>() +const descriptionDocument = ref(null) +watch( + () => props.project.body, + async (body) => { + descriptionDocument.value = body ? await parseModrinthMarkdown(body) : null + }, + { immediate: true }, +) + const nagContext = computed(() => ({ project: props.project, projectV3: props.projectV3, versions: props.versions, currentMember: props.currentMember?.user as Labrinth.Users.v2.User, currentRoute: props.routeName, + descriptionDocument: descriptionDocument.value, tags: props.tags, submitProject: submitForReview, })) diff --git a/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue b/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue index 861b6b04ca..6f886326bc 100644 --- a/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue +++ b/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue @@ -16,12 +16,12 @@ import { defineMessages, injectModrinthClient, injectNotificationManager, + MarkdownBody, NewModal, Table, type TableColumn, useVIntl, } from '@modrinth/ui' -import { renderString } from '@modrinth/utils' import { useQueryClient } from '@tanstack/vue-query' import { computed, ref, useTemplateRef } from 'vue' @@ -427,7 +427,7 @@ defineExpose({ show, hide }) v-if="versionLoadError || rowScanError" class="mb-3 rounded-xl bg-highlight-red px-4 py-1 text-red" > -
+ { newMap.set( entry.node, expanded - ? `
${renderHighlightedString(expanded)}
` + ? `
${await renderHighlightedString(expanded)}
` : '', ) } catch { diff --git a/apps/frontend/src/components/ui/report/ReportInfo.vue b/apps/frontend/src/components/ui/report/ReportInfo.vue index 1de7dfb119..e7f0cdd56e 100644 --- a/apps/frontend/src/components/ui/report/ReportInfo.vue +++ b/apps/frontend/src/components/ui/report/ReportInfo.vue @@ -99,7 +99,7 @@ color="orange" /> -
+ +
@@ -166,11 +171,13 @@ import { AutoLink, Avatar, Badge, + MarkdownBody, TeleportOverflowMenu, useFormatDateTime, useRelativeTime, } from '@modrinth/ui' import { renderString } from '@modrinth/utils' +import { computedAsync } from '@vueuse/core' import { isStaff } from '~/helpers/users.js' @@ -208,20 +215,14 @@ const props = defineProps({ const emit = defineEmits(['update-thread']) const settings = useModerationSettings() -const formattedMessage = computed(() => { - const body = renderString(props.message.body.body) - if (props.forceCompact) { - const hasImage = body.includes(']+(>|$)/g, '') - if (noHtml.trim()) { - return noHtml - } else if (hasImage) { - return 'sent an image.' - } else { - return 'sent a message.' - } - } - return body +const compactPreview = computedAsync(async () => { + if (!props.forceCompact) return '' + const body = await renderString(props.message.body.body) + const hasImage = body.includes(']+(>|$)/g, '') + if (noHtml.trim()) return noHtml + if (hasImage) return 'sent an image.' + return 'sent a message.' }) const formatRelativeTime = useRelativeTime() diff --git a/apps/frontend/src/pages/[type]/[project]/changelog.vue b/apps/frontend/src/pages/[type]/[project]/changelog.vue index 376964b839..e311f93f24 100644 --- a/apps/frontend/src/pages/[type]/[project]/changelog.vue +++ b/apps/frontend/src/pages/[type]/[project]/changelog.vue @@ -66,10 +66,10 @@ Download
-
@@ -94,11 +94,11 @@ import { ButtonLink, injectModrinthClient, injectProjectPageContext, + MarkdownBody, Pagination, useFormatDateTime, } from '@modrinth/ui' import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue' -import { renderHighlightedString } from '@modrinth/utils' import { useQuery } from '@tanstack/vue-query' import { onMounted, watch } from 'vue' diff --git a/apps/frontend/src/pages/collection/[collection].vue b/apps/frontend/src/pages/collection/[collection].vue index 8f6d1d77fc..713869a8ee 100644 --- a/apps/frontend/src/pages/collection/[collection].vue +++ b/apps/frontend/src/pages/collection/[collection].vue @@ -237,11 +237,7 @@ v-if="collection.description" :title="formatMessage(commonMessages.descriptionLabel)" > -
+

{{ collection.description }}

{ - const attrUrlValue = tokens[idx].attrGet(attrName) - if (attrUrlValue) { - tokens[idx].attrSet( - attrName, - new URL(attrUrlValue, `${SITE_URL}/news/article/${slug}/`).href.replace(SITE_URL, ''), - ) - } - return defaultRenderer(tokens, idx, options, env, self) - } - } - - mdIt.renderer.rules.image = normalizeRendererHtmlUriAttribute('image', 'src') - mdIt.renderer.rules.link_open = normalizeRendererHtmlUriAttribute('link_open', 'href') - - const minifiedHtml = await minify(mdIt.render(content), { + const minifiedHtml = await minify(await renderArticle(content), { collapseWhitespace: true, removeComments: true, }) diff --git a/packages/moderation/src/data/nags/description.ts b/packages/moderation/src/data/nags/description.ts index 62b45fbf6d..036ce139c4 100644 --- a/packages/moderation/src/data/nags/description.ts +++ b/packages/moderation/src/data/nags/description.ts @@ -1,5 +1,6 @@ import { defineMessage, useVIntl } from '@modrinth/ui' -import { renderHighlightedString } from '@modrinth/utils' +import type { ElementNode, MarkdownDocument, Node } from '@modrinth/utils' +import { visit } from '@modrinth/utils' import type { Nag, NagContext } from '../../types/nags' @@ -8,31 +9,41 @@ export const MAX_HEADER_LENGTH = 80 export const MIN_SUMMARY_CHARS = 30 export const MIN_CHARS_PER_IMAGE = 60 -export function analyzeHeaderLength(markdown: string): { +function collectText(node: Node, out: string[]): void { + if (typeof node === 'string') { + out.push(node) + return + } + const tag = node[0] + if (tag === null || tag === 'code' || tag === 'pre') return + for (const child of node.slice(2) as Node[]) collectText(child, out) +} + +function getElementText(node: ElementNode): string { + const out: string[] = [] + for (const child of node.slice(2) as Node[]) collectText(child, out) + return out.join('') +} + +export function analyzeHeaderLength(document: MarkdownDocument | null): { hasLongHeaders: boolean longHeaders: string[] } { - if (!markdown) return { hasLongHeaders: false, longHeaders: [] } - - const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '') - - const headerRegex = /^(#{1,3})\s+(.+)$/gm - const headers = [...withoutCodeBlocks.matchAll(headerRegex)] + if (!document) return { hasLongHeaders: false, longHeaders: [] } const longHeaders: string[] = [] + visit( + document, + (node) => Array.isArray(node) && /^h[1-3]$/.test(node[0] as string), + (node) => { + const headerText = getElementText(node as ElementNode).trim() + const sentences = headerText.split(/[.!?]+/g).filter((s) => s.trim().length > 0) - headers.forEach((match) => { - const headerText = match[2].trim() - const sentenceEnders = /[.!?]+/g - const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0) - - const isVeryLong = headerText.length > MAX_HEADER_LENGTH - const hasMultipleSentences = sentences.length > 1 - - if (isVeryLong || hasMultipleSentences) { - longHeaders.push(headerText) - } - }) + if (headerText.length > MAX_HEADER_LENGTH || sentences.length > 1) { + longHeaders.push(headerText) + } + }, + ) return { hasLongHeaders: longHeaders.length > 0, @@ -40,76 +51,41 @@ export function analyzeHeaderLength(markdown: string): { } } -export function analyzeImageContent(markdown: string): { +export function analyzeImageContent(document: MarkdownDocument | null): { imageHeavy: boolean hasEmptyAltText: boolean } { - if (!markdown) return { imageHeavy: false, hasEmptyAltText: false } + if (!document) return { imageHeavy: false, hasEmptyAltText: false } - const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '') + let totalImages = 0 + let hasEmptyAltText = false - const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g - const images = [...withoutCodeBlocks.matchAll(imageRegex)] + visit( + document, + (node) => Array.isArray(node) && node[0] === 'img', + (node) => { + totalImages++ + const alt = (node as ElementNode)[1]?.alt + if (typeof alt !== 'string' || !alt.trim()) hasEmptyAltText = true + }, + ) - const htmlImageRegex = /]*>/gi - const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)] - - const totalImages = images.length + htmlImages.length if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false } - const textLength = countText(withoutCodeBlocks) + const textLength = countText(document) const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages const imageHeavy = recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength - const hasEmptyAltText = - images.some((match) => !match[1]?.trim()) || - htmlImages.some((match) => { - const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i) - return !altMatch || !altMatch[1]?.trim() - }) - return { imageHeavy, hasEmptyAltText } } -export function countText(markdown: string): number { - if (!markdown) return 0 +export function countText(document: MarkdownDocument | null): number { + if (!document) return 0 - const fallback = (md: string): number => { - const withoutCode = md.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '') - const withoutImagesAndLinks = withoutCode - .replace(/!\[[^\]]*]\([^)]+\)/g, ' ') - .replace(/\[[^\]]*]\([^)]+\)/g, ' ') - const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ') - const withoutMdSyntax = withoutHtml - .replace(/^>{1}\s?.*$/gm, ' ') - .replace(/^#{1,6}\s+/gm, ' ') - .replace(/[*_~`>-]/g, ' ') - .replace(/\|/g, ' ') - return withoutMdSyntax.replace(/\s+/g, ' ').trim().length - } - - if (typeof window === 'undefined' || typeof globalThis.DOMParser === 'undefined') { - console.warn(`[Moderation] SSR: no window/DOMParser, falling back for countText`) - return fallback(markdown) - } - - try { - const htmlString = renderHighlightedString(markdown) - const parser = new DOMParser() - const doc = parser.parseFromString(htmlString, 'text/html') - const walker = doc.createTreeWalker(doc.body || doc, NodeFilter.SHOW_TEXT) - - const textList: string[] = [] - let node = walker.nextNode() - while (node) { - if (node.textContent) textList.push(node.textContent) - node = walker.nextNode() - } - return textList.join(' ').replace(/\s+/g, ' ').trim().length - } catch { - return fallback(markdown) - } + const out: string[] = [] + for (const node of document.nodes) collectText(node, out) + return out.join('').replace(/\s+/g, ' ').trim().length } export const descriptionNags: Nag[] = [ @@ -121,7 +97,7 @@ export const descriptionNags: Nag[] = [ }), description: (context: NagContext) => { const { formatMessage } = useVIntl() - const readableLength = countText(context.project.body || '') + const readableLength = countText(context.descriptionDocument) return formatMessage( defineMessage({ @@ -137,7 +113,7 @@ export const descriptionNags: Nag[] = [ }, status: 'warning', shouldShow: (context: NagContext) => { - const readableLength = countText(context.project.body || '') + const readableLength = countText(context.descriptionDocument) return readableLength < MIN_DESCRIPTION_CHARS && readableLength > 0 }, link: { @@ -158,7 +134,7 @@ export const descriptionNags: Nag[] = [ }), description: (context: NagContext) => { const { formatMessage } = useVIntl() - const { longHeaders } = analyzeHeaderLength(context.project.body || '') + const { longHeaders } = analyzeHeaderLength(context.descriptionDocument) const count = longHeaders.length return formatMessage( @@ -174,7 +150,7 @@ export const descriptionNags: Nag[] = [ }, status: 'warning', shouldShow: (context: NagContext) => { - const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '') + const { hasLongHeaders } = analyzeHeaderLength(context.descriptionDocument) return hasLongHeaders }, link: { @@ -358,7 +334,7 @@ export const descriptionNags: Nag[] = [ }), status: 'warning', shouldShow: (context: NagContext) => { - const { imageHeavy } = analyzeImageContent(context.project.body || '') + const { imageHeavy } = analyzeImageContent(context.descriptionDocument) return imageHeavy }, link: { @@ -384,7 +360,7 @@ export const descriptionNags: Nag[] = [ }), status: 'warning', shouldShow: (context: NagContext) => { - const { hasEmptyAltText } = analyzeImageContent(context.project.body || '') + const { hasEmptyAltText } = analyzeImageContent(context.descriptionDocument) return hasEmptyAltText }, link: { diff --git a/packages/moderation/src/types/nags.ts b/packages/moderation/src/types/nags.ts index c0a63cddd7..aa07d12e8f 100644 --- a/packages/moderation/src/types/nags.ts +++ b/packages/moderation/src/types/nags.ts @@ -1,5 +1,6 @@ import type { Labrinth } from '@modrinth/api-client' import type { MessageDescriptor } from '@modrinth/ui' +import type { MarkdownDocument } from '@modrinth/utils' import type { FunctionalComponent, SVGAttributes } from 'vue' /** @@ -37,6 +38,10 @@ export interface NagContext { * The current route in the application. */ currentRoute: string + /** + * The project's description parsed into an AST via Comark (`null` while parsing) + */ + descriptionDocument: MarkdownDocument | null /* eslint-disable @typescript-eslint/no-explicit-any */ tags: any submitProject: (...any: any) => any diff --git a/packages/moderation/src/types/node/components/NodeRenderer.vue b/packages/moderation/src/types/node/components/NodeRenderer.vue index d1bf6768b8..67e51ae6ca 100644 --- a/packages/moderation/src/types/node/components/NodeRenderer.vue +++ b/packages/moderation/src/types/node/components/NodeRenderer.vue @@ -1,6 +1,5 @@ - - - - diff --git a/packages/ui/src/components/base/MarkdownBody.vue b/packages/ui/src/components/base/MarkdownBody.vue new file mode 100644 index 0000000000..e5d676f7fd --- /dev/null +++ b/packages/ui/src/components/base/MarkdownBody.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/packages/ui/src/components/base/MarkdownEditor.vue b/packages/ui/src/components/base/MarkdownEditor.vue index 0157509f67..74badfa1ce 100644 --- a/packages/ui/src/components/base/MarkdownEditor.vue +++ b/packages/ui/src/components/base/MarkdownEditor.vue @@ -39,11 +39,7 @@
-
+
-
@@ -325,7 +313,6 @@ import { YouTubeIcon, } from '@modrinth/assets' import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror' -import { renderHighlightedString } from '@modrinth/utils/highlightjs' import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, useId, watch } from 'vue' import { Button, IconButton } from '#ui/components/base/buttons' @@ -338,6 +325,7 @@ import FileInput from './FileInput.vue' import Input from './inputs/Input.vue' import InputFrame from './inputs/InputFrame.vue' import IntlFormatted from './IntlFormatted.vue' +import MarkdownBody from './MarkdownBody.vue' import Toggle from './Toggle.vue' const { formatMessage } = useVIntl() diff --git a/packages/ui/src/components/base/ServerNotice.vue b/packages/ui/src/components/base/ServerNotice.vue index d748a1d8f0..e326188e04 100644 --- a/packages/ui/src/components/base/ServerNotice.vue +++ b/packages/ui/src/components/base/ServerNotice.vue @@ -28,13 +28,12 @@ Dismiss -
+ diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index f9a9429837..1a62a43dfd 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -7,7 +7,6 @@ export { default as AutoLink } from './AutoLink.vue' export { default as Avatar } from './Avatar.vue' export { default as Badge } from './Badge.vue' export { default as BaseTerminal } from './BaseTerminal.vue' -export { default as BasicMarkdownText } from './BasicMarkdownText.vue' export { default as BigOptionButton } from './BigOptionButton.vue' export { default as BulletDivider } from './BulletDivider.vue' export { default as Button } from './buttons/Button.vue' @@ -71,6 +70,7 @@ export { default as IntlFormatted } from './IntlFormatted.vue' export { default as LoadingBar } from './LoadingBar.vue' export { default as LoadingIndicator } from './LoadingIndicator.vue' export { default as ManySelect } from './ManySelect.vue' +export { default as MarkdownBody } from './MarkdownBody.vue' export { default as MarkdownEditor } from './MarkdownEditor.vue' export type { MultiSelectItem, diff --git a/packages/ui/src/components/base/markdown/MarkdownAlert.vue b/packages/ui/src/components/base/markdown/MarkdownAlert.vue new file mode 100644 index 0000000000..b0398bb850 --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownAlert.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownCollectionEmbed.vue b/packages/ui/src/components/base/markdown/MarkdownCollectionEmbed.vue new file mode 100644 index 0000000000..65efe55cc8 --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownCollectionEmbed.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownEmbedCard.vue b/packages/ui/src/components/base/markdown/MarkdownEmbedCard.vue new file mode 100644 index 0000000000..00f7138949 --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownEmbedCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownHighlightedPre.vue b/packages/ui/src/components/base/markdown/MarkdownHighlightedPre.vue new file mode 100644 index 0000000000..d7c5a838bd --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownHighlightedPre.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownOrganizationEmbed.vue b/packages/ui/src/components/base/markdown/MarkdownOrganizationEmbed.vue new file mode 100644 index 0000000000..6e242e1c43 --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownOrganizationEmbed.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownProjectEmbed.vue b/packages/ui/src/components/base/markdown/MarkdownProjectEmbed.vue new file mode 100644 index 0000000000..f7e6aab66a --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownProjectEmbed.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/ui/src/components/base/markdown/MarkdownUserEmbed.vue b/packages/ui/src/components/base/markdown/MarkdownUserEmbed.vue new file mode 100644 index 0000000000..23d983de54 --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownUserEmbed.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/ui/src/components/changelog/ChangelogEntry.vue b/packages/ui/src/components/changelog/ChangelogEntry.vue index 1beca17366..4a472a71b4 100644 --- a/packages/ui/src/components/changelog/ChangelogEntry.vue +++ b/packages/ui/src/components/changelog/ChangelogEntry.vue @@ -36,20 +36,20 @@
-
+