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"> <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"

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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')
}) })
}) })

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>