Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) e71b67e9fc refactor: swap markdown-it -> comark 2026-05-18 17:29:14 +01:00
39 changed files with 1122 additions and 822 deletions
+1
View File
@@ -31,6 +31,7 @@
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"comark": "^0.3.2",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
+2 -5
View File
@@ -41,6 +41,7 @@ import {
defineMessages,
I18nDebugPanel,
LoadingBar,
MarkdownBody,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -55,7 +56,6 @@ import {
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
@@ -1477,10 +1477,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:header="criticalErrorMessage.header"
class="m-6 mb-0"
>
<div
class="markdown-body text-primary"
v-html="renderString(criticalErrorMessage.body ?? '')"
></div>
<MarkdownBody body-class="text-primary" :markdown="criticalErrorMessage.body ?? ''" />
</Admonition>
<Admonition
v-if="authUnreachable"
@@ -52,7 +52,7 @@
<div class="description-cards">
<Card>
<h3 class="card-title">Changelog</h3>
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
<MarkdownBody :markdown="version.changelog ?? ''" />
</Card>
<Card>
<h3 class="card-title">Files</h3>
@@ -176,10 +176,10 @@ import {
ButtonStyled,
Card,
CopyCode,
MarkdownBody,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
+1 -4
View File
@@ -59,14 +59,12 @@
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"intl-messageformat": "^10.7.7",
"iso-3166-2": "1.0.0",
"jose": "^6.2.2",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
@@ -76,8 +74,7 @@
"vue-router": "*",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2",
"xss": "^1.0.14"
"vue3-apexcharts": "^1.5.2"
},
"web-types": "../../web-types.json",
"prettier": "@modrinth/tooling-config/frontend.prettier.config.cjs"
@@ -140,7 +140,7 @@
.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
<MarkdownBody :markdown="notification.title" inline />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
@@ -318,11 +318,12 @@ import {
DoubleIcon,
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'
@@ -26,6 +26,7 @@ import {
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
highlightCodeLines,
OverflowMenu,
type OverflowMenuOption,
useFormatBytes,
@@ -35,7 +36,6 @@ import { NavTabs } from '@modrinth/ui'
import {
capitalizeString,
formatProjectType,
highlightCodeLines,
type ThreadMessage,
type User,
} from '@modrinth/utils'
@@ -565,6 +565,7 @@ const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
const highlightedSourceLoading = reactive<Set<string>>(new Set())
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10
interface ClassGroup {
@@ -723,9 +724,18 @@ function getHighlightedClassSource(classItem: ClassGroup): string[] {
const cached = highlightedSourceCache.get(classItem.key)
if (cached?.source === source) return cached.lines
const lines = highlightCodeLines(source, 'java')
highlightedSourceCache.set(classItem.key, { source, lines })
return lines
if (!highlightedSourceLoading.has(classItem.key)) {
highlightedSourceLoading.add(classItem.key)
highlightCodeLines(source, 'java')
.then((lines) => {
highlightedSourceCache.set(classItem.key, { source, lines })
})
.finally(() => {
highlightedSourceLoading.delete(classItem.key)
})
}
return []
}
function isClassLoadingSource(classItem: ClassGroup): boolean {
@@ -186,7 +186,11 @@
</h2>
<div v-if="currentStageObj.text" class="mb-4">
<div v-if="stageTextExpanded" class="markdown-body" v-html="stageTextExpanded"></div>
<MarkdownBody
v-if="stageTextExpanded"
:markdown="stageTextExpanded"
highlight
/>
<div v-else class="markdown-body">Loading stage content...</div>
</div>
@@ -493,18 +497,14 @@ import {
DropdownSelect,
injectNotificationManager,
injectProjectPageContext,
MarkdownBody,
MarkdownEditor,
OverflowMenu,
type OverflowMenuOption,
StyledInput,
useDebugLogger,
} from '@modrinth/ui'
import {
type ModerationJudgements,
type ModerationModpackItem,
type ProjectStatus,
renderHighlightedString,
} from '@modrinth/utils'
import type { ModerationJudgements, ModerationModpackItem, ProjectStatus } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { computedAsync, useDebounceFn } from '@vueuse/core'
import type { Component } from 'vue'
@@ -1135,13 +1135,11 @@ const stageTextExpanded = computedAsync(async () => {
const stageIndex = currentStage.value
const stage = checklist[stageIndex]
if (stage.text) {
return renderHighlightedString(
expandVariables(
await stage.text(projectV2.value, projectV3.value),
projectV2.value,
projectV3.value,
variables.value,
),
return expandVariables(
await stage.text(projectV2.value, projectV3.value),
projectV2.value,
projectV3.value,
variables.value,
)
}
return null
@@ -73,7 +73,7 @@
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<MarkdownBody v-if="showMessage" :markdown="report.body" highlight />
<ThreadSummary
v-if="thread"
:thread="thread"
@@ -107,8 +107,8 @@
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { Avatar, Badge, CopyCode, useFormatDateTime, useRelativeTime } from '@modrinth/ui'
import { formatProjectType, renderHighlightedString } from '@modrinth/utils'
import { Avatar, Badge, CopyCode, MarkdownBody, useFormatDateTime, useRelativeTime } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
@@ -146,10 +146,14 @@ import {
Badge,
ButtonStyled,
OverflowMenu,
markdownHasImage,
markdownToPlainText,
renderMarkdown,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { computedAsync } from '@vueuse/core'
import { computed, ref } from 'vue'
import { isStaff } from '~/helpers/users.js'
@@ -187,21 +191,21 @@ const props = defineProps({
const emit = defineEmits(['update-thread'])
const flags = useFeatureFlags()
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
const formattedMessage = computedAsync(async () => {
const markdown = props.message.body.body
const body = await renderMarkdown(markdown)
if (props.forceCompact) {
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
const plainText = markdownToPlainText(markdown)
if (plainText) {
return plainText
} else if (markdownHasImage(markdown)) {
return 'sent an image.'
} else {
return 'sent a message.'
}
}
return body
})
}, '')
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
+5 -8
View File
@@ -58,13 +58,9 @@
{{ project.license.name ? project.license.name : formatMessage(messages.licenseTitle) }}
</span>
</template>
<div
class="markdown-body"
v-html="
renderString(licenseText).isEmpty
? formatMessage(messages.loadingLicenseText)
: renderString(licenseText)
"
<MarkdownBody
:markdown="licenseText"
:fallback="formatMessage(messages.loadingLicenseText)"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
@@ -1080,6 +1076,7 @@ import {
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownBody,
NavTabs,
NewModal,
OpenInAppModal,
@@ -1107,7 +1104,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
import { capitalizeString, formatProjectType } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
@@ -68,8 +68,9 @@
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
>
<MarkdownBody :markdown="version.changelog" highlight />
</div>
</div>
</div>
</template>
@@ -93,11 +94,11 @@ import {
ButtonStyled,
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'
@@ -193,11 +193,10 @@
</div>
<div class="version-page__changelog universal-card">
<h3>Changelog</h3>
<div
class="markdown-body"
v-html="
version.changelog ? renderHighlightedString(version.changelog) : 'No changelog specified.'
"
<MarkdownBody
:markdown="version.changelog ?? ''"
fallback="No changelog specified."
highlight
/>
</div>
<div
@@ -441,13 +440,13 @@ import {
ENVIRONMENTS_COPY,
injectNotificationManager,
injectProjectPageContext,
MarkdownBody,
MultiSelect,
PROJECT_DEP_MARKER_QUERY,
StyledInput,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
-3
View File
@@ -8,9 +8,6 @@
// Apply default styles
@import 'styles/defaults';
// Apply code block highlighting styles
@import 'styles/highlightjs';
// Finally, apply accessibility-related global styling
@import 'styles/accessibility';
+29
View File
@@ -781,6 +781,29 @@ a:not(.no-click-animation),
line-height: 1.5;
}
ul,
ol {
padding-left: 2rem;
}
li {
margin: 0.25rem 0;
}
li > p {
margin: 0;
}
li > p + p {
margin-top: 0.75rem;
}
li > ul,
li > ol {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
h1 {
display: block;
}
@@ -858,9 +881,15 @@ a:not(.no-click-animation),
overflow-x: auto;
code {
display: block;
background-color: transparent;
color: inherit;
font-size: 80%;
padding: 0;
border-radius: 0;
overflow-wrap: normal;
white-space: pre;
word-break: normal;
}
}
-89
View File
@@ -1,89 +0,0 @@
.hljs,
.hljs-subst {
color: #444;
}
.hljs-comment {
color: #888888;
}
.hljs-keyword,
.hljs-attribute,
.hljs-selector-tag,
.hljs-meta-keyword,
.hljs-doctag,
.hljs-name {
color: #f58300;
font-weight: bold;
}
.hljs-type,
.hljs-string,
.hljs-number,
.hljs-selector-id,
.hljs-selector-class,
.hljs-quote,
.hljs-template-tag,
.hljs-deletion {
color: var(--color-brand);
}
.hljs-title,
.hljs-section {
color: #008888;
font-weight: bold;
}
.hljs-regexp,
.hljs-symbol,
.hljs-variable,
.hljs-template-variable,
.hljs-link,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #bc6060;
}
.hljs-literal {
color: #78a960;
}
.hljs-built_in,
.hljs-bullet,
.hljs-code,
.hljs-addition {
color: #f58300;
}
.hljs-meta {
color: #1f7199;
}
.hljs-meta-string {
color: #4d99bf;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
pre {
background-color: #222222;
padding: 1em 1em 1em 1em;
border-width: 5px;
border-radius: 2em;
border-color: var(--color-divider);
overflow-x: hidden;
code {
line-height: 100%;
padding: 0.2em;
letter-spacing: -0.05em;
word-break: normal;
font-family: monospace;
}
}
+7 -31
View File
@@ -1,12 +1,9 @@
import { compareImportSources } from '@modrinth/tooling-config/script-utils/import-sort'
import { md } from '@modrinth/utils'
import { createMarkdownRenderer } from '@modrinth/ui/src/utils/markdown'
import { promises as fs } from 'fs'
import { glob } from 'glob'
import matter from 'gray-matter'
import { minify } from 'html-minifier-terser'
import type { Options } from 'markdown-it'
import type Renderer from 'markdown-it/lib/renderer.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import * as path from 'path'
import RSS from 'rss'
import { parseStringPromise } from 'xml2js'
@@ -77,35 +74,14 @@ async function compileArticles() {
process.exit(1)
}
const mdIt = md()
const slug = frontSlug || path.basename(file, '.md')
const renderArticleMarkdown = createMarkdownRenderer({
baseUrl: `${SITE_URL}/news/article/${slug}/`,
stripBaseUrl: SITE_URL,
highlightCode: true,
})
// Normalizes relative URL resolution to occur in the context of the article's directory.
// This prevents user agents from resolving relative URLs differently based on whether
// the current document URL has a trailing slash or not.
function normalizeRendererHtmlUriAttribute(ruleName: string, attrName: string) {
const defaultRenderer =
mdIt.renderer.rules[ruleName] ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
return (tokens: Token[], idx: number, options: Options, env: object, self: Renderer) => {
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 renderArticleMarkdown(content), {
collapseWhitespace: true,
removeComments: true,
})
+1 -1
View File
@@ -17,7 +17,7 @@
"jiti": "^2.4.2"
},
"dependencies": {
"@modrinth/utils": "workspace:*",
"@modrinth/ui": "workspace:*",
"dayjs": "^1.11.10",
"glob": "^10.2.7",
"gray-matter": "^4.0.3",
@@ -1,5 +1,4 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils'
import { countMarkdownText, defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
@@ -74,42 +73,7 @@ export function analyzeImageContent(markdown: string): {
export function countText(markdown: string): number {
if (!markdown) 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)
}
return countMarkdownText(markdown)
}
export const descriptionNags: Nag[] = [
+4 -6
View File
@@ -54,17 +54,16 @@
"@codemirror/language": "^6.9.3",
"@codemirror/state": "^6.3.2",
"@codemirror/view": "^6.22.1",
"@comark/html": "^0.3.1",
"@intercom/messenger-js-sdk": "^0.0.14",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/utils": "workspace:*",
"@tanstack/vue-query": "5.90.7",
"@tresjs/cientos": "^4.3.0",
"@tresjs/core": "^4.3.4",
"@tresjs/post-processing": "^2.4.0",
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.1",
"@types/three": "^0.172.0",
"@vintl/how-ago": "^3.0.1",
"@vueuse/core": "^11.1.0",
@@ -73,27 +72,26 @@
"@xterm/xterm": "^6.0.0",
"ace-builds": "^1.43.5",
"apexcharts": "^4.0.0",
"comark": "^0.3.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.7",
"es-toolkit": "^1.44.0",
"flatpickr": "^4.6.13",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"highlight.js": "^11.9.0",
"intl-messageformat": "^10.7.7",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
"markdown-it": "^13.0.2",
"motion-v": "^2.2.1",
"postprocessing": "^6.37.6",
"qrcode.vue": "^3.4.1",
"shiki": "^4.0.2",
"three": "^0.172.0",
"vue-i18n": "^10.0.0",
"vue-select": "4.0.0-beta.6",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2",
"xss": "^1.0.14"
"vue3-apexcharts": "^1.5.2"
},
"web-types": "../../web-types.json"
}
@@ -0,0 +1,42 @@
<template>
<component :is="tag" v-if="html" :class="bodyClass" v-html="html" />
<component :is="tag" v-else-if="fallback" :class="bodyClass">{{ fallback }}</component>
</template>
<script setup lang="ts">
import { computedAsync } from '@vueuse/core'
import { computed } from 'vue'
import { renderHighlightedMarkdown, renderMarkdown } from '../../utils/markdown'
const props = withDefaults(
defineProps<{
markdown?: string | null
inline?: boolean
highlight?: boolean
fallback?: string
bodyClass?: unknown
}>(),
{
markdown: '',
inline: false,
highlight: false,
fallback: '',
bodyClass: undefined,
},
)
const rendered = computedAsync(async () => {
const markdown = props.markdown ?? ''
const html = props.highlight ? await renderHighlightedMarkdown(markdown) : await renderMarkdown(markdown)
if (!props.inline) return html
const match = html.match(/^<p>([\s\S]*)<\/p>\s*$/)
return match ? match[1] : html
}, '')
const html = computed(() => rendered.value)
const tag = computed(() => (props.inline ? 'span' : 'div'))
const bodyClass = computed(() => (props.inline ? props.bodyClass : ['markdown-body', props.bodyClass]))
</script>
@@ -39,11 +39,7 @@
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(linkMarkdown)"
/>
<MarkdownBody style="width: 100%" :markdown="linkMarkdown" highlight />
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
@@ -137,11 +133,7 @@
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(imageMarkdown)"
/>
<MarkdownBody style="width: 100%" :markdown="imageMarkdown" highlight />
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
@@ -197,11 +189,7 @@
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(videoMarkdown)"
/>
<MarkdownBody style="width: 100%" :markdown="videoMarkdown" highlight />
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
@@ -289,14 +277,14 @@
</div>
<div v-else>
<div class="markdown-body-wrapper">
<div
<MarkdownBody
style="width: 100%"
:style="{
maxHeight: props.maxHeight ? `${props.maxHeight}px` : 'unset',
overflowY: 'auto',
}"
class="markdown-body"
v-html="renderHighlightedString(currentValue ?? '')"
:markdown="currentValue ?? ''"
highlight
/>
</div>
</div>
@@ -330,7 +318,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, watch } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
@@ -340,6 +327,7 @@ import ButtonStyled from './ButtonStyled.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
import IntlFormatted from './IntlFormatted.vue'
import MarkdownBody from './MarkdownBody.vue'
import StyledInput from './StyledInput.vue'
import Toggle from './Toggle.vue'
@@ -27,19 +27,19 @@
</button>
</ButtonStyled>
</template>
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
<MarkdownBody v-if="message" :markdown="message" />
</Admonition>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import Admonition from './Admonition.vue'
import ButtonStyled from './ButtonStyled.vue'
import CopyCode from './CopyCode.vue'
import MarkdownBody from './MarkdownBody.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
+1
View File
@@ -50,6 +50,7 @@ export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export { default as MarkdownBody } from './MarkdownBody.vue'
export type { MultiSelectOption } from './MultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
@@ -36,20 +36,26 @@
</div>
</div>
<div class="ml-8 mt-3 rounded-2xl bg-bg-raised px-4 py-3">
<div class="changelog-body" v-html="renderHighlightedString(entry.body)" />
<MarkdownBody body-class="changelog-body" :markdown="entry.body" highlight />
</div>
</div>
</template>
<script setup lang="ts">
import type { VersionEntry } from '@modrinth/blog/changelog'
import { renderHighlightedString } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import AutoLink from '../base/AutoLink.vue'
import MarkdownBody from '../base/MarkdownBody.vue'
type VersionEntry = {
date: dayjs.Dayjs
product: 'web' | 'hosting' | 'app'
version?: string
body: string
}
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -7,10 +7,10 @@
</template>
<div class="flex flex-col gap-4">
<template v-if="description">
<div
<MarkdownBody
v-if="markdown"
class="markdown-body max-w-[35rem]"
v-html="renderString(description)"
body-class="max-w-[35rem]"
:markdown="description"
/>
<p v-else class="max-w-[35rem] m-0">
{{ description }}
@@ -50,10 +50,10 @@
<script setup>
import { TrashIcon, XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { computed, ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import MarkdownBody from '../base/MarkdownBody.vue'
import StyledInput from '../base/StyledInput.vue'
import NewModal from './NewModal.vue'
@@ -1,8 +1,8 @@
<template>
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
<MarkdownBody :markdown="description ?? ''" highlight />
</template>
<script setup lang="ts">
import { renderHighlightedString } from '@modrinth/utils'
import MarkdownBody from '../base/MarkdownBody.vue'
withDefaults(
defineProps<{
@@ -177,8 +177,9 @@
<div
v-else-if="selectedVersion.changelog"
class="markdown [&_img]:max-w-full [&_img]:h-auto"
v-html="renderHighlightedString(selectedVersion.changelog)"
/>
>
<MarkdownBody :markdown="selectedVersion.changelog" highlight />
</div>
<div v-else class="text-secondary italic">
{{ formatMessage(messages.noChangelog) }}
</div>
@@ -248,12 +249,13 @@ import {
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { capitalizeString } from '@modrinth/utils'
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import MarkdownBody from '#ui/components/base/MarkdownBody.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
File diff suppressed because one or more lines are too long
+1
View File
@@ -4,6 +4,7 @@ export * from './events'
export * from './file-extensions'
export * from './game-modes'
export * from './loaders'
export * from './markdown'
export * from './notices'
export * from './savable'
export * from './search'
+3
View File
@@ -0,0 +1,3 @@
export * from './plain-text'
export * from './render'
export * from './security'
@@ -0,0 +1,25 @@
export function markdownToPlainText(markdown: string): string {
if (!markdown) return ''
const withoutCode = markdown.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`]*`/g, ' ')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[([^\]]*)]\([^)]+\)/g, '$1')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim()
}
export function countMarkdownText(markdown: string): number {
return markdownToPlainText(markdown).length
}
export function markdownHasImage(markdown: string): boolean {
if (!markdown) return false
return /!\[[^\]]*]\([^)]+\)/.test(markdown) || /<img[\s>]/i.test(markdown)
}
+114
View File
@@ -0,0 +1,114 @@
import { renderHTML } from '@comark/html'
import { createParse } from 'comark'
import highlight from 'comark/plugins/highlight'
import { codeToHtml } from 'shiki'
import { modrinthMarkdownSecurity, type ModrinthMarkdownSecurityOptions } from './security'
export interface MarkdownRenderOptions extends ModrinthMarkdownSecurityOptions {
highlightCode?: boolean
}
export type MarkdownRenderer = (markdown: string) => Promise<string>
const shikiThemes = {
light: 'github-light',
dark: 'github-dark',
}
function normalizeLanguage(language: string): string {
const normalized = language.trim().toLowerCase()
const aliases: Record<string, string> = {
command: 'mcfunction',
fxml: 'xml',
htm: 'html',
js: 'javascript',
json5: 'json',
kt: 'kotlin',
kubejs: 'javascript',
mcui: 'xml',
py: 'python',
sk: 'skript',
xhtml: 'html',
yml: 'yaml',
}
return aliases[normalized] ?? normalized
}
export function createMarkdownRenderer(options: MarkdownRenderOptions = {}): MarkdownRenderer {
const plugins = []
if (options.highlightCode) {
plugins.push(
highlight({
preStyles: false,
registerDefaultLanguages: true,
// TODO: Add Shiki grammars for skript and mcfunction before re-enabling those legacy Highlight.js aliases.
}),
)
}
plugins.push(modrinthMarkdownSecurity(options))
const parse = createParse({
autoClose: false,
html: true,
plugins,
})
return async (markdown: string) => {
if (!markdown) return ''
const tree = await parse(markdown)
return renderHTML(tree)
}
}
const renderMarkdownDefault = createMarkdownRenderer()
const renderHighlightedMarkdownDefault = createMarkdownRenderer({ highlightCode: true })
export async function renderMarkdown(markdown: string): Promise<string> {
return renderMarkdownDefault(markdown)
}
export async function renderHighlightedMarkdown(markdown: string): Promise<string> {
return renderHighlightedMarkdownDefault(markdown)
}
export async function renderMarkdownInline(markdown: string): Promise<string> {
const html = await renderMarkdown(markdown)
const match = html.match(/^<p>([\s\S]*)<\/p>\s*$/)
return match ? match[1] : html
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
function plainCodeLines(code: string): string[] {
return code.split('\n').map(escapeHtml)
}
export async function highlightCodeLines(code: string, language: string): Promise<string[]> {
if (!code) return []
try {
const html = await codeToHtml(code, {
lang: normalizeLanguage(language),
themes: shikiThemes,
})
if (typeof DOMParser === 'undefined') return plainCodeLines(code)
const document = new DOMParser().parseFromString(html, 'text/html')
const lines = [...document.querySelectorAll('span.line')].map((line) => line.innerHTML)
return lines.length > 0 ? lines : plainCodeLines(code)
} catch {
return plainCodeLines(code)
}
}
+543
View File
@@ -0,0 +1,543 @@
import type { ComarkNode } from 'comark'
import { defineComarkPlugin } from 'comark/parse'
type AttributeMap = Record<string, unknown>
type MutableComarkElement = [string | null, AttributeMap, ...ComarkNode[]]
export interface ModrinthMarkdownSecurityOptions {
baseUrl?: string
stripBaseUrl?: string
}
const ALLOWED_TAGS = new Set([
'a',
'abbr',
'address',
'area',
'article',
'aside',
'audio',
'b',
'bdi',
'bdo',
'big',
'blockquote',
'br',
'caption',
'center',
'cite',
'code',
'col',
'colgroup',
'dd',
'del',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'font',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'li',
'map',
'mark',
'nav',
'ol',
'p',
'picture',
'pre',
's',
'section',
'small',
'source',
'span',
'strike',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'tt',
'u',
'ul',
'video',
])
const ALLOWED_ATTRIBUTES: Record<string, Set<string>> = {
a: new Set(['href', 'rel', 'target', 'title']),
abbr: new Set(['title']),
area: new Set(['alt', 'coords', 'href', 'shape']),
audio: new Set(['autoplay', 'controls', 'crossorigin', 'loop', 'muted', 'preload', 'src']),
blockquote: new Set(['cite']),
col: new Set(['align', 'span', 'valign', 'width']),
colgroup: new Set(['align', 'span', 'valign', 'width']),
del: new Set(['datetime']),
details: new Set(['open']),
font: new Set(['color', 'face', 'size']),
h1: new Set(['id']),
h2: new Set(['id']),
h3: new Set(['id']),
h4: new Set(['id']),
h5: new Set(['id']),
h6: new Set(['id']),
iframe: new Set(['allowfullscreen', 'frameborder', 'height', 'src', 'width', 'start', 'end']),
img: new Set(['align', 'alt', 'height', 'loading', 'src', 'style', 'title', 'usemap', 'width']),
input: new Set(['checked', 'disabled', 'type']),
ins: new Set(['datetime']),
kbd: new Set(['id']),
map: new Set(['name']),
p: new Set(['align']),
div: new Set(['align']),
pre: new Set(['class', 'style', 'tabindex']),
code: new Set(['class']),
span: new Set(['class', 'style']),
source: new Set(['media', 'sizes', 'src', 'srcset', 'type']),
table: new Set(['align', 'border', 'valign', 'width']),
tbody: new Set(['align', 'valign']),
td: new Set(['align', 'colspan', 'rowspan', 'style', 'valign', 'width']),
tfoot: new Set(['align', 'valign']),
th: new Set(['align', 'colspan', 'rowspan', 'style', 'valign', 'width']),
thead: new Set(['align', 'valign']),
tr: new Set(['align', 'rowspan', 'valign']),
video: new Set([
'autoplay',
'controls',
'crossorigin',
'height',
'loop',
'muted',
'playsinline',
'poster',
'preload',
'src',
'width',
]),
}
const MEDIA_URL_TAGS = new Set(['audio', 'img', 'source', 'video'])
const MEDIA_URL_ATTRIBUTES = new Set(['poster', 'src', 'srcset'])
const ALLOWED_MEDIA_HOSTNAMES = new Set([
'imgur.com',
'i.imgur.com',
'cdn-raw.modrinth.com',
'cdn.modrinth.com',
'staging-cdn-raw.modrinth.com',
'staging-cdn.modrinth.com',
'github.com',
'raw.githubusercontent.com',
'img.shields.io',
'i.postimg.cc',
'wsrv.nl',
'cf.way2muchnoise.eu',
'bstats.org',
])
const ALLOWED_MEDIA_HOSTNAME_SUFFIXES = ['.github.io']
const ALLOWED_IFRAME_SOURCES = [
{
url: /^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}/,
allowedParameters: [/start=\d+/, /end=\d+/],
},
{
url: /^https?:\/\/(www\.)?discord\.com\/widget/,
allowedParameters: [/id=\d{18,19}/],
},
]
const BARE_URL_PATTERN = /https?:\/\/[^\s<]+/g
function isElement(node: ComarkNode): node is MutableComarkElement {
return Array.isArray(node)
}
function normalizeAttributeName(name: string): string {
return name.startsWith(':') ? name.slice(1) : name.toLowerCase()
}
function stringifyAttribute(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return undefined
}
function normalizeUrl(value: string, options: ModrinthMarkdownSecurityOptions): string {
if (!options.baseUrl) return value
try {
const url = new URL(value, options.baseUrl)
const href = url.href
return options.stripBaseUrl && href.startsWith(options.stripBaseUrl)
? href.slice(options.stripBaseUrl.length)
: href
} catch {
return value
}
}
function hasSafeUrlProtocol(value: string, allowDataImage = false): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed === '#') return true
if (
trimmed.startsWith('/') ||
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('#')
) {
return true
}
try {
const url = new URL(trimmed)
if (['http:', 'https:', 'mailto:', 'tel:', 'ftp:'].includes(url.protocol)) return true
return allowDataImage && url.protocol === 'data:' && trimmed.startsWith('data:image/')
} catch {
return false
}
}
function isAllowedMediaHost(hostname: string): boolean {
return (
ALLOWED_MEDIA_HOSTNAMES.has(hostname) ||
ALLOWED_MEDIA_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
)
}
function sanitizeMediaUrl(value: string, attrName: string): string | undefined {
const allowDataImage = attrName !== 'srcset'
if (!hasSafeUrlProtocol(value, allowDataImage)) return undefined
if (value.trim().startsWith('data:')) return value
try {
const url = new URL(value)
if (url.hostname.includes('wsrv.nl')) {
url.searchParams.delete('errorredirect')
url.searchParams.delete('default')
}
if (!isAllowedMediaHost(url.hostname)) {
return `https://wsrv.nl/?url=${encodeURIComponent(url.toString().replaceAll('&amp;', '&'))}&n=-1`
}
return url.toString()
} catch {
return value
}
}
function sanitizeSrcset(value: string): string | undefined {
const srcset = value
.split(',')
.map((entry) => {
const parts = entry.trim().split(/\s+/)
const url = parts.shift()
if (!url) return undefined
const sanitized = sanitizeMediaUrl(url, 'srcset')
return sanitized ? [sanitized, ...parts].join(' ') : undefined
})
.filter((entry): entry is string => !!entry)
.join(', ')
return srcset || undefined
}
function sanitizeIframeSrc(value: string): string | undefined {
try {
const url = new URL(value)
for (const source of ALLOWED_IFRAME_SOURCES) {
if (!source.url.test(url.href)) continue
const searchParams = new URLSearchParams(url.searchParams)
url.searchParams.forEach((paramValue, key) => {
if (!source.allowedParameters.some((param) => param.test(`${key}=${paramValue}`))) {
searchParams.delete(key)
}
})
url.search = searchParams.toString()
return url.toString()
}
} catch {
return undefined
}
return undefined
}
function isSafeStyleValue(value: string): boolean {
return !/[<>]/.test(value) && !/url\s*\(|expression\s*\(/i.test(value)
}
function sanitizeStyle(value: string, tag: string): string | undefined {
const declarations = value
.split(';')
.map((declaration) => {
const [propertyRaw, ...valueParts] = declaration.split(':')
const property = propertyRaw?.trim().toLowerCase()
const propertyValue = valueParts.join(':').trim()
if (!property || !propertyValue) return undefined
if (!isSafeStyleValue(propertyValue)) return undefined
if (property === 'image-rendering' && propertyValue === 'pixelated') {
return `${property}: ${propertyValue}`
}
if (property === 'text-align' && /^(center|left|right)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
if (property === 'float' && /^(left|right)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
if (tag === 'pre' && property.startsWith('--shiki-')) {
return `${property}: ${propertyValue}`
}
if (tag === 'span') {
if (
['color', 'background-color'].includes(property) &&
/^(#[\da-f]{3,8}|rgb\([\d\s,.%]+\)|rgba\([\d\s,.%]+\)|hsl\([\d\s,.%]+\)|hsla\([\d\s,.%]+\)|var\(--shiki-[\w-]+\)|currentcolor|transparent|inherit)$/i.test(
propertyValue,
)
) {
return `${property}: ${propertyValue}`
}
if (property === 'font-style' && /^(normal|italic)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
if (property === 'font-weight' && /^(normal|bold|[1-9]00)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
if (property === 'text-decoration' && /^(none|underline|line-through)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
if (property === 'display' && /^(inline|inline-block|block)$/.test(propertyValue)) {
return `${property}: ${propertyValue}`
}
}
return undefined
})
.filter((declaration): declaration is string => !!declaration)
return declarations.length > 0 ? declarations.join('; ') : undefined
}
function sanitizeClass(tag: string, value: string): string | undefined {
const classes = value
.split(/\s+/)
.filter(
(className) =>
className.startsWith('language-') ||
(tag === 'pre' &&
/^(shiki|shiki-themes|material-theme-lighter|material-theme-palenight)$/.test(className)) ||
(tag === 'span' && ['line', 'highlight'].includes(className)),
)
return classes.length > 0 ? classes.join(' ') : undefined
}
function sanitizeHref(value: string, options: ModrinthMarkdownSecurityOptions): string | undefined {
const normalized = normalizeUrl(value, options)
return hasSafeUrlProtocol(normalized) ? normalized : undefined
}
function isExactModrinthLink(href: string): boolean {
try {
return new URL(href).hostname === 'modrinth.com'
} catch {
return false
}
}
function sanitizeAttribute(
tag: string,
rawName: string,
rawValue: unknown,
options: ModrinthMarkdownSecurityOptions,
): [string, unknown] | undefined {
const name = normalizeAttributeName(rawName)
if (name.startsWith('on')) return undefined
const allowedAttributes = ALLOWED_ATTRIBUTES[tag]
if (!allowedAttributes?.has(name)) return undefined
const value = stringifyAttribute(rawValue)
if (value === undefined) return undefined
if (tag === 'iframe' && name === 'src') {
const src = sanitizeIframeSrc(value)
return src ? [name, src] : undefined
}
if (tag === 'a' || tag === 'area') {
if (name === 'href') {
const href = sanitizeHref(value, options)
return href ? [name, href] : undefined
}
if (name === 'target' && !['_blank', '_self', '_parent', '_top'].includes(value)) {
return undefined
}
}
if (MEDIA_URL_TAGS.has(tag) && MEDIA_URL_ATTRIBUTES.has(name)) {
if (name === 'srcset') {
const srcset = sanitizeSrcset(normalizeUrl(value, options))
return srcset ? [name, srcset] : undefined
}
const normalized = normalizeUrl(value, options)
const sanitized = sanitizeMediaUrl(normalized, name)
return sanitized ? [name, sanitized] : undefined
}
if (name === 'style') {
const style = sanitizeStyle(value, tag)
return style ? [name, style] : undefined
}
if (name === 'class') {
const className = sanitizeClass(tag, value)
return className ? [name, className] : undefined
}
if (name === 'tabindex' && !/^-?\d+$/.test(value)) return undefined
if (tag === 'input' && name === 'type' && value !== 'checkbox') return undefined
return [name, value]
}
function sanitizeElement(
element: MutableComarkElement,
options: ModrinthMarkdownSecurityOptions,
): false | void {
const tag = element[0]
if (typeof tag !== 'string' || !ALLOWED_TAGS.has(tag)) return false
const attrs = element[1] as AttributeMap
const sanitizedAttrs: AttributeMap = {}
for (const [rawName, rawValue] of Object.entries(attrs)) {
const sanitized = sanitizeAttribute(tag, rawName, rawValue, options)
if (!sanitized) continue
const [name, value] = sanitized
sanitizedAttrs[name] = value
}
if (tag === 'a') {
const href = stringifyAttribute(sanitizedAttrs.href)
if (!href) return false
if (!isExactModrinthLink(href)) {
sanitizedAttrs.rel = 'noopener nofollow ugc'
}
}
if (tag === 'area' && !sanitizedAttrs.href) return false
if (tag === 'iframe' && !sanitizedAttrs.src) return false
element[1] = sanitizedAttrs
}
function splitTextByBareUrls(text: string): ComarkNode[] {
const nodes: ComarkNode[] = []
let lastIndex = 0
for (const match of text.matchAll(BARE_URL_PATTERN)) {
const url = match[0].replace(/[),.;:!?]+$/, '')
const index = match.index ?? 0
if (index > lastIndex) nodes.push(text.slice(lastIndex, index))
nodes.push(['a', { href: url }, url])
lastIndex = index + url.length
}
if (lastIndex === 0) return [text]
if (lastIndex < text.length) nodes.push(text.slice(lastIndex))
return nodes
}
function linkifyTextNodes(nodes: ComarkNode[], parentTag?: string): void {
const shouldSkip = parentTag === 'a' || parentTag === 'code' || parentTag === 'pre'
if (shouldSkip) return
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (typeof node === 'string' && !shouldSkip) {
const replacements = splitTextByBareUrls(node)
if (replacements.length > 1) {
nodes.splice(i, 1, ...replacements)
i += replacements.length - 1
}
continue
}
if (isElement(node)) {
const children = node.slice(2)
linkifyTextNodes(children, node[0] as string | undefined)
node.splice(2, node.length - 2, ...children)
}
}
}
function sanitizeNodes(nodes: ComarkNode[], options: ModrinthMarkdownSecurityOptions): void {
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (!isElement(node)) continue
const result = sanitizeElement(node, options)
if (result === false) {
nodes.splice(i, 1)
continue
}
const children = node.slice(2)
sanitizeNodes(children, options)
node.splice(2, node.length - 2, ...children)
}
}
export const modrinthMarkdownSecurity = defineComarkPlugin(
(options: ModrinthMarkdownSecurityOptions = {}) => ({
name: 'modrinth-markdown-security',
post(state) {
linkifyTextNodes(state.tree.nodes)
sanitizeNodes(state.tree.nodes, options)
},
}),
)
-106
View File
@@ -1,106 +0,0 @@
import hljs from 'highlight.js/lib/core'
// Configs
import gradle from 'highlight.js/lib/languages/gradle'
// Coding
import groovy from 'highlight.js/lib/languages/groovy'
import ini from 'highlight.js/lib/languages/ini'
import java from 'highlight.js/lib/languages/java'
// Scripting
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import kotlin from 'highlight.js/lib/languages/kotlin'
import lua from 'highlight.js/lib/languages/lua'
import properties from 'highlight.js/lib/languages/properties'
import python from 'highlight.js/lib/languages/python'
import scala from 'highlight.js/lib/languages/scala'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
import mcfunction from 'highlightjs-mcfunction'
import { configuredXss, md } from '../parse'
import skript from './skript'
/* REGISTRATION */
// Scripting
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('lua', lua)
hljs.registerLanguage('skript', skript)
hljs.registerLanguage('mcfunction', mcfunction)
// Coding
hljs.registerLanguage('java', java)
hljs.registerLanguage('kotlin', kotlin)
hljs.registerLanguage('scala', scala)
hljs.registerLanguage('groovy', groovy)
// Configs
hljs.registerLanguage('gradle', gradle)
hljs.registerLanguage('json', json)
hljs.registerLanguage('ini', ini)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('properties', properties)
/* ALIASES */
// Scripting
hljs.registerAliases(['js'], { languageName: 'javascript' })
hljs.registerAliases(['py'], { languageName: 'python' })
hljs.registerAliases(['sk'], { languageName: 'skript' })
hljs.registerAliases(['command'], { languageName: 'mcfunction' })
hljs.registerAliases(['kubejs'], { languageName: 'javascript' })
// Coding
hljs.registerAliases(['kt'], { languageName: 'kotlin' })
// Configs
hljs.registerAliases(['json5'], { languageName: 'json' })
hljs.registerAliases(['toml'], { languageName: 'ini' })
hljs.registerAliases(['yml'], { languageName: 'yaml' })
hljs.registerAliases(['html', 'htm', 'xhtml', 'mcui', 'fxml'], { languageName: 'xml' })
export { hljs }
export const renderHighlightedString = (string) =>
configuredXss.process(
md({
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch {
/* empty */
}
}
return ''
},
}).render(string),
)
export const highlightCodeLines = (code: string, language: string): string[] => {
if (!code) return []
if (!hljs.getLanguage(language)) {
return code.split('\n')
}
try {
const highlighted = hljs.highlight(code, { language }).value
const openTags: string[] = []
const processedHtml = highlighted.replace(/(<span [^>]+>)|(<\/span>)|(\n)/g, (match) => {
if (match === '\n') {
return '</span>'.repeat(openTags.length) + '\n' + openTags.join('')
}
if (match === '</span>') {
openTags.pop()
} else {
openTags.push(match)
}
return match
})
return processedHtml.split('\n')
} catch {
return code.split('\n')
}
}
-140
View File
@@ -1,140 +0,0 @@
import type { HLJSApi } from 'highlight.js'
/*
Language: Skript
Description: Skript language support for Minecraft server scripting
Website: https:
Category: scripting
*/
export default function (hljs: HLJSApi) {
const CONTROL_KEYWORDS = {
keyword: 'if else while loop return continue at stop cancel false true now',
built_in: 'parse do',
}
const ENTITIES = 'player players victim attacker sender loop-player shooter console'
const CONDITION_OPERATORS =
'contains has have is was were are does can cannot ' +
"hasn't haven't isn't wasn't weren't aren't doesn't can't"
return {
name: 'Skript',
aliases: ['sk'],
case_insensitive: true,
keywords: CONTROL_KEYWORDS,
contains: [
{
className: 'comment',
begin: '(?<!#)#(?!#)',
end: '$',
relevance: 0,
},
{
className: 'string',
begin: '"',
end: '"',
illegal: '\\n',
contains: [hljs.BACKSLASH_ESCAPE],
},
{
className: 'section',
begin: '\\bon\\s+',
end: ':',
excludeEnd: false,
keywords: 'on',
relevance: 10,
},
{
className: 'section',
begin: '\\bcommand\\s+/',
end: ':',
excludeEnd: false,
relevance: 10,
},
{
className: 'variable',
begin: '{',
end: '}',
contains: [
{
className: 'variable',
begin: ':+',
relevance: 0,
},
],
},
{
className: 'params',
begin: '<',
end: '>',
relevance: 5,
},
{
className: 'function',
begin: '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\()',
relevance: 0,
},
{
className: 'variable',
begin: '\\b(loop|event)-[a-zA-Z]+\\b',
relevance: 5,
},
{
className: 'number',
variants: [
{ begin: '\\b\\d+(\\.\\d+)?\\s+(tick|second|minute|hour|day)s?\\b' },
{ begin: '\\ba\\s+(tick|second|minute|hour|day)s?\\b' },
{ begin: '\\b(minecraft|mc|real|rl|irl)\\s+(tick|second|minute|hour|day)s?\\b' },
],
relevance: 0,
},
hljs.NUMBER_MODE,
{
className: 'built_in',
begin: '\\b(' + ENTITIES + ')\\b',
relevance: 0,
},
{
className: 'built_in',
begin: "(uuid\\s+of|'s\\s+uuid|location\\s+of|'s\\s+location)",
relevance: 0,
},
{
className: 'operator',
begin: '\\b(' + CONDITION_OPERATORS.split(' ').join('|') + ')\\b',
relevance: 0,
},
{
className: 'operator',
begin: '::?',
relevance: 0,
},
{
className: 'literal',
begin: '\\b(true|false)\\b',
relevance: 0,
},
{
className: 'keyword',
begin: '\\b(stop|cancel|halt|enable|disable|trigger|server)\\b',
relevance: 5,
},
],
}
}
-2
View File
@@ -1,7 +1,5 @@
export * from './billing'
export * from './highlightjs'
export * from './licenses'
export * from './parse'
export * from './projects'
export * from './servers'
export * from './three/skin-rendering'
+1 -6
View File
@@ -18,14 +18,9 @@
"@codemirror/language": "^6.9.3",
"@codemirror/state": "^6.3.2",
"@codemirror/view": "^6.22.1",
"@types/markdown-it": "^14.1.1",
"@types/three": "^0.172.0",
"dayjs": "^1.11.10",
"highlight.js": "^11.9.0",
"highlightjs-mcfunction": "github:modrinth/better-highlightjs-mcfunction",
"markdown-it": "^14.1.0",
"ofetch": "^1.3.4",
"three": "^0.172.0",
"xss": "^1.0.14"
"three": "^0.172.0"
}
}
-187
View File
@@ -1,187 +0,0 @@
import MarkdownIt from 'markdown-it'
import xss from 'xss'
// @ts-expect-error xss types don't reflect CJS default export shape
const { escapeAttrValue, FilterXSS, safeAttrValue, whiteList } = xss
export const configuredXss = new FilterXSS({
whiteList: {
...whiteList,
summary: [],
h1: ['id'],
h2: ['id'],
h3: ['id'],
h4: ['id'],
h5: ['id'],
h6: ['id'],
kbd: ['id'],
input: ['checked', 'disabled', 'type'],
iframe: ['width', 'height', 'allowfullscreen', 'frameborder', 'start', 'end'],
img: [...(whiteList.img || []), 'usemap', 'style', 'align'],
map: ['name'],
area: [...(whiteList.a || []), 'coords'],
a: [...(whiteList.a || []), 'rel'],
td: [...(whiteList.td || []), 'style'],
th: [...(whiteList.th || []), 'style'],
picture: [],
source: ['media', 'sizes', 'src', 'srcset', 'type'],
p: [...(whiteList.p || []), 'align'],
div: [...(whiteList.p || []), 'align'],
},
css: {
whiteList: {
'image-rendering': /^pixelated$/,
'text-align': /^center|left|right$/,
float: /^left|right$/,
},
},
onIgnoreTagAttr: (tag, name, value) => {
// Allow iframes from acceptable sources
if (tag === 'iframe' && name === 'src') {
const allowedSources = [
{
url: /^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}/,
allowedParameters: [/start=\d+/, /end=\d+/],
},
{
url: /^https?:\/\/(www\.)?discord\.com\/widget/,
allowedParameters: [/id=\d{18,19}/],
},
]
try {
const url = new URL(value)
for (const source of allowedSources) {
if (!source.url.test(url.href)) {
continue
}
const newSearchParams = new URLSearchParams(url.searchParams)
url.searchParams.forEach((value, key) => {
if (!source.allowedParameters.some((param) => param.test(`${key}=${value}`))) {
newSearchParams.delete(key)
}
})
url.search = newSearchParams.toString()
return `${name}="${escapeAttrValue(url.toString())}"`
}
} catch {
// ..
}
}
// For Highlight.JS
if (name === 'class' && ['pre', 'code', 'span'].includes(tag)) {
const allowedClasses: string[] = []
for (const className of value.split(/\s/g)) {
if (className.startsWith('hljs-') || className.startsWith('language-')) {
allowedClasses.push(className)
}
}
return `${name}="${escapeAttrValue(allowedClasses.join(' '))}"`
}
},
safeAttrValue(tag, name, value, cssFilter) {
if (
(tag === 'img' || tag === 'video' || tag === 'audio' || tag === 'source') &&
(name === 'src' || name === 'srcset' || name === 'poster') &&
!value.startsWith('data:')
) {
try {
const url = new URL(value)
if (url.hostname.includes('wsrv.nl')) {
url.searchParams.delete('errorredirect')
url.searchParams.delete('default')
}
const allowedHostnames = [
'imgur.com',
'i.imgur.com',
'cdn-raw.modrinth.com',
'cdn.modrinth.com',
'staging-cdn-raw.modrinth.com',
'staging-cdn.modrinth.com',
'github.com',
'raw.githubusercontent.com',
'img.shields.io',
'i.postimg.cc',
'wsrv.nl',
'cf.way2muchnoise.eu',
'bstats.org',
]
const allowedHostnameSuffixes = ['.github.io']
if (
!allowedHostnames.includes(url.hostname) &&
!allowedHostnameSuffixes.some((suffix) => url.hostname.endsWith(suffix))
) {
return safeAttrValue(
tag,
name,
`https://wsrv.nl/?url=${encodeURIComponent(
url.toString().replaceAll('&amp;', '&'),
)}&n=-1`,
cssFilter,
)
}
return safeAttrValue(tag, name, url.toString(), cssFilter)
} catch {
/* empty */
}
}
return safeAttrValue(tag, name, value, cssFilter)
},
})
export const md = (options = {}) => {
const md = new MarkdownIt('default', {
html: true,
linkify: true,
breaks: false,
...options,
})
const defaultLinkOpenRenderer =
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.linkify.set({
fuzzyLink: false,
fuzzyIP: false,
})
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const index = token.attrIndex('href')
if (token.attrs && index !== -1) {
const href = token.attrs[index][1]
try {
const url = new URL(href)
const allowedHostnames = ['modrinth.com']
if (allowedHostnames.includes(url.hostname)) {
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
}
} catch {
/* empty */
}
}
tokens[idx].attrSet('rel', 'noopener nofollow ugc')
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
}
return md
}
export const renderString = (string: string) => configuredXss.process(md().render(string))
+198 -120
View File
@@ -125,6 +125,9 @@ importers:
'@vueuse/core':
specifier: ^11.1.0
version: 11.3.0(vue@3.5.27(typescript@5.9.3))
comark:
specifier: ^0.3.2
version: 0.3.2(shiki@4.0.2)
dayjs:
specifier: ^1.11.10
version: 1.11.19
@@ -320,9 +323,6 @@ importers:
fuse.js:
specifier: ^6.6.2
version: 6.6.2
highlight.js:
specifier: ^11.7.0
version: 11.11.1
intl-messageformat:
specifier: ^10.7.7
version: 10.7.18
@@ -341,9 +341,6 @@ importers:
lru-cache:
specifier: ^11.2.4
version: 11.2.5
markdown-it:
specifier: 14.1.0
version: 14.1.0
pathe:
specifier: ^1.1.2
version: 1.1.2
@@ -374,9 +371,6 @@ importers:
vue3-apexcharts:
specifier: ^1.5.2
version: 1.10.0(apexcharts@4.7.0)(vue@3.5.27(typescript@5.9.3))
xss:
specifier: ^1.0.14
version: 1.0.15
devDependencies:
'@formatjs/cli':
specifier: ^6.2.12
@@ -482,9 +476,9 @@ importers:
packages/blog:
dependencies:
'@modrinth/utils':
'@modrinth/ui':
specifier: workspace:*
version: link:../utils
version: link:../ui
dayjs:
specifier: ^1.11.10
version: 1.11.19
@@ -625,6 +619,9 @@ importers:
'@codemirror/view':
specifier: ^6.22.1
version: 6.39.12
'@comark/html':
specifier: ^0.3.1
version: 0.3.1(shiki@4.0.2)
'@intercom/messenger-js-sdk':
specifier: ^0.0.14
version: 0.0.14
@@ -634,9 +631,6 @@ importers:
'@modrinth/assets':
specifier: workspace:*
version: link:../assets
'@modrinth/blog':
specifier: workspace:*
version: link:../blog
'@modrinth/utils':
specifier: workspace:*
version: link:../utils
@@ -655,9 +649,6 @@ importers:
'@types/dompurify':
specifier: ^3.0.5
version: 3.2.0
'@types/markdown-it':
specifier: ^14.1.1
version: 14.1.2
'@types/three':
specifier: ^0.172.0
version: 0.172.0
@@ -682,6 +673,9 @@ importers:
apexcharts:
specifier: ^4.0.0
version: 4.7.0
comark:
specifier: ^0.3.2
version: 0.3.2(shiki@4.0.2)
dayjs:
specifier: ^1.11.10
version: 1.11.19
@@ -700,9 +694,6 @@ importers:
fuse.js:
specifier: ^6.6.2
version: 6.6.2
highlight.js:
specifier: ^11.9.0
version: 11.11.1
intl-messageformat:
specifier: ^10.7.7
version: 10.7.18
@@ -712,9 +703,6 @@ importers:
lru-cache:
specifier: ^11.2.4
version: 11.2.5
markdown-it:
specifier: ^13.0.2
version: 13.0.2
motion-v:
specifier: ^2.2.1
version: 2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3))
@@ -724,6 +712,9 @@ importers:
qrcode.vue:
specifier: ^3.4.1
version: 3.8.0(vue@3.5.27(typescript@5.9.3))
shiki:
specifier: ^4.0.2
version: 4.0.2
three:
specifier: ^0.172.0
version: 0.172.0
@@ -742,9 +733,6 @@ importers:
vue3-apexcharts:
specifier: ^1.5.2
version: 1.10.0(apexcharts@4.7.0)(vue@3.5.27(typescript@5.9.3))
xss:
specifier: ^1.0.14
version: 1.0.15
devDependencies:
'@formatjs/cli':
specifier: ^6.2.12
@@ -824,33 +812,18 @@ importers:
'@codemirror/view':
specifier: ^6.22.1
version: 6.39.12
'@types/markdown-it':
specifier: ^14.1.1
version: 14.1.2
'@types/three':
specifier: ^0.172.0
version: 0.172.0
dayjs:
specifier: ^1.11.10
version: 1.11.19
highlight.js:
specifier: ^11.9.0
version: 11.11.1
highlightjs-mcfunction:
specifier: github:modrinth/better-highlightjs-mcfunction
version: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e
markdown-it:
specifier: ^14.1.0
version: 14.1.0
ofetch:
specifier: ^1.3.4
version: 1.5.1
three:
specifier: ^0.172.0
version: 0.172.0
xss:
specifier: ^1.0.14
version: 1.0.15
devDependencies:
'@modrinth/api-client':
specifier: workspace:*
@@ -1196,6 +1169,20 @@ packages:
'@codemirror/view@6.39.12':
resolution: {integrity: sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==}
'@comark/html@0.3.1':
resolution: {integrity: sha512-vlFWZ8TMSaQ/WXwzktnZyPsm8XuE7GWEZ7ag2jXFL88Wf2ibSHvFgsoiDr9gDX9752haT4CU0Biph+5xXJ30Vg==}
peerDependencies:
beautiful-mermaid: '>=1.0'
katex: '>=0.16'
shiki: ^4.0.0
peerDependenciesMeta:
beautiful-mermaid:
optional: true
katex:
optional: true
shiki:
optional: true
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -3985,36 +3972,64 @@ packages:
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
'@shikijs/core@4.0.2':
resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@1.29.2':
resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
'@shikijs/engine-javascript@4.0.2':
resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@1.29.2':
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
'@shikijs/engine-oniguruma@4.0.2':
resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
engines: {node: '>=20'}
'@shikijs/langs@1.29.2':
resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
'@shikijs/langs@4.0.2':
resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
engines: {node: '>=20'}
'@shikijs/primitive@4.0.2':
resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
engines: {node: '>=20'}
'@shikijs/themes@1.29.2':
resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
'@shikijs/themes@4.0.2':
resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
engines: {node: '>=20'}
'@shikijs/types@1.29.2':
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
'@shikijs/types@4.0.2':
resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -4439,9 +4454,6 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -5492,6 +5504,20 @@ packages:
colord@2.9.3:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
comark@0.3.2:
resolution: {integrity: sha512-wZ16V3PPItX49EScg6yDswqQ24bsMAhujZUGIf1dBKMmZjqGBa4HmXDe+WZNeqNQSOd8v4AJJT1NKyrvgdf3dA==}
peerDependencies:
beautiful-mermaid: ^1.1.3
katex: ^0.16.45
shiki: ^4.0.0
peerDependenciesMeta:
beautiful-mermaid:
optional: true
katex:
optional: true
shiki:
optional: true
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -5646,9 +5672,6 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssfilter@0.0.10:
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
cssnano-preset-default@7.0.10:
resolution: {integrity: sha512-6ZBjW0Lf1K1Z+0OKUAUpEN62tSXmYChXWi2NAA0afxEVsj9a+MbcB1l5qel6BHJHmULai2fCGRthCeKSFbScpA==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
@@ -5806,19 +5829,35 @@ packages:
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom-serializer@3.1.1:
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
engines: {node: '>=20.19.0'}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domelementtype@3.0.0:
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
engines: {node: '>=20.19.0'}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domhandler@6.0.1:
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
engines: {node: '>=20.19.0'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
domutils@4.0.2:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -5895,10 +5934,6 @@ packages:
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@3.0.1:
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
engines: {node: '>=0.12'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -5911,6 +5946,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -6564,14 +6603,6 @@ packages:
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e:
resolution: {tarball: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e}
version: 1.0.0
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -6599,6 +6630,10 @@ packages:
html-whitespace-sensitive-tag-names@3.0.1:
resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==}
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -7150,9 +7185,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@4.0.1:
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@@ -7247,18 +7279,13 @@ packages:
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
markdown-exit@1.0.0-beta.9:
resolution: {integrity: sha512-5tzrMKMF367amyBly131vm6eGuWRL2DjBqWaFmPzPbLyuxP0XOmyyyroOAIXuBAMF/3kZbbfqOxvW/SotqKqbQ==}
markdown-extensions@2.0.0:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
markdown-it@13.0.2:
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
hasBin: true
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -7334,9 +7361,6 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdurl@1.0.1:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -8790,6 +8814,10 @@ packages:
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
shiki@4.0.2:
resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -9277,9 +9305,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -10092,11 +10117,6 @@ packages:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
xss@1.0.15:
resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
engines: {node: '>= 0.10.0'}
hasBin: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -10667,6 +10687,12 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@comark/html@0.3.1(shiki@4.0.2)':
dependencies:
comark: 0.3.2(shiki@4.0.2)
optionalDependencies:
shiki: 4.0.2
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -13169,6 +13195,14 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@4.0.2':
dependencies:
'@shikijs/primitive': 4.0.2
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
@@ -13181,6 +13215,12 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-javascript@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
@@ -13191,6 +13231,11 @@ snapshots:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
@@ -13199,6 +13244,16 @@ snapshots:
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/langs@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/primitive@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
@@ -13207,6 +13262,10 @@ snapshots:
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/themes@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/types@1.29.2':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
@@ -13217,6 +13276,11 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@4.0.2':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/is@7.2.0': {}
@@ -13622,11 +13686,6 @@ snapshots:
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -14961,6 +15020,15 @@ snapshots:
colord@2.9.3: {}
comark@0.3.2(shiki@4.0.2):
dependencies:
entities: 8.0.0
htmlparser2: 12.0.0
js-yaml: 4.1.1
markdown-exit: 1.0.0-beta.9
optionalDependencies:
shiki: 4.0.2
comma-separated-tokens@2.0.3: {}
commander@10.0.1: {}
@@ -15097,8 +15165,6 @@ snapshots:
cssesc@3.0.0: {}
cssfilter@0.0.10: {}
cssnano-preset-default@7.0.10(postcss@8.5.6):
dependencies:
browserslist: 4.28.1
@@ -15226,12 +15292,24 @@ snapshots:
domhandler: 5.0.3
entities: 4.5.0
dom-serializer@3.1.1:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
entities: 8.0.0
domelementtype@2.3.0: {}
domelementtype@3.0.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domhandler@6.0.1:
dependencies:
domelementtype: 3.0.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -15242,6 +15320,12 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils@4.0.2:
dependencies:
dom-serializer: 3.1.1
domelementtype: 3.0.0
domhandler: 6.0.1
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -15308,14 +15392,14 @@ snapshots:
entities@2.2.0: {}
entities@3.0.1: {}
entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
entities@8.0.0: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -16414,10 +16498,6 @@ snapshots:
hey-listen@1.0.8: {}
highlight.js@11.11.1: {}
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {}
hookable@5.5.3: {}
hookable@6.0.1: {}
@@ -16448,6 +16528,13 @@ snapshots:
html-whitespace-sensitive-tag-names@3.0.1: {}
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
domutils: 4.0.2
entities: 8.0.0
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -16908,10 +16995,6 @@ snapshots:
lines-and-columns@1.2.4: {}
linkify-it@4.0.1:
dependencies:
uc.micro: 1.0.6
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -17022,25 +17105,18 @@ snapshots:
'@babel/types': 7.29.0
source-map-js: 1.2.1
markdown-extensions@2.0.0: {}
markdown-it@13.0.2:
markdown-exit@1.0.0-beta.9:
dependencies:
argparse: 2.0.1
entities: 3.0.1
linkify-it: 4.0.1
mdurl: 1.0.1
uc.micro: 1.0.6
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
entities: 7.0.1
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-extensions@2.0.0: {}
markdown-table@3.0.4: {}
marked@7.0.4: {}
@@ -17236,8 +17312,6 @@ snapshots:
mdn-data@2.12.2: {}
mdurl@1.0.1: {}
mdurl@2.0.0: {}
merge-stream@2.0.0: {}
@@ -19249,6 +19323,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shiki@4.0.2:
dependencies:
'@shikijs/core': 4.0.2
'@shikijs/engine-javascript': 4.0.2
'@shikijs/engine-oniguruma': 4.0.2
'@shikijs/langs': 4.0.2
'@shikijs/themes': 4.0.2
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -19755,8 +19840,6 @@ snapshots:
typescript@5.9.3: {}
uc.micro@1.0.6: {}
uc.micro@2.1.0: {}
ufo@1.6.3: {}
@@ -20563,11 +20646,6 @@ snapshots:
xmlbuilder@11.0.1: {}
xss@1.0.15:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
xtend@4.0.2: {}
xxhash-wasm@1.1.0: {}