diff --git a/package.json b/package.json index 4e84fe7f1d..1b77452d1c 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ }, "pnpm": { "patchedDependencies": { - "readable-stream@2.3.8": "patches/readable-stream@2.3.8.patch" + "readable-stream@2.3.8": "patches/readable-stream@2.3.8.patch", + "comark@0.6.2": "patches/comark@0.6.2.patch" }, "peerDependencyRules": { "allowedVersions": { diff --git a/packages/assets/styles/classes.scss b/packages/assets/styles/classes.scss index 1891dbda89..69c4615458 100644 --- a/packages/assets/styles/classes.scss +++ b/packages/assets/styles/classes.scss @@ -825,15 +825,8 @@ a:not(.no-click-animation), padding-block-start: 0; } - blockquote, - details, - dl, - ol, - p, - code, - pre, - table, - ul { + + > * { margin-top: 0; margin-bottom: 16px; } @@ -844,13 +837,31 @@ a:not(.no-click-animation), line-height: 1.5; } - ul.contains-task-list, - li.task-list-item { - list-style: none; + p { + margin-top: 0; + margin-bottom: 16px; } - .task-list-item-checkbox { - margin-right: 0.5em; + ul { + list-style: disc; + padding-left: 1.5em; + + ul { + list-style: circle; + + ul { + list-style: square; + } + } + } + + ol { + list-style: decimal; + padding-left: 1.5em; + } + + li.task-list-item { + list-style: none; } h1 { diff --git a/packages/assets/styles/highlightjs.scss b/packages/assets/styles/highlightjs.scss index 9b5eb74335..1c6bf43f29 100644 --- a/packages/assets/styles/highlightjs.scss +++ b/packages/assets/styles/highlightjs.scss @@ -70,20 +70,3 @@ .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; - } -} diff --git a/packages/ui/src/components/base/MarkdownBody.vue b/packages/ui/src/components/base/MarkdownBody.vue index e5d676f7fd..18cecec1fa 100644 --- a/packages/ui/src/components/base/MarkdownBody.vue +++ b/packages/ui/src/components/base/MarkdownBody.vue @@ -37,6 +37,7 @@ import MarkdownCollectionEmbed from './markdown/MarkdownCollectionEmbed.vue' import MarkdownHighlightedPre from './markdown/MarkdownHighlightedPre.vue' import MarkdownOrganizationEmbed from './markdown/MarkdownOrganizationEmbed.vue' import MarkdownProjectEmbed from './markdown/MarkdownProjectEmbed.vue' +import MarkdownTaskCheckbox from './markdown/MarkdownTaskCheckbox.vue' import MarkdownUserEmbed from './markdown/MarkdownUserEmbed.vue' defineOptions({ @@ -82,6 +83,7 @@ const components = computed(() => user: MarkdownUserEmbed, organization: MarkdownOrganizationEmbed, collection: MarkdownCollectionEmbed, + input: MarkdownTaskCheckbox, ...(props.highlight ? { pre: MarkdownHighlightedPre } : {}), }, ) diff --git a/packages/ui/src/components/base/markdown/MarkdownTaskCheckbox.vue b/packages/ui/src/components/base/markdown/MarkdownTaskCheckbox.vue new file mode 100644 index 0000000000..92c7a7c6db --- /dev/null +++ b/packages/ui/src/components/base/markdown/MarkdownTaskCheckbox.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/utils/markdown/html-block.ts b/packages/utils/markdown/html-block.ts new file mode 100644 index 0000000000..2672741d77 --- /dev/null +++ b/packages/utils/markdown/html-block.ts @@ -0,0 +1,128 @@ +import { defineComarkPlugin } from 'comark/parse' +import { Parser as HtmlTagParser } from 'htmlparser2' +import type Token from 'markdown-it/lib/token.mjs' + +import type MarkdownIt = require('markdown-it') + +// This is here to keep backwards compatibility +// Comark closes most open html tags when it hits a blank line, +// we make it only do that if we can't find a closing tag, in order to mimic the side effect of +// markdown-it emitting html entirely raw (which your browser is then lenient with unclosed tags) +// whereas comark does its fancy AST which means it can't just emit the html raw +// Thank you for coming to my TED talk -chyz + +type RuleBlock = MarkdownIt.ParserBlock.RuleBlock +type StateBlock = MarkdownIt.StateBlock + +// If you know a better way to do this go ahead +function findHtmlBlockRuleFn(md: MarkdownIt): RuleBlock | undefined { + const rules = (md.block.ruler as unknown as { __rules__: { name: string; fn: RuleBlock }[] }) + .__rules__ + return rules?.find((r) => r.name === 'comark_html_block')?.fn +} + +function extractOpenTagName(line: string): string | undefined { + return /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(line)?.[1]?.toLowerCase() +} + +function parseOpenTagAttrs(line: string): [string, string][] { + const attrs: [string, string][] = [] + const parser = new HtmlTagParser({ + onopentag(_name, attrObj) { + for (const [key, value] of Object.entries(attrObj)) attrs.push([key, value]) + }, + }) + parser.write(line) + parser.end() + return attrs +} + +function markdownItModrinthHtmlBlock(md: MarkdownIt) { + const htmlBlockFn = findHtmlBlockRuleFn(md) + if (!htmlBlockFn) return + + md.block.ruler.before( + 'comark_html_block', + 'modrinth_html_block', + (state: StateBlock, startLine: number, endLine: number, silent: boolean) => { + if (silent) return htmlBlockFn(state, startLine, endLine, true) + + const tokensBefore = state.tokens.length + const lineBefore = state.line + const matched = htmlBlockFn(state, startLine, endLine, false) + if (!matched) return false + + const token = state.tokens[state.tokens.length - 1] + if (token.type !== 'html_block' || !token.map) return true + + const firstLine = (token.content.split('\n')[0] ?? '').trim() + if (firstLine.endsWith('/>')) return true + + const tagName = extractOpenTagName(firstLine) + if (!tagName) return true + + const openLineRe = new RegExp(`^<${tagName}(\\s|/?>|$)`, 'i') + const closeLineRe = new RegExp(`^$`, 'i') + let depth = 0 + let closeLine = -1 + for (let line = startLine + 1; line < endLine; line++) { + if (state.sCount[line] < state.blkIndent) break + const p = state.bMarks[line] + state.tShift[line] + const mx = state.eMarks[line] + const text = state.src.slice(p, mx).trim() + if (openLineRe.test(text) && !text.endsWith('/>')) { + depth++ + continue + } + if (closeLineRe.test(text)) { + if (depth > 0) { + depth-- + continue + } + closeLine = line + break + } + } + if (closeLine === -1 || closeLine < token.map[1]) return true + + state.tokens.length = tokensBefore + state.line = lineBefore + + const attrs = parseOpenTagAttrs(firstLine) + const oldParent = state.parentType + const oldLineMax = state.lineMax + state.parentType = 'comark_block' as StateBlock['parentType'] + state.lineMax = closeLine + + const tokenOpen: Token & { block?: boolean } = state.push('mdc_block_open', tagName, 1) + tokenOpen.block = true + tokenOpen.map = [startLine, closeLine + 1] + for (const [key, value] of attrs) tokenOpen.attrSet(key, value) + + const blkIndent = state.blkIndent + state.blkIndent = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const env = state.env as any + env.comarkBlockTokens ||= [] + env.comarkBlockTokens.unshift(tokenOpen) + state.md.block.tokenize(state, startLine + 1, closeLine) + state.blkIndent = blkIndent + env.comarkBlockTokens.shift() + + const tokenClose: Token & { block?: boolean } = state.push('mdc_block_close', tagName, -1) + tokenClose.map = [startLine, closeLine + 1] + tokenClose.block = true + + state.parentType = oldParent + state.lineMax = oldLineMax + state.line = closeLine + 1 + return true + }, + { alt: ['paragraph', 'reference', 'blockquote'] }, + ) +} + +export const modrinthHtmlBlock = defineComarkPlugin(() => ({ + name: 'modrinth-html-block', + markdownItPlugins: [markdownItModrinthHtmlBlock], +})) diff --git a/packages/utils/markdown/parse.ts b/packages/utils/markdown/parse.ts index 8f634055fa..7958e44ec7 100644 --- a/packages/utils/markdown/parse.ts +++ b/packages/utils/markdown/parse.ts @@ -16,6 +16,7 @@ import taskList from 'comark/plugins/task-list' import { visitAsync } from 'comark/utils' import { modrinthEmbedSyntax } from './embeds' +import { modrinthHtmlBlock } from './html-block' import { modrinthResolveMedia } from './media' import { modrinthSecurity } from './security' @@ -44,6 +45,7 @@ export const modrinthPlugins: ComarkPlugin[] = [ // frontmatter() // heading() html(), + modrinthHtmlBlock(), // json-render() // math(), // needs dep // mermaid(), // needs dep @@ -53,7 +55,7 @@ export const modrinthPlugins: ComarkPlugin[] = [ modrinthResolveMedia(), modrinthSecurity(), // shiki(), // needs dep + we have highlight.js - // summary() // irrelevant + // summary(), //TODO taskList(), // toc() // would be cool to have but would need heavy changes to project pages ] diff --git a/packages/utils/package.json b/packages/utils/package.json index 917a805ad0..3fb1c695f0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,7 +10,8 @@ }, "devDependencies": { "@modrinth/api-client": "workspace:*", - "@modrinth/tooling-config": "workspace:*" + "@modrinth/tooling-config": "workspace:*", + "@types/markdown-it": "14.1.2" }, "dependencies": { "@codemirror/commands": "^6.3.2", @@ -24,6 +25,7 @@ "dayjs": "^1.11.10", "highlight.js": "^11.9.0", "highlightjs-mcfunction": "github:modrinth/better-highlightjs-mcfunction", + "htmlparser2": "^12.0.0", "ofetch": "^1.3.4", "three": "^0.172.0" } diff --git a/patches/comark@0.6.2.patch b/patches/comark@0.6.2.patch new file mode 100644 index 0000000000..f8053d6cb6 --- /dev/null +++ b/patches/comark@0.6.2.patch @@ -0,0 +1,21 @@ +diff --git a/dist/internal/parse/token-processor.js b/dist/internal/parse/token-processor.js +index 440841f8d9252067681f09df935dd0287fff2cf0..71c44f24a932a1f900329800f28029206aa5a13f 100644 +--- a/dist/internal/parse/token-processor.js ++++ b/dist/internal/parse/token-processor.js +@@ -483,6 +483,20 @@ function processBlockChildren(tokens, startIndex, closeType, inlineOnly, inHeadi + } + i++; + } ++ else if (token.type === 'paragraph_open' && token.hidden) { ++ i++; ++ if (tokens[i] && tokens[i].type === 'inline') { ++ nodes.push(...processInlineTokens(tokens[i].children || [], inHeading)); ++ i++; ++ } ++ if (tokens[i] && tokens[i].type === 'paragraph_close') { ++ i++; ++ } ++ } + else { + const result = processBlockToken(tokens, i, insideNestedContext, state); + i = result.nextIndex; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 204f5e6a0d..7ce89af57f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false patchedDependencies: + comark@0.6.2: + hash: 4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c + path: patches/comark@0.6.2.patch readable-stream@2.3.8: hash: 3045f7adf989b4e668a4a13819b7285b0de186b79bbf22408acaf2a41c6eaa86 path: patches/readable-stream@2.3.8.patch @@ -708,7 +711,7 @@ importers: version: 4.7.0 comark: specifier: ^0.6.2 - version: 0.6.2 + version: 0.6.2(patch_hash=4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c) dayjs: specifier: ^1.11.10 version: 1.11.19 @@ -718,12 +721,12 @@ importers: es-toolkit: specifier: ^1.44.0 version: 1.44.0 - flatpickr: - specifier: ^4.6.13 - version: 4.6.13 fabric: specifier: ^7.4.0 version: 7.4.0 + flatpickr: + specifier: ^4.6.13 + version: 4.6.13 floating-vue: specifier: ^5.2.2 version: 5.2.2(@nuxt/kit@3.21.0(magicast@0.5.1))(vue@3.5.27(typescript@5.9.3)) @@ -859,7 +862,7 @@ importers: version: 0.172.0 comark: specifier: ^0.6.2 - version: 0.6.2 + version: 0.6.2(patch_hash=4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c) dayjs: specifier: ^1.11.10 version: 1.11.19 @@ -869,6 +872,9 @@ importers: highlightjs-mcfunction: specifier: github:modrinth/better-highlightjs-mcfunction version: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e + htmlparser2: + specifier: ^12.0.0 + version: 12.0.0 ofetch: specifier: ^1.3.4 version: 1.5.1 @@ -882,6 +888,9 @@ importers: '@modrinth/tooling-config': specifier: workspace:* version: link:../tooling-config + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 packages: @@ -4685,6 +4694,9 @@ 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==} @@ -10419,8 +10431,8 @@ packages: vue-component-type-helpers@3.2.4: resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} - vue-component-type-helpers@3.3.10: - resolution: {integrity: sha512-t7IQivQ3oD4D01b7s7a9AWzHAcr3DGBIa/1jZREsFQJcFgSL92gqUkqNiHTYgzTt7QDuJa0I9wCeDwEtZICoBQ==} + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} vue-confetti-explosion@1.0.2: resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==} @@ -10715,11 +10727,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - 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'} @@ -11303,13 +11310,13 @@ snapshots: '@comark/html@0.6.2': dependencies: - comark: 0.6.2 + comark: 0.6.2(patch_hash=4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c) transitivePeerDependencies: - rangi '@comark/vue@0.6.2(vue@3.5.27(typescript@5.9.3))': dependencies: - comark: 0.6.2 + comark: 0.6.2(patch_hash=4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c) vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - rangi @@ -14179,7 +14186,7 @@ snapshots: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) type-fest: 2.19.0 vue: 3.5.27(typescript@5.9.3) - vue-component-type-helpers: 3.3.10 + vue-component-type-helpers: 3.3.11 '@stripe/stripe-js@7.9.0': {} @@ -14518,6 +14525,11 @@ 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 @@ -15939,7 +15951,7 @@ snapshots: colord@2.9.3: {} - comark@0.6.2: + comark@0.6.2(patch_hash=4e73fba3ed127a788219ec2e7a413966f48fc7695802a1b0f2694ef2686d475c): dependencies: entities: 8.0.0 htmlparser2: 12.0.0 @@ -21616,7 +21628,7 @@ snapshots: vue-component-type-helpers@3.2.4: {} - vue-component-type-helpers@3.3.10: {} + vue-component-type-helpers@3.3.11: {} vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)): dependencies: @@ -21920,11 +21932,6 @@ snapshots: xmlchars@2.2.0: optional: true - xss@1.0.15: - dependencies: - commander: 2.20.3 - cssfilter: 0.0.10 - xtend@4.0.2: {} xxhash-wasm@1.1.0: {}