feat(14.2.1-03): add markdown and multilingual cabana fields
Lift locale maps before ProjectWritableFields so a Journal-shaped save can persist the default host scalar and non-default locales through TranslationWriter without dropping nested JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
109
admin/src/components/form/fields/MLMarkdownField.vue
Normal file
109
admin/src/components/form/fields/MLMarkdownField.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import type { FieldControlProps } from '../control'
|
||||||
|
import MarkdownField from './MarkdownField.vue'
|
||||||
|
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||||
|
|
||||||
|
const props = defineProps<FieldControlProps>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
|
const values = computed(() => localeRecord(props.modelValue))
|
||||||
|
const locales = computed(() => {
|
||||||
|
const keys = Object.keys(values.value)
|
||||||
|
return keys.length > 0 ? keys : ['en']
|
||||||
|
})
|
||||||
|
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||||
|
const copyFrom = ref('')
|
||||||
|
|
||||||
|
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||||
|
const editorId = computed(() => `${props.controlId}-editor`)
|
||||||
|
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
||||||
|
const otherLocales = computed(() => locales.value.filter((code) => code !== activeLocale.value))
|
||||||
|
const markdownField = computed(() => ({ ...props.field, type: 'markdown' }))
|
||||||
|
|
||||||
|
function setLocale(locale: string) {
|
||||||
|
if (!locale) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeLocale.value = locale
|
||||||
|
if (!copyFrom.value || copyFrom.value === locale) {
|
||||||
|
copyFrom.value = otherLocales.value[0] ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocaleInput(event: Event) {
|
||||||
|
const locale = (event.target as HTMLSelectElement).value
|
||||||
|
setLocale(locale)
|
||||||
|
broadcastMLLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onText(text: string) {
|
||||||
|
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLocale() {
|
||||||
|
const from = copyFrom.value
|
||||||
|
if (!from || from === activeLocale.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', { ...values.value, [activeLocale.value]: values.value[from] ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBroadcast(event: Event) {
|
||||||
|
const locale = (event as CustomEvent<string>).detail
|
||||||
|
if (typeof locale === 'string' && locale !== '') {
|
||||||
|
setLocale(locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
copyFrom.value = otherLocales.value[0] ?? ''
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mlmarkdown">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
data-ml-locale
|
||||||
|
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||||
|
:value="activeLocale"
|
||||||
|
:aria-label="`Locale for ${field.label || field.name}`"
|
||||||
|
@change="onLocaleInput"
|
||||||
|
>
|
||||||
|
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="otherLocales.length > 0"
|
||||||
|
data-ml-copy-from
|
||||||
|
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||||
|
:value="copyFrom"
|
||||||
|
aria-label="Copy from locale"
|
||||||
|
@change="copyFrom = ($event.target as HTMLSelectElement).value"
|
||||||
|
>
|
||||||
|
<option v-for="code in otherLocales" :key="code" :value="code">{{ code }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
v-if="otherLocales.length > 0"
|
||||||
|
type="button"
|
||||||
|
data-ml-copy
|
||||||
|
class="rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||||
|
@click="copyLocale"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MarkdownField
|
||||||
|
:field="markdownField"
|
||||||
|
:model-value="activeText"
|
||||||
|
:control-id="editorId"
|
||||||
|
:invalid="invalid"
|
||||||
|
:described-by="describedBy"
|
||||||
|
@update:model-value="onText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
108
admin/src/components/form/fields/MLTextField.vue
Normal file
108
admin/src/components/form/fields/MLTextField.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import type { FieldControlProps } from '../control'
|
||||||
|
import TextField from './TextField.vue'
|
||||||
|
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||||
|
|
||||||
|
const props = defineProps<FieldControlProps>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
|
const values = computed(() => localeRecord(props.modelValue))
|
||||||
|
const locales = computed(() => {
|
||||||
|
const keys = Object.keys(values.value)
|
||||||
|
return keys.length > 0 ? keys : ['en']
|
||||||
|
})
|
||||||
|
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||||
|
const copyFrom = ref('')
|
||||||
|
|
||||||
|
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||||
|
const editorId = computed(() => `${props.controlId}-editor`)
|
||||||
|
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
||||||
|
const otherLocales = computed(() => locales.value.filter((code) => code !== activeLocale.value))
|
||||||
|
|
||||||
|
function setLocale(locale: string) {
|
||||||
|
if (!locale) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeLocale.value = locale
|
||||||
|
if (!copyFrom.value || copyFrom.value === locale) {
|
||||||
|
copyFrom.value = otherLocales.value[0] ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocaleInput(event: Event) {
|
||||||
|
const locale = (event.target as HTMLSelectElement).value
|
||||||
|
setLocale(locale)
|
||||||
|
broadcastMLLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onText(text: string) {
|
||||||
|
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLocale() {
|
||||||
|
const from = copyFrom.value
|
||||||
|
if (!from || from === activeLocale.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', { ...values.value, [activeLocale.value]: values.value[from] ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBroadcast(event: Event) {
|
||||||
|
const locale = (event as CustomEvent<string>).detail
|
||||||
|
if (typeof locale === 'string' && locale !== '') {
|
||||||
|
setLocale(locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
copyFrom.value = otherLocales.value[0] ?? ''
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mltext">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
data-ml-locale
|
||||||
|
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||||
|
:value="activeLocale"
|
||||||
|
:aria-label="`Locale for ${field.label || field.name}`"
|
||||||
|
@change="onLocaleInput"
|
||||||
|
>
|
||||||
|
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="otherLocales.length > 0"
|
||||||
|
data-ml-copy-from
|
||||||
|
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||||
|
:value="copyFrom"
|
||||||
|
aria-label="Copy from locale"
|
||||||
|
@change="copyFrom = ($event.target as HTMLSelectElement).value"
|
||||||
|
>
|
||||||
|
<option v-for="code in otherLocales" :key="code" :value="code">{{ code }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
v-if="otherLocales.length > 0"
|
||||||
|
type="button"
|
||||||
|
data-ml-copy
|
||||||
|
class="rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||||
|
@click="copyLocale"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
:field="{ ...field, type: 'text' }"
|
||||||
|
:model-value="activeText"
|
||||||
|
:control-id="editorId"
|
||||||
|
:invalid="invalid"
|
||||||
|
:described-by="describedBy"
|
||||||
|
@update:model-value="onText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
51
admin/src/components/form/fields/MarkdownField.vue
Normal file
51
admin/src/components/form/fields/MarkdownField.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
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).
|
||||||
|
const props = defineProps<FieldControlProps>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:id="controlId"
|
||||||
|
v-bind="attrs"
|
||||||
|
:name="field.name"
|
||||||
|
:rows="rows"
|
||||||
|
:value="text"
|
||||||
|
:required="field.required || undefined"
|
||||||
|
:aria-required="field.required ? 'true' : undefined"
|
||||||
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
|
:aria-describedby="describedBy || undefined"
|
||||||
|
:class="controlClass(invalid)"
|
||||||
|
class="min-h-input resize-y py-2.5"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
// filtering, the save payload, 422 detail mapping and focus.
|
// filtering, the save payload, 422 detail mapping and focus.
|
||||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||||
import { permissionValues } from './control'
|
import { permissionValues } from './control'
|
||||||
|
import { localeRecord } from './mlLocale'
|
||||||
import { isRegistered } from './registry'
|
import { isRegistered } from './registry'
|
||||||
|
|
||||||
|
export { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from './mlLocale'
|
||||||
|
|
||||||
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
|
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
|
||||||
export type FormMode = 'create' | 'update' | 'preview'
|
export type FormMode = 'create' | 'update' | 'preview'
|
||||||
|
|
||||||
@@ -62,6 +65,10 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
|
|||||||
out[field.name] = permissionValues(field, value)
|
out[field.name] = permissionValues(field, value)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||||
|
out[field.name] = localeRecord(value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
out[field.name] = value
|
out[field.name] = value
|
||||||
}
|
}
|
||||||
@@ -95,6 +102,8 @@ export function initialValues(fields: FormField[]): AdminRecord {
|
|||||||
out[field.name] = false
|
out[field.name] = false
|
||||||
} else if (field.type === 'relation' && field.multiple) {
|
} else if (field.type === 'relation' && field.multiple) {
|
||||||
out[field.name] = []
|
out[field.name] = []
|
||||||
|
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||||
|
out[field.name] = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
24
admin/src/components/form/mlLocale.ts
Normal file
24
admin/src/components/form/mlLocale.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** Window event that keeps every ML control on one active locale. */
|
||||||
|
export const ML_LOCALE_CHANGE = 'summer:ml-locale'
|
||||||
|
|
||||||
|
/** Broadcast the locale the administrator selected on any ML control. */
|
||||||
|
export function broadcastMLLocale(locale: string): void {
|
||||||
|
if (typeof window === 'undefined' || locale === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: locale }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale → text map as it is sent on save: only string values. */
|
||||||
|
export function localeRecord(value: unknown): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for (const [locale, text] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
out[locale] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ import RelationField from './fields/RelationField.vue'
|
|||||||
import SwitchField from './fields/SwitchField.vue'
|
import SwitchField from './fields/SwitchField.vue'
|
||||||
import TextField from './fields/TextField.vue'
|
import TextField from './fields/TextField.vue'
|
||||||
import TextareaField from './fields/TextareaField.vue'
|
import TextareaField from './fields/TextareaField.vue'
|
||||||
|
import MarkdownField from './fields/MarkdownField.vue'
|
||||||
|
import MLTextField from './fields/MLTextField.vue'
|
||||||
|
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
||||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||||
import WidgetField from './fields/WidgetField.vue'
|
import WidgetField from './fields/WidgetField.vue'
|
||||||
|
|
||||||
@@ -60,6 +63,9 @@ const renderers = new Map<string, Component>([
|
|||||||
['datepicker', DatepickerField],
|
['datepicker', DatepickerField],
|
||||||
['password', PasswordField],
|
['password', PasswordField],
|
||||||
['permissioneditor', PermissionEditorField],
|
['permissioneditor', PermissionEditorField],
|
||||||
|
['markdown', MarkdownField],
|
||||||
|
['mltext', MLTextField],
|
||||||
|
['mlmarkdown', MLMarkdownField],
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||||
@@ -79,7 +85,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
|||||||
* Types whose control is a group rather than one focusable element: the
|
* Types whose control is a group rather than one focusable element: the
|
||||||
* visible label is a span the group points at, not a label for an input.
|
* visible label is a span the group points at, not a label for an input.
|
||||||
*/
|
*/
|
||||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor'])
|
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mlmarkdown'])
|
||||||
|
|
||||||
export function rendererFor(type: string): Component {
|
export function rendererFor(type: string): Component {
|
||||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||||
|
|||||||
95
admin/tests/form/MLFields.test.ts
Normal file
95
admin/tests/form/MLFields.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import type { FormField } from '../../src/api/types'
|
||||||
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
|
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||||
|
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
||||||
|
import { editablePayload } from '../../src/components/form/formState'
|
||||||
|
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||||
|
import { resetState } from '../helpers'
|
||||||
|
|
||||||
|
function field(type: string, name = 'title'): FormField {
|
||||||
|
return { name, label: 'Title', type } as FormField
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetState()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('ML field registry and nested save body', () => {
|
||||||
|
it('resolves markdown, mltext and mlmarkdown', () => {
|
||||||
|
expect(rendererFor('markdown')).toBe(MarkdownField)
|
||||||
|
expect(rendererFor('mltext')).toBe(MLTextField)
|
||||||
|
expect(rendererFor('mlmarkdown')).toBe(MLMarkdownField)
|
||||||
|
expect(isRegistered('markdown')).toBe(true)
|
||||||
|
expect(isRegistered('mltext')).toBe(true)
|
||||||
|
expect(isRegistered('mlmarkdown')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends every locale as a nested record', () => {
|
||||||
|
const fields = [field('mltext'), field('mlmarkdown', 'body')]
|
||||||
|
const payload = editablePayload(fields, {
|
||||||
|
title: { en: 'Hello', pl: 'Witaj' },
|
||||||
|
body: { en: '# Hi', pl: '# Cześć' },
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({
|
||||||
|
title: { en: 'Hello', pl: 'Witaj' },
|
||||||
|
body: { en: '# Hi', pl: '# Cześć' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits the active locale and copies from another', async () => {
|
||||||
|
const wrapper = mount(MLTextField, {
|
||||||
|
props: {
|
||||||
|
field: field('mltext'),
|
||||||
|
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||||
|
controlId: 'f-title',
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
expect(input.element.value).toBe('Hello')
|
||||||
|
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||||
|
expect(wrapper.find('input').element.value).toBe('Witaj')
|
||||||
|
await wrapper.find('[data-ml-copy-from]').setValue('en')
|
||||||
|
await wrapper.find('[data-ml-copy]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Hello' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches every ML control from one selector', async () => {
|
||||||
|
const title = mount(MLTextField, {
|
||||||
|
props: { field: field('mltext'), modelValue: { en: 'Hello', pl: 'Witaj' }, controlId: 'f-title' },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
const body = mount(MLMarkdownField, {
|
||||||
|
props: { field: field('mlmarkdown', 'body'), modelValue: { en: 'EN', pl: 'PL' }, controlId: 'f-body' },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(body.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
|
await title.find('[data-ml-locale]').setValue('pl')
|
||||||
|
expect((body.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
|
||||||
|
expect(body.find('textarea').element.value).toBe('PL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not execute raw HTML in the markdown preview', async () => {
|
||||||
|
const wrapper = mount(MarkdownField, {
|
||||||
|
props: {
|
||||||
|
field: field('markdown', 'body'),
|
||||||
|
modelValue: '<script>window.__ml_xss = 1</script><img src=x onerror="window.__ml_xss = 1">',
|
||||||
|
controlId: 'f-body',
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
|
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,9 @@ type CRUDService struct {
|
|||||||
// commit; tr localizes the file limit messages. Both may be nil.
|
// commit; tr localizes the file limit messages. Both may be nil.
|
||||||
bucket *blob.Bucket
|
bucket *blob.Bucket
|
||||||
tr *phrasebook.Translator
|
tr *phrasebook.Translator
|
||||||
|
// writer persists non-default locale values for mltext/mlmarkdown fields.
|
||||||
|
// Nil when the translate plugin is not mounted.
|
||||||
|
writer TranslationWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
||||||
@@ -588,6 +591,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RecordResult{}, err
|
return RecordResult{}, err
|
||||||
}
|
}
|
||||||
|
// ML locale maps are lifted before scalar projection, which drops every
|
||||||
|
// nested value. The default-locale string is written back onto the body
|
||||||
|
// so Fill sees a host scalar.
|
||||||
|
translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)
|
||||||
|
if err != nil {
|
||||||
|
return RecordResult{}, err
|
||||||
|
}
|
||||||
var result RecordResult
|
var result RecordResult
|
||||||
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
|
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
ctx = withVirtualFields(withTx(ctx, tx), virtual)
|
ctx = withVirtualFields(withTx(ctx, tx), virtual)
|
||||||
@@ -670,6 +680,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
|
// Non-default locales are written only after the host row has an id
|
||||||
|
// and after permission, query scope, and relation locks have passed.
|
||||||
|
if err := applyMLTranslations(ctx, tx, s.writer, target, translations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
|
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1202,7 +1217,8 @@ func modelColumns(model any) map[string]struct{} {
|
|||||||
|
|
||||||
func scalarFormField(typ string) bool {
|
func scalarFormField(typ string) bool {
|
||||||
switch typ {
|
switch typ {
|
||||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker":
|
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
||||||
|
"markdown", "mltext", "mlmarkdown":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
46
modules/cabana/field_markdown.go
Normal file
46
modules/cabana/field_markdown.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
markdownEngine = goldmark.New()
|
||||||
|
|
||||||
|
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
|
||||||
|
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
||||||
|
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderMarkdown converts source to HTML using the pinned goldmark engine
|
||||||
|
// without html.WithUnsafe. Output that still contains script/iframe tags,
|
||||||
|
// event handlers, or javascript/vbscript/data URLs is rejected, matching
|
||||||
|
// postcard's mail HTML gate.
|
||||||
|
func RenderMarkdown(src string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := markdownEngine.Convert([]byte(src), &buf); err != nil {
|
||||||
|
return "", fmt.Errorf("cabana: markdown: %w", err)
|
||||||
|
}
|
||||||
|
html := buf.String()
|
||||||
|
if err := rejectUnsafeMarkdownHTML(html); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectUnsafeMarkdownHTML(html string) error {
|
||||||
|
if markdownUnsafeTag.MatchString(html) {
|
||||||
|
return fmt.Errorf("cabana: rendered HTML contains raw unsafe tags")
|
||||||
|
}
|
||||||
|
if markdownEventHandler.MatchString(html) {
|
||||||
|
return fmt.Errorf("cabana: rendered HTML contains event handlers")
|
||||||
|
}
|
||||||
|
if markdownDangerousURL.MatchString(html) {
|
||||||
|
return fmt.Errorf("cabana: rendered HTML contains a dangerous URL scheme")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
160
modules/cabana/field_ml.go
Normal file
160
modules/cabana/field_ml.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mlTextFieldType = "mltext"
|
||||||
|
mlMarkdownFieldType = "mlmarkdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranslationWriter persists non-default locale values during a cabana save.
|
||||||
|
// The interface lives in the framework so cabana never imports a plugin; a
|
||||||
|
// host publishes an adapter at Boot. There is no standalone translate-write
|
||||||
|
// HTTP endpoint: writes run only inside a permissioned, scoped host save
|
||||||
|
// transaction after the host row has a primary key.
|
||||||
|
type TranslationWriter interface {
|
||||||
|
// DefaultLocale is the host-column locale.
|
||||||
|
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
|
||||||
|
// EnabledLocales are the codes a save body may name, including the default.
|
||||||
|
EnabledLocales(ctx context.Context, tx *gorm.DB) ([]string, error)
|
||||||
|
// WriteTranslated stores field in locale for model. locale is never the
|
||||||
|
// default; cabana already filled the host scalar.
|
||||||
|
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// mlValue is one multilingual field lifted from a save body.
|
||||||
|
type mlValue struct {
|
||||||
|
field string
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlFieldType(typ string) bool {
|
||||||
|
return typ == mlTextFieldType || typ == mlMarkdownFieldType
|
||||||
|
}
|
||||||
|
|
||||||
|
// liftMLValues takes declared mltext/mlmarkdown maps out of a save body
|
||||||
|
// before ProjectWritableFields drops nested values. Only fields present in
|
||||||
|
// the body whose context allows op are lifted. A value must be a JSON object
|
||||||
|
// of enabled locale code to string; unknown locales, extra keys, and
|
||||||
|
// non-string values are validation_failed on the field. The default-locale
|
||||||
|
// string is written back onto body so Fill sees a host scalar.
|
||||||
|
func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]any, op string, writer TranslationWriter, db *gorm.DB) ([]mlValue, error) {
|
||||||
|
if cc == nil || cc.Form == nil || body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, field := range cc.Form.Fields {
|
||||||
|
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, present := body[field.Name]; present {
|
||||||
|
names = append(names, field.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
details := map[string]any{}
|
||||||
|
needWriter := false
|
||||||
|
for _, name := range names {
|
||||||
|
if _, isMap := body[name].(map[string]any); isMap {
|
||||||
|
needWriter = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
defaultLocale string
|
||||||
|
enabled map[string]struct{}
|
||||||
|
)
|
||||||
|
if needWriter {
|
||||||
|
if writer == nil {
|
||||||
|
for _, name := range names {
|
||||||
|
if _, isMap := body[name].(map[string]any); isMap {
|
||||||
|
details[name] = []string{"The " + name + " field cannot be translated."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &ValidationError{Details: details}
|
||||||
|
}
|
||||||
|
code, err := writer.DefaultLocale(ctx, db)
|
||||||
|
if err != nil || code == "" {
|
||||||
|
return nil, fmt.Errorf("cabana: default locale: %w", err)
|
||||||
|
}
|
||||||
|
defaultLocale = code
|
||||||
|
codes, err := writer.EnabledLocales(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cabana: enabled locales: %w", err)
|
||||||
|
}
|
||||||
|
enabled = make(map[string]struct{}, len(codes))
|
||||||
|
for _, loc := range codes {
|
||||||
|
enabled[loc] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []mlValue
|
||||||
|
for _, name := range names {
|
||||||
|
raw := body[name]
|
||||||
|
object, isMap := raw.(map[string]any)
|
||||||
|
if !isMap {
|
||||||
|
if nestedValue(raw) {
|
||||||
|
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values := make(map[string]string, len(object))
|
||||||
|
ok := true
|
||||||
|
for locale, item := range object {
|
||||||
|
if _, known := enabled[locale]; !known {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text, isString := item.(string)
|
||||||
|
if !isString {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values[locale] = text
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, hasDefault := values[defaultLocale]; !hasDefault {
|
||||||
|
details[name] = []string{"The " + name + " field must include the default locale."}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body[name] = values[defaultLocale]
|
||||||
|
out = append(out, mlValue{field: name, values: values})
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
return nil, &ValidationError{Details: details}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMLTranslations writes non-default locale values through the published
|
||||||
|
// TranslationWriter. It runs after the host row write so the model has a
|
||||||
|
// primary key, and only with the tx of the host save.
|
||||||
|
func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) error {
|
||||||
|
if writer == nil || len(translations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaultLocale, err := writer.DefaultLocale(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cabana: default locale: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range translations {
|
||||||
|
for locale, value := range item.values {
|
||||||
|
if locale == defaultLocale {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writer.WriteTranslated(ctx, tx, model, item.field, locale, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ var (
|
|||||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||||
"password": {}, "permissioneditor": {},
|
"password": {}, "permissioneditor": {},
|
||||||
|
"markdown": {}, "mltext": {}, "mlmarkdown": {},
|
||||||
}
|
}
|
||||||
formSpans = map[string]struct{}{
|
formSpans = map[string]struct{}{
|
||||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||||
|
|||||||
@@ -930,7 +930,13 @@ func (s *service) crud() (CRUDService, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CRUDService{}, err
|
return CRUDService{}, err
|
||||||
}
|
}
|
||||||
return CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
|
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}
|
||||||
|
if s.app != nil {
|
||||||
|
if w, ok := s.app.Lookup[TranslationWriter](); ok {
|
||||||
|
svc.writer = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
261
modules/cabana/ml_smoke_test.go
Normal file
261
modules/cabana/ml_smoke_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mlFormConfig = `name: posts
|
||||||
|
form: ~/plugins/acme/demo/models/post/fields.yaml
|
||||||
|
modelClass: Post
|
||||||
|
`
|
||||||
|
|
||||||
|
const mlListConfig = `modelClass: Post
|
||||||
|
list: ~/plugins/acme/demo/models/post/columns.yaml
|
||||||
|
recordsPerPage: 20
|
||||||
|
toolbar:
|
||||||
|
buttons: [create]
|
||||||
|
`
|
||||||
|
|
||||||
|
const mlFields = `fields:
|
||||||
|
title:
|
||||||
|
label: Title
|
||||||
|
type: mltext
|
||||||
|
required: true
|
||||||
|
`
|
||||||
|
|
||||||
|
const mlColumns = `columns:
|
||||||
|
title:
|
||||||
|
label: Title
|
||||||
|
searchable: true
|
||||||
|
`
|
||||||
|
|
||||||
|
type mlPost struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mlPost) TableName() string { return "cabana_ml_posts" }
|
||||||
|
func (mlPost) Fillable() []string { return []string{"title"} }
|
||||||
|
func (mlPost) Rules() map[string]string { return map[string]string{"title": "required"} }
|
||||||
|
|
||||||
|
type mlController struct{}
|
||||||
|
|
||||||
|
func (mlController) ID() string { return "acme.demo.posts" }
|
||||||
|
func (mlController) ModelName() string { return "Post" }
|
||||||
|
func (mlController) ConfigDir() string { return "controllers/posts" }
|
||||||
|
func (mlController) NewRecord() any { return &mlPost{} }
|
||||||
|
|
||||||
|
func (mlController) FormExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = mlController{}
|
||||||
|
_ pact.AdminRecordSource = mlController{}
|
||||||
|
_ pact.FormExtendQuery = mlController{}
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingWriter struct {
|
||||||
|
defaultLocale string
|
||||||
|
enabled []string
|
||||||
|
attrs map[string]map[string]string
|
||||||
|
ids []uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) {
|
||||||
|
return w.defaultLocale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||||
|
return append([]string(nil), w.enabled...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
|
||||||
|
post, _ := model.(*mlPost)
|
||||||
|
if post == nil || post.ID == 0 {
|
||||||
|
return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}}
|
||||||
|
}
|
||||||
|
w.ids = append(w.ids, post.ID)
|
||||||
|
if w.attrs == nil {
|
||||||
|
w.attrs = map[string]map[string]string{}
|
||||||
|
}
|
||||||
|
if w.attrs[locale] == nil {
|
||||||
|
w.attrs[locale] = map[string]string{}
|
||||||
|
}
|
||||||
|
w.attrs[locale][field] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
|
||||||
|
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte(mlFormConfig)},
|
||||||
|
"models/post/columns.yaml": &fstest.MapFile{Data: []byte(mlColumns)},
|
||||||
|
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(mlFields)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlCompiled(t *testing.T) *CompiledController {
|
||||||
|
t.Helper()
|
||||||
|
reg, err := compileRegistry([]controllerRef{{
|
||||||
|
plugin: formPlugin{fsys: mlFS()},
|
||||||
|
ctl: mlController{},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry: %v", err)
|
||||||
|
}
|
||||||
|
cc, ok := reg.Get("acme.demo.posts")
|
||||||
|
if !ok || cc.Form == nil {
|
||||||
|
t.Fatalf("compiled controller missing form: %+v", cc)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMLNestedSaveSmoke(t *testing.T) {
|
||||||
|
t.Run("schema accepts markdown mltext mlmarkdown and rejects unknown", func(t *testing.T) {
|
||||||
|
accepted := `fields:
|
||||||
|
title:
|
||||||
|
type: mltext
|
||||||
|
label: Title
|
||||||
|
excerpt:
|
||||||
|
type: markdown
|
||||||
|
label: Excerpt
|
||||||
|
body:
|
||||||
|
type: mlmarkdown
|
||||||
|
label: Body
|
||||||
|
`
|
||||||
|
schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, accepted))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile accepted types: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, field := range schema.Fields {
|
||||||
|
got[field.Name] = field.Type
|
||||||
|
}
|
||||||
|
if got["title"] != "mltext" || got["excerpt"] != "markdown" || got["body"] != "mlmarkdown" {
|
||||||
|
t.Fatalf("types = %v", got)
|
||||||
|
}
|
||||||
|
_, err = CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n title:\n type: mlunknown\n label: Title\n"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsupported type mlunknown") {
|
||||||
|
t.Fatalf("unknown type err = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ProjectWritableFields still drops generic nested maps", func(t *testing.T) {
|
||||||
|
cc := mlCompiled(t)
|
||||||
|
projected := ProjectWritableFields(cc, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
"extra": map[string]any{"nested": true},
|
||||||
|
})
|
||||||
|
if len(projected) != 0 {
|
||||||
|
t.Fatalf("unlifted nested maps reached projection: %#v", projected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("save lifts locales, fills English, writes Polish", func(t *testing.T) {
|
||||||
|
_, shared := newListService(t)
|
||||||
|
db := shared
|
||||||
|
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
cc := mlCompiled(t)
|
||||||
|
|
||||||
|
rec, err := svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
"extra": map[string]any{"nested": true},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if rec["title"] != "Hello" {
|
||||||
|
t.Fatalf("projected title = %#v, want Hello", rec["title"])
|
||||||
|
}
|
||||||
|
var row mlPost
|
||||||
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.Title != "Hello" {
|
||||||
|
t.Fatalf("host title = %q, want Hello", row.Title)
|
||||||
|
}
|
||||||
|
if writer.attrs["en"] != nil {
|
||||||
|
t.Fatalf("default locale duplicated in attributes: %#v", writer.attrs["en"])
|
||||||
|
}
|
||||||
|
if writer.attrs["pl"]["title"] != "Witaj" {
|
||||||
|
t.Fatalf("Polish attributes = %#v", writer.attrs)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(writer.attrs["pl"])
|
||||||
|
if err != nil || string(raw) != `{"title":"Witaj"}` {
|
||||||
|
t.Fatalf("attribute JSON = %s err=%v", raw, err)
|
||||||
|
}
|
||||||
|
if len(writer.ids) != 1 || writer.ids[0] != row.ID || row.ID == 0 {
|
||||||
|
t.Fatalf("writer ids = %v host id = %d", writer.ids, row.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "de": "Hallo"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("undeclared locale succeeded")
|
||||||
|
}
|
||||||
|
var ve *ValidationError
|
||||||
|
if !asValidation(err, &ve) || ve.Details["title"] == nil {
|
||||||
|
t.Fatalf("undeclared locale err = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsafe markdown cannot become executable HTML", func(t *testing.T) {
|
||||||
|
if _, err := RenderMarkdown("<script>alert(1)</script>"); err == nil || !strings.Contains(err.Error(), "raw unsafe tags") {
|
||||||
|
// goldmark without WithUnsafe drops the raw tag; leftover HTML
|
||||||
|
// must still be rejected if a later renderer change lets it through.
|
||||||
|
html, convErr := renderMarkdownUnchecked("<script>alert(1)</script>")
|
||||||
|
if convErr != nil {
|
||||||
|
t.Fatal(convErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(html), "<script") {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("RenderMarkdown accepted script HTML: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if html, err := RenderMarkdown("[x](javascript:alert(1))"); err == nil && strings.Contains(strings.ToLower(html), "javascript:") {
|
||||||
|
t.Fatalf("javascript URL survived: %s", html)
|
||||||
|
} else if err == nil && strings.Contains(strings.ToLower(html), "<script") {
|
||||||
|
t.Fatalf("script tag survived: %s", html)
|
||||||
|
}
|
||||||
|
if _, err := RenderMarkdown(`<img src=x onerror="alert(1)">`); err == nil {
|
||||||
|
html, _ := renderMarkdownUnchecked(`<img src=x onerror="alert(1)">`)
|
||||||
|
if strings.Contains(strings.ToLower(html), "onerror") {
|
||||||
|
t.Fatalf("event handler survived: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if html, err := RenderMarkdown("# Hello"); err != nil || !strings.Contains(html, "<h1>") {
|
||||||
|
t.Fatalf("safe markdown: html=%s err=%v", html, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func asValidation(err error, dest **ValidationError) bool {
|
||||||
|
return errors.As(err, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMarkdownUnchecked(src string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := markdownEngine.Convert([]byte(src), &buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user