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:
Jakub Zych
2026-10-06 12:32:54 +02:00
parent 234111d245
commit a04116d41f
13 changed files with 895 additions and 3 deletions

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

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

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