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">
|
<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'
|
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||||
|
|
||||||
// Source editor for type: markdown. Preview interpolates text (never v-html)
|
// Source editor for type: markdown (and, through MLMarkdownField, for the
|
||||||
// so translated raw HTML/script cannot execute in the admin SPA. Server
|
// active locale of type: mlmarkdown). The preview renders only the HTML that
|
||||||
// preview uses cabana.RenderMarkdown (goldmark without html.WithUnsafe).
|
// 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 props = defineProps<FieldControlProps>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
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 rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
|
||||||
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 8)
|
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 8)
|
||||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||||
const attrs = computed(() => controlAttributes(props.field))
|
const attrs = computed(() => controlAttributes(props.field))
|
||||||
const preview = ref(false)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,16 +114,20 @@ const preview = ref(false)
|
|||||||
data-markdown-preview
|
data-markdown-preview
|
||||||
class="self-start rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
class="self-start rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||||
:aria-pressed="preview ? 'true' : 'false'"
|
:aria-pressed="preview ? 'true' : 'false'"
|
||||||
@click="preview = !preview"
|
@click="togglePreview"
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
<pre
|
<div v-if="preview" class="flex flex-col gap-1.5">
|
||||||
v-if="preview"
|
<p v-if="previewError" data-markdown-preview-error role="status" class="text-[13px] text-danger">{{ previewError }}</p>
|
||||||
data-markdown-preview-pane
|
<div
|
||||||
:class="controlClass(invalid)"
|
data-markdown-preview-pane
|
||||||
class="min-h-input overflow-auto px-3.5 py-2.5 whitespace-pre-wrap"
|
:class="controlClass(invalid)"
|
||||||
>{{ text }}</pre>
|
class="summer-markdown min-h-input overflow-auto px-3.5 py-2.5"
|
||||||
|
:aria-busy="loading ? 'true' : 'false'"
|
||||||
|
v-html="sanitizedHtml"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-else
|
v-else
|
||||||
:id="controlId"
|
:id="controlId"
|
||||||
|
|||||||
@@ -236,6 +236,116 @@
|
|||||||
outline-offset: 2px;
|
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 {
|
.summer-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { FormField } from '../../src/api/types'
|
import type { FormField } from '../../src/api/types'
|
||||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
@@ -8,7 +8,7 @@ import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
|||||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||||
import { editablePayload, initialValues, mergeMLValue } from '../../src/components/form/formState'
|
import { editablePayload, initialValues, mergeMLValue } from '../../src/components/form/formState'
|
||||||
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||||
import { resetState } from '../helpers'
|
import { API, mockApi, resetState } from '../helpers'
|
||||||
|
|
||||||
function field(type: string, name = 'title'): FormField {
|
function field(type: string, name = 'title'): FormField {
|
||||||
return { name, label: 'Title', type } as FormField
|
return { name, label: 'Title', type } as FormField
|
||||||
@@ -126,6 +126,7 @@ describe('ML field registry and nested save body', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not execute raw HTML in the markdown preview', async () => {
|
it('does not execute raw HTML in the markdown preview', async () => {
|
||||||
|
mockApi({ [`POST ${API}/markdown/preview`]: { body: { data: { html: '<p><!-- raw HTML omitted --></p>\n' }, meta: {} } } })
|
||||||
const wrapper = mount(MarkdownField, {
|
const wrapper = mount(MarkdownField, {
|
||||||
props: {
|
props: {
|
||||||
field: field('markdown', 'body'),
|
field: field('markdown', 'body'),
|
||||||
@@ -135,10 +136,10 @@ describe('ML field registry and nested save body', () => {
|
|||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
})
|
})
|
||||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
expect(wrapper.find('script').exists()).toBe(false)
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
expect(wrapper.find('img').exists()).toBe(false)
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('seeds empty maps and lists every enabled locale on create', () => {
|
it('seeds empty maps and lists every enabled locale on create', () => {
|
||||||
|
|||||||
@@ -1,68 +1,205 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { FormField } from '../../src/api/types'
|
import type { FormField } from '../../src/api/types'
|
||||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||||
import { resetState } from '../helpers'
|
import { API, mockApi, requestsTo, resetState, type Reply } from '../helpers'
|
||||||
|
|
||||||
|
const PREVIEW = `${API}/markdown/preview`
|
||||||
|
|
||||||
function field(type = 'markdown', name = 'body'): FormField {
|
function field(type = 'markdown', name = 'body'): FormField {
|
||||||
return { name, label: 'Body', type } as FormField
|
return { name, label: 'Body', type } as FormField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function html(value: string): Reply {
|
||||||
|
return { body: { data: { html: value }, meta: {} } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountField(modelValue: string) {
|
||||||
|
return mount(MarkdownField, {
|
||||||
|
props: { field: field(), modelValue, controlId: 'f-body' },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bodyOf(request: Request): Promise<unknown> {
|
||||||
|
return request.clone().json()
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetState()
|
resetState()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
delete (window as unknown as { __md_xss?: number }).__md_xss
|
||||||
})
|
})
|
||||||
|
|
||||||
enableAutoUnmount(afterEach)
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
describe('MarkdownField composition and sinks', () => {
|
describe('MarkdownField preview', () => {
|
||||||
it('edits source and previews interpolated text without v-html', async () => {
|
it('posts the source on Preview and renders the server HTML', async () => {
|
||||||
const wrapper = mount(MarkdownField, {
|
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<h1>Hello</h1>\n') })
|
||||||
props: {
|
const wrapper = mountField('# Hello')
|
||||||
field: field(),
|
|
||||||
modelValue: '# Hello',
|
|
||||||
controlId: 'f-body',
|
|
||||||
},
|
|
||||||
attachTo: document.body,
|
|
||||||
})
|
|
||||||
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
||||||
|
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||||
|
expect(sent).toHaveLength(1)
|
||||||
|
expect(await bodyOf(sent[0]!)).toEqual({ markdown: '# Hello' })
|
||||||
|
expect(sent[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||||
|
const pane = wrapper.find('[data-markdown-preview-pane]')
|
||||||
|
expect(pane.find('h1').text()).toBe('Hello')
|
||||||
|
expect(pane.attributes('aria-busy')).toBe('false')
|
||||||
|
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('binds the server answer, not the source', async () => {
|
||||||
|
mockApi({ [`POST ${PREVIEW}`]: html('<p><!-- raw HTML omitted --></p>\n') })
|
||||||
|
const wrapper = mountField('<img src=x onerror="window.__md_xss = 1"><script>window.__md_xss = 1</script>')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
|
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane] p').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a refused output as a text notice', async () => {
|
||||||
|
const message = 'The rendered HTML was refused <b>here</b>.'
|
||||||
|
mockApi({
|
||||||
|
[`POST ${PREVIEW}`]: {
|
||||||
|
status: 422,
|
||||||
|
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { markdown: [message] } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wrapper = mountField('<b>see</b> data: here')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const notice = wrapper.find('[data-markdown-preview-error]')
|
||||||
|
expect(notice.exists()).toBe(true)
|
||||||
|
expect(notice.text()).toBe(message)
|
||||||
|
expect(notice.find('b').exists()).toBe(false)
|
||||||
|
const pane = wrapper.find('[data-markdown-preview-pane]')
|
||||||
|
expect(pane.element.children).toHaveLength(0)
|
||||||
|
expect(pane.text()).toBe('')
|
||||||
|
expect(wrapper.find('b').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a generic notice when the request fails', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||||
|
const wrapper = mountField('# Hello')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-markdown-preview-error]').text()).toBe('Preview unavailable.')
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').element.children).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing for a blank source', async () => {
|
||||||
|
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<p>x</p>') })
|
||||||
|
const wrapper = mountField(' \n ')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0)
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('')
|
||||||
|
expect(wrapper.find('[data-markdown-preview-error]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fetch while editing with Preview closed', async () => {
|
||||||
|
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<p>x</p>') })
|
||||||
|
const wrapper = mountField('# Hello')
|
||||||
await wrapper.find('textarea').setValue('# Changed')
|
await wrapper.find('textarea').setValue('# Changed')
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed'])
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed'])
|
||||||
await wrapper.setProps({ modelValue: '# Changed' })
|
await wrapper.setProps({ modelValue: '# Changed' })
|
||||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
await flushPromises()
|
||||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('# Changed')
|
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0)
|
||||||
expect(wrapper.find('h1').exists()).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not execute script, iframe, or event-handler HTML', async () => {
|
it('re-fetches once, debounced, when the source changes while open', async () => {
|
||||||
const wrapper = mount(MarkdownField, {
|
vi.useFakeTimers()
|
||||||
props: {
|
const calls = mockApi({
|
||||||
field: field(),
|
[`POST ${PREVIEW}`]: async (request) => {
|
||||||
modelValue:
|
const { markdown } = (await request.json()) as { markdown: string }
|
||||||
'<script>window.__md_xss = 1</script><iframe src="javascript:alert(1)"></iframe><img src=x onerror="window.__md_xss = 1">',
|
return html(markdown === '# Hello' ? '<h1>Hello</h1>' : '<h2>Changed</h2>')
|
||||||
controlId: 'f-body',
|
|
||||||
},
|
},
|
||||||
attachTo: document.body,
|
|
||||||
})
|
})
|
||||||
|
const wrapper = mountField('# Hello')
|
||||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
expect(wrapper.find('script').exists()).toBe(false)
|
await flushPromises()
|
||||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1)
|
||||||
expect(wrapper.find('img').exists()).toBe(false)
|
|
||||||
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
await wrapper.setProps({ modelValue: '## Chan' })
|
||||||
expect(wrapper.html()).not.toMatch(/v-html|innerHTML/)
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
await wrapper.setProps({ modelValue: '## Changed' })
|
||||||
|
await vi.advanceTimersByTimeAsync(299)
|
||||||
|
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(await bodyOf(sent[1]!)).toEqual({ markdown: '## Changed' })
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane] h2').text()).toBe('Changed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('composes inside mlmarkdown without a raw-HTML sink', async () => {
|
it('ignores an older answer that resolves after a newer one', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const pending: Array<(reply: Reply) => void> = []
|
||||||
|
const calls = mockApi({
|
||||||
|
[`POST ${PREVIEW}`]: () => new Promise<Reply>((resolve) => pending.push(resolve)),
|
||||||
|
})
|
||||||
|
const wrapper = mountField('# Old')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.setProps({ modelValue: '# New' })
|
||||||
|
await vi.advanceTimersByTimeAsync(300)
|
||||||
|
await flushPromises()
|
||||||
|
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(2)
|
||||||
|
expect(pending).toHaveLength(2)
|
||||||
|
|
||||||
|
pending[1]!(html('<h1>New</h1>'))
|
||||||
|
await flushPromises()
|
||||||
|
pending[0]!(html('<h1>Old</h1>'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('New')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to the unchanged source when Preview is clicked again', async () => {
|
||||||
|
mockApi({ [`POST ${PREVIEW}`]: html('<h1>Hello</h1>') })
|
||||||
|
const wrapper = mountField('# Hello')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
||||||
|
expect(wrapper.find('[data-markdown-preview]').attributes('aria-pressed')).toBe('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MarkdownField inside mlmarkdown', () => {
|
||||||
|
it('previews the active locale and follows a locale switch', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const calls = mockApi({
|
||||||
|
[`POST ${PREVIEW}`]: async (request) => {
|
||||||
|
const { markdown } = (await request.json()) as { markdown: string }
|
||||||
|
return html(markdown === '# Hi' ? '<h1>Hi</h1>' : '<h1>Hallo</h1>')
|
||||||
|
},
|
||||||
|
})
|
||||||
const wrapper = mount(MLMarkdownField, {
|
const wrapper = mount(MLMarkdownField, {
|
||||||
props: {
|
props: {
|
||||||
field: field('mlmarkdown', 'body'),
|
field: field('mlmarkdown', 'body'),
|
||||||
modelValue: { en: '# Hi', pl: '<script>window.__md_xss = 1</script>' },
|
modelValue: { en: '# Hi', pl: '# Hallo' },
|
||||||
controlId: 'f-body',
|
controlId: 'f-body',
|
||||||
},
|
},
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
@@ -73,10 +210,17 @@ describe('MarkdownField composition and sinks', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
|
||||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
expect(wrapper.find('script').exists()).toBe(false)
|
await flushPromises()
|
||||||
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hi')
|
||||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
|
||||||
|
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||||
|
await vi.advanceTimersByTimeAsync(300)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(await bodyOf(sent[1]!)).toEqual({ markdown: '# Hallo' })
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hallo')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
|
|||||||
|
|
||||||
## Markdown and multilingual fields
|
## Markdown and multilingual fields
|
||||||
|
|
||||||
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST <prefix>/api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused.
|
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor with a Preview toggle. When Preview opens, and again shortly after the source changes while it is open, the SPA posts the field's source (the active locale's text for `mlmarkdown`) to the preview route below and renders only the server's answer; when the output is refused it shows the server's message as text. The server renders through `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST <prefix>/api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused.
|
||||||
|
|
||||||
`type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped.
|
`type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-BUCXRqcT.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-BUCXRqcT.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-CRrQr2H1.js"></script>
|
<script type="module" crossorigin src="./assets/index-7UEAlsFO.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Bd6s2WVD.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BUCXRqcT.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user