diff --git a/packages/frontend/nuxt.config.ts b/packages/frontend/nuxt.config.ts index ebec66158..0c4009fe3 100644 --- a/packages/frontend/nuxt.config.ts +++ b/packages/frontend/nuxt.config.ts @@ -44,6 +44,9 @@ export default defineNuxtConfig({ port: 3000, }, vite: { + resolve: { + conditions: ['module', 'worker', 'browser', 'development|production'], + }, optimizeDeps: { include: ['vuedraggable', 'monaco-editor', '@github/webauthn-json/browser-ponyfill'], }, diff --git a/packages/frontend/src/components/Comment/Sidebar.vue b/packages/frontend/src/components/Comment/Sidebar.vue index 41ffb26d2..d04d7488c 100644 --- a/packages/frontend/src/components/Comment/Sidebar.vue +++ b/packages/frontend/src/components/Comment/Sidebar.vue @@ -257,10 +257,3 @@ defineExpose({ min-height: 0; } - - diff --git a/packages/frontend/src/components/Design/LayoutComponentEditDialog.vue b/packages/frontend/src/components/Design/LayoutComponentEditDialog.vue index cd6185715..b87948a5e 100644 --- a/packages/frontend/src/components/Design/LayoutComponentEditDialog.vue +++ b/packages/frontend/src/components/Design/LayoutComponentEditDialog.vue @@ -19,8 +19,8 @@ v-model="form" :lang="item.context.projectType.language" :upload-file="uploadFile" - :rewrite-file-url="rewriteFileUrl" - :disabled="disabled" + :rewrite-file-url-map="props.rewriteFileUrlMap" + :disabled="props.disabled" /> diff --git a/packages/frontend/src/components/Design/LayoutComponentForm.vue b/packages/frontend/src/components/Design/LayoutComponentForm.vue index e1677da68..c481be2d7 100644 --- a/packages/frontend/src/components/Design/LayoutComponentForm.vue +++ b/packages/frontend/src/components/Design/LayoutComponentForm.vue @@ -199,7 +199,7 @@ const emit = defineEmits<{ const localSettings = useLocalSettings(); const markdownProps = computed(() => ({ - ...pick(props, ['disabled', 'lang', 'uploadFile', 'rewriteFileUrl', 'rewriteReferenceLink']), + ...pick(props, ['disabled', 'lang', 'uploadFile', 'rewriteFileUrlMap', 'referenceItems']), spellcheckEnabled: localSettings.designSpellcheckEnabled, 'onUpdate:spellcheckEnabled': (value: boolean) => { localSettings.designSpellcheckEnabled = value }, markdownEditorMode: localSettings.designMarkdownEditorMode, diff --git a/packages/frontend/src/components/Design/LayoutEditor.vue b/packages/frontend/src/components/Design/LayoutEditor.vue index 1b81f1784..6a9c435ba 100644 --- a/packages/frontend/src/components/Design/LayoutEditor.vue +++ b/packages/frontend/src/components/Design/LayoutEditor.vue @@ -85,8 +85,8 @@ const markdownProps = computed(() => ({ disabled: props.disabled, lang: props.lang || props.projectType.language, uploadFile: props.uploadFile, - rewriteFileUrl: props.rewriteFileUrl, - rewriteReferenceLink: props.rewriteReferenceLink, + rewriteFileUrlMap: props.rewriteFileUrlMap, + referenceItems: props.referenceItems, })); const isVisible = ref(true); diff --git a/packages/frontend/src/components/Design/LayoutTree.vue b/packages/frontend/src/components/Design/LayoutTree.vue index 8df6fcc14..f771c9c7c 100644 --- a/packages/frontend/src/components/Design/LayoutTree.vue +++ b/packages/frontend/src/components/Design/LayoutTree.vue @@ -85,7 +85,7 @@ const emit = defineEmits<{ jumpToCode: [{ tab: PdfDesignerTab, position: DocumentSelectionPosition }]; }>(); -const markdownProps = computed(() => pick(props, ['disabled', 'lang', 'uploadFile', 'rewriteFileUrl', 'rewriteReferenceLink'])); +const markdownProps = computed(() => pick(props, ['disabled', 'lang', 'uploadFile', 'rewriteFileUrlMap', 'referenceItems'])); function onChange(e: any) { if (e.moved) { diff --git a/packages/frontend/src/components/Design/PreviewDataForm.vue b/packages/frontend/src/components/Design/PreviewDataForm.vue index 717517bf5..157639787 100644 --- a/packages/frontend/src/components/Design/PreviewDataForm.vue +++ b/packages/frontend/src/components/Design/PreviewDataForm.vue @@ -153,7 +153,7 @@ const props = defineProps<{ projectType: ProjectType; readonly?: boolean; uploadFile?: (file: File) => Promise; - rewriteFileUrl?: (fileSrc: string) => string; + rewriteFileUrlMap?: Record; }>(); const emit = defineEmits<{ 'update:modelValue': [any]; @@ -260,7 +260,7 @@ function riskLevel(finding: any) { const fieldAttrs = computed(() => ({ showFieldIds: true, uploadFile: props.uploadFile, - rewriteFileUrl: props.rewriteFileUrl, + rewriteFileUrlMap: props.rewriteFileUrlMap, selectableUsers: [auth.user.value!], lang: props.projectType.language, readonly: props.readonly, diff --git a/packages/frontend/src/components/DynamicInputField.vue b/packages/frontend/src/components/DynamicInputField.vue index 3347077a9..db3923b0c 100644 --- a/packages/frontend/src/components/DynamicInputField.vue +++ b/packages/frontend/src/components/DynamicInputField.vue @@ -527,7 +527,7 @@ const fieldAttrs = computed(() => ({ label: label.value, ...pick(props, [ 'disabled', 'readonly', 'autofocus', 'lang', 'spellcheckEnabled', 'markdownEditorMode', - 'referenceItems', 'uploadFile', 'rewriteFileUrl', 'rewriteReferenceLink' + 'referenceItems', 'rewriteFileUrlMap', 'uploadFile', ]), onCollab: (v: any) => emit('collab', v), onComment: (v: any) => emit('comment', v), diff --git a/packages/frontend/src/components/DynamicInputFieldDiff.vue b/packages/frontend/src/components/DynamicInputFieldDiff.vue index e6a7b8e4f..0c783cd4f 100644 --- a/packages/frontend/src/components/DynamicInputFieldDiff.vue +++ b/packages/frontend/src/components/DynamicInputFieldDiff.vue @@ -98,8 +98,7 @@ const attrs = useAttrs(); const inheritedDiffAttrs = computed(() => { const copyFields = [ 'disabled', 'readonly', 'lang', 'spellcheckEnabled', 'markdownEditorMode', - 'uploadFile', 'rewriteFileUrl', 'rewriteReferenceLink', - 'selectableUsers', 'referenceItems', + 'uploadFile', 'selectableUsers', 'referenceItems', 'rewriteFileUrlMap', 'fieldValueSuggestions', 'onUpdate:markdownEditorMode', 'onUpdate:spellcheckEnabled', 'collab', 'onCollab', 'onComment', diff --git a/packages/frontend/src/components/Markdown/Preview.vue b/packages/frontend/src/components/Markdown/Preview.vue index 6dea5578e..669487e97 100644 --- a/packages/frontend/src/components/Markdown/Preview.vue +++ b/packages/frontend/src/components/Markdown/Preview.vue @@ -8,9 +8,10 @@ diff --git a/packages/frontend/src/workers/markdownWorker.ts b/packages/frontend/src/workers/markdownWorker.ts new file mode 100644 index 000000000..f41b12677 --- /dev/null +++ b/packages/frontend/src/workers/markdownWorker.ts @@ -0,0 +1,10 @@ +import { renderMarkdownToHtml } from "@sysreptor/markdown"; + +onmessage = (e: MessageEvent<{ messageId: string, options: Parameters[0] }>) => { + try { + const result = renderMarkdownToHtml(e.data.options); + postMessage({ messageId: e.data.messageId, status: 'success', result }) + } catch (error) { + postMessage({ messageId: e.data.messageId, status: 'error', error }); + } +}; diff --git a/packages/frontend/src/workers/regexWorker.ts b/packages/frontend/src/workers/regexWorker.ts index a2f0a5c7b..b35c97ea4 100644 --- a/packages/frontend/src/workers/regexWorker.ts +++ b/packages/frontend/src/workers/regexWorker.ts @@ -1,5 +1,4 @@ -onmessage = (e: MessageEvent) => { - const { pattern, value }: { pattern: RegExp, value: string } = e.data; - const res = pattern.test(value); +onmessage = (e: MessageEvent<{ pattern: RegExp, value: string }>) => { + const res = e.data.pattern.test(e.data.value); postMessage(res); }; diff --git a/packages/frontend/test/e2e/global-setup.ts b/packages/frontend/test/e2e/global-setup.ts index 764825461..dcbe86708 100644 --- a/packages/frontend/test/e2e/global-setup.ts +++ b/packages/frontend/test/e2e/global-setup.ts @@ -13,7 +13,7 @@ async function globalSetup(config: FullConfig) { await integrationLogin(page, baseURL); await integrationSuperuser(page); await selfPromotion(page); - await downloadDemoData(page); + await downloadDemoData(); await importDemoData(page); // Let the demo data import... await page.context().storageState({ path: storageState as string }); diff --git a/packages/frontend/test/markdownExtensions.test.ts b/packages/frontend/test/markdownExtensions.test.ts index dda0a5971..eb1a910f6 100644 --- a/packages/frontend/test/markdownExtensions.test.ts +++ b/packages/frontend/test/markdownExtensions.test.ts @@ -87,7 +87,7 @@ describe('Markdown extensions', () => { '```\n\\# \\{\\{ text \\}\\}\n```': codeBlock('\\# \\{\\{ text \\}\\}'), }).map(([md, expected]) => [md, typeof expected === 'string' ? { html: expected, formatted: md } : expected]) as [string, { html: string, formatted: string }][]) { test(md, () => { - const html = renderMarkdownToHtml(md).replaceAll(/ data-position=".*?"/g, '').trim() + const html = renderMarkdownToHtml({ text: md }).replaceAll(/ data-position=".*?"/g, '').trim() expect(html).toBe(expected.html); const formattedMd = formatMarkdown(md).trim(); expect(formattedMd).toBe(expected.formatted); diff --git a/packages/markdown/mdext/index.js b/packages/markdown/mdext/index.js index b2d7b1b94..3596b70b1 100644 --- a/packages/markdown/mdext/index.js +++ b/packages/markdown/mdext/index.js @@ -5,11 +5,10 @@ import remarkStringify from 'remark-stringify'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { merge } from 'lodash-es'; -import 'highlight.js/styles/default.css'; import { remarkFootnotes, remarkToRehypeHandlersFootnotes, remarkToRehypeHandersFootnotesPreview, rehypeFootnoteSeparator, rehypeFootnoteSeparatorPreview } from './footnotes'; import { remarkStrikethrough, remarkTaskListItem } from './gfm'; -import { rehypeConvertAttrsToStyle, rehypeLinkTargetBlank, rehypeRewriteImageSources, rehypeRewriteFileLinks, rehypeTemplates, rehypeRawFixSelfClosingTags, rehypeRawFixPassthroughStitches } from './rehypePlugins'; +import { rehypeConvertAttrsToStyle, rehypeLinkTargetBlank, rehypeRewriteFileUrls, rehypeTemplates, rehypeRawFixSelfClosingTags, rehypeRawFixPassthroughStitches } from './rehypePlugins'; import { remarkAttrs, remarkToRehypeAttrs } from './attrs'; import { remarkFigure, remarkToRehypeHandlersFigure } from './image'; import { remarkTables, remarkTableCaptions, remarkToRehypeHandlersTableCaptions, rehypeTableCaptions } from './tables'; @@ -83,12 +82,11 @@ export function formatMarkdown(text) { } /** - * - * @param {string} text - * @param {*} options + * Render markdown text to HTML + * @param {{text: string, preview?: boolean, referenceItems?: {id: string, href?: string, label?: string}[], rewriteFileUrlMap?: Record, cacheBuster?: string}} options * @returns {string} */ -export function renderMarkdownToHtml(text, {preview = false, rewriteFileSource = null, rewriteReferenceLink = null} = {}) { +export function renderMarkdownToHtml({ text = '', preview = false, referenceItems = undefined, rewriteFileUrlMap = undefined, cacheBuster = undefined} = {}) { let md = markdownParser() .use(remarkParse) .use(remarkRehype, { @@ -111,10 +109,11 @@ export function renderMarkdownToHtml(text, {preview = false, rewriteFileSource = .use(rehypeTemplateVariables, { preview }) .use(rehypeConvertAttrsToStyle) .use(preview ? rehypeFootnoteSeparatorPreview : rehypeFootnoteSeparator) - .use(preview ? rehypeReferenceLinkPreview : rehypeReferenceLink, { rewriteReferenceLink }) - .use(rehypeRewriteImageSources, {rewriteImageSource: rewriteFileSource}) - .use(rehypeRewriteFileLinks, {rewriteFileUrl: rewriteFileSource}) - .use(rehypeLinkTargetBlank); + .use(preview ? rehypeReferenceLinkPreview : rehypeReferenceLink, { referenceItems }) + if (rewriteFileUrlMap) { + md = md.use(rehypeRewriteFileUrls, { rewriteFileUrlMap, cacheBuster }); + } + md = md.use(rehypeLinkTargetBlank) if (preview) { md = md.use(rehypeSanitize, rehypeSanitizeSchema); } diff --git a/packages/markdown/mdext/reference.js b/packages/markdown/mdext/reference.js index 6cdb339ac..2a548f8a8 100644 --- a/packages/markdown/mdext/reference.js +++ b/packages/markdown/mdext/reference.js @@ -13,7 +13,7 @@ export function rehypeReferenceLink() { } -export function rehypeReferenceLinkPreview({ rewriteReferenceLink = null }) { +export function rehypeReferenceLinkPreview({ referenceItems = undefined }) { return tree => { let refNodes = []; let refTargets = {}; @@ -35,8 +35,8 @@ export function rehypeReferenceLinkPreview({ rewriteReferenceLink = null }) { let refPreview = null; // Known reference target (e.g. other finding) - if (!refPreview && rewriteReferenceLink) { - refPreview = rewriteReferenceLink(refId); + if (!refPreview && referenceItems) { + refPreview = referenceItems.find(item => item.id === refId); } // Local reference target (e.g. figure in same markdown field) @@ -45,12 +45,12 @@ export function rehypeReferenceLinkPreview({ rewriteReferenceLink = null }) { refTargets[refId].parent.tagName === 'figure' && refTargets[refId].parent.children.some(cn => cn.tagName === 'figcaption')) { refPreview = { - title: `[Figure #${refId}]`, + label: `[Figure #${refId}]`, }; } if (refTargets[refId].node.tagName === 'caption') { refPreview = { - title: `[Table #${refId}]`, + label: `[Table #${refId}]`, }; } } @@ -58,7 +58,7 @@ export function rehypeReferenceLinkPreview({ rewriteReferenceLink = null }) { // Unknown reference target if (!refPreview) { refPreview = { - title: `[Reference to #${refId}]`, + label: `[Reference to #${refId}]`, }; } @@ -67,8 +67,8 @@ export function rehypeReferenceLinkPreview({ rewriteReferenceLink = null }) { if (refPreview.href) { node.properties.href = refPreview.href; } - if (refPreview.title && node.children.length === 0) { - node.children.push({type: 'text', value: refPreview.title}); + if (refPreview.label && node.children.length === 0) { + node.children.push({type: 'text', value: refPreview.label}); } } } diff --git a/packages/markdown/mdext/rehypePlugins.js b/packages/markdown/mdext/rehypePlugins.js index a67efdbd6..025fa1202 100644 --- a/packages/markdown/mdext/rehypePlugins.js +++ b/packages/markdown/mdext/rehypePlugins.js @@ -67,19 +67,27 @@ export function removeClass(node, className) { } -export function rehypeRewriteImageSources({ rewriteImageSource }) { +/** + * Rewrite image source to handle image fetching from markdown. + * Images in markdown are referenced with a URL relative to the parent resource (e.g. "/images/name/image.png"). + */ +export function rehypeRewriteFileUrls({ rewriteFileUrlMap, cacheBuster }) { + function rewriteFileUrl(src) { + for (const [oldPrefix, newPrefix] of Object.entries(rewriteFileUrlMap)) { + if (src.startsWith(oldPrefix)) { + return newPrefix + src.slice(oldPrefix.length) + (cacheBuster ? '?c=' + cacheBuster : ''); + } + } + return src; + } + return tree => visit(tree, 'element', node => { - if (node.tagName === 'img' && node.properties.src && rewriteImageSource) { - node.properties.src = rewriteImageSource(node.properties.src); + if (node.tagName === 'img' && node.properties.src) { + node.properties.src = rewriteFileUrl(node.properties.src); node.properties.loading = 'lazy'; } - }); -} - -export function rehypeRewriteFileLinks({ rewriteFileUrl }) { - return tree => visit(tree, 'element', node => { - if (node.tagName === 'a' && node.properties?.href?.startsWith('/files/') && rewriteFileUrl) { + if (node.tagName === 'a' && node.properties.href && node.properties.href.startsWith('/files/')) { node.properties.href = rewriteFileUrl(node.properties.href); node.properties.download = true; addClass(node, ['file-download-preview']); diff --git a/packages/rendering/src/components/Markdown.vue b/packages/rendering/src/components/Markdown.vue index 2fb15811b..f7b947d7e 100644 --- a/packages/rendering/src/components/Markdown.vue +++ b/packages/rendering/src/components/Markdown.vue @@ -1,6 +1,7 @@