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:
@@ -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"
|
||||
|
||||
@@ -236,6 +236,116 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Markdown preview: the server-rendered HTML of a markdown field. Preflight
|
||||
strips heading, list and margin styles, so the pane restores them. */
|
||||
.summer-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--c-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summer-markdown :where(h1, h2, h3, h4) {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.summer-markdown :where(h1) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(h2) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(h4) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(p, ul, ol, blockquote, pre, table) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(h1, h2, h3, h4, p, ul, ol, blockquote, pre, table):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.summer-markdown :where(ul) {
|
||||
list-style: disc;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
background: var(--c-subtle);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(pre) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
background: var(--c-subtle);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.summer-markdown :where(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summer-markdown :where(blockquote) {
|
||||
border-left: 3px solid var(--c-border-strong);
|
||||
padding-left: 12px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
.summer-markdown :where(a) {
|
||||
color: var(--c-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.summer-markdown :where(a):focus-visible {
|
||||
outline: 3px solid var(--c-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.summer-markdown :where(hr) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--c-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.summer-markdown :where(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.summer-markdown :where(th, td) {
|
||||
border: 1px solid var(--c-border);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summer-markdown :where(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.summer-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user