fix(admin): render markdown preview from server-sanitized HTML

- MarkdownField posts the source to POST /markdown/preview when Preview
  opens and again 300 ms after a change while open; stale answers dropped
- the pane binds only data.html of a 2xx answer; a refusal is a text notice
- mlmarkdown previews the active locale and follows a locale switch
- .summer-markdown style kit restores headings, lists, code and tables
- vitest coverage, forms.md and rebuilt modules/boardwalk/dist
This commit is contained in:
Jakub Zych
2026-10-06 20:57:51 +02:00
parent b492e79f2b
commit a0116dfbb9
9 changed files with 408 additions and 57 deletions

View File

@@ -1,18 +1,110 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { api } from '../../../api/client'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
// Source editor for type: markdown. Preview interpolates text (never v-html)
// so translated raw HTML/script cannot execute in the admin SPA. Server
// preview uses cabana.RenderMarkdown (goldmark without html.WithUnsafe).
// Source editor for type: markdown (and, through MLMarkdownField, for the
// active locale of type: mlmarkdown). The preview renders only the HTML that
// POST /markdown/preview answers: cabana.RenderMarkdown on the server, which
// is goldmark without unsafe HTML; output its gate refuses comes back as a
// 422 and is shown as a text notice. The pane's raw-HTML binding reads
// sanitizedHtml, which is assigned only from a 2xx answer's data.html; the
// markdown source is never bound as HTML.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const PREVIEW_UNAVAILABLE = 'Preview unavailable.'
const PREVIEW_DEBOUNCE_MS = 300
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 8)
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
const preview = ref(false)
const sanitizedHtml = ref('')
const previewError = ref('')
const loading = ref(false)
let sequence = 0
let previewTimer: ReturnType<typeof setTimeout> | null = null
function cancelTimer() {
if (previewTimer !== null) {
clearTimeout(previewTimer)
previewTimer = null
}
}
function refusal(error: unknown): string {
const details = (error as { error?: { details?: Record<string, unknown> } } | undefined)?.error?.details
const messages = details?.markdown
if (Array.isArray(messages) && typeof messages[0] === 'string' && messages[0] !== '') {
return messages[0]
}
return PREVIEW_UNAVAILABLE
}
async function renderPreview() {
const current = ++sequence
if (text.value.trim() === '') {
sanitizedHtml.value = ''
previewError.value = ''
loading.value = false
return
}
loading.value = true
try {
const { data, error } = await api.POST('/markdown/preview', { body: { markdown: text.value } })
if (current !== sequence) {
return
}
if (data) {
sanitizedHtml.value = data.data.html
previewError.value = ''
} else {
sanitizedHtml.value = ''
previewError.value = refusal(error)
}
} catch {
if (current !== sequence) {
return
}
sanitizedHtml.value = ''
previewError.value = PREVIEW_UNAVAILABLE
} finally {
if (current === sequence) {
loading.value = false
}
}
}
function togglePreview() {
preview.value = !preview.value
cancelTimer()
if (preview.value) {
void renderPreview()
} else {
// Drop any answer still in flight for the closed pane.
sequence++
loading.value = false
}
}
watch(text, () => {
if (!preview.value) {
return
}
cancelTimer()
previewTimer = setTimeout(() => {
previewTimer = null
void renderPreview()
}, PREVIEW_DEBOUNCE_MS)
})
onBeforeUnmount(() => {
cancelTimer()
sequence++
})
</script>
<template>
@@ -22,16 +114,20 @@ const preview = ref(false)
data-markdown-preview
class="self-start rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
:aria-pressed="preview ? 'true' : 'false'"
@click="preview = !preview"
@click="togglePreview"
>
Preview
</button>
<pre
v-if="preview"
data-markdown-preview-pane
:class="controlClass(invalid)"
class="min-h-input overflow-auto px-3.5 py-2.5 whitespace-pre-wrap"
>{{ text }}</pre>
<div v-if="preview" class="flex flex-col gap-1.5">
<p v-if="previewError" data-markdown-preview-error role="status" class="text-[13px] text-danger">{{ previewError }}</p>
<div
data-markdown-preview-pane
:class="controlClass(invalid)"
class="summer-markdown min-h-input overflow-auto px-3.5 py-2.5"
:aria-busy="loading ? 'true' : 'false'"
v-html="sanitizedHtml"
/>
</div>
<textarea
v-else
:id="controlId"