@nuxt-customer-portal/ui 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @nuxt-customer-portal/ui
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5beff82: Add a shared visual Markdown editor with a raw Markdown mode, protected special content, and English/Dutch formatting controls. Use it for product summaries/descriptions and email bodies/footers, including a picker for the selected email's available placeholders.
8
+
9
+ ### Patch Changes
10
+
11
+ - @nuxt-customer-portal/core@0.5.0
12
+
3
13
  ## 0.4.9
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -3,3 +3,19 @@
3
3
  Neutral shell primitives and fallback layouts for Nuxt Customer Portal. Hosts
4
4
  can override every layout and branded component while retaining the shared
5
5
  feature registry, session state, and navigation contracts from core.
6
+
7
+ `PortalMarkdownEditor` provides visual formatting and a raw Markdown mode over
8
+ one string `v-model`. It integrates with Nuxt UI `UFormField` validation and
9
+ inherits the form's disabled state. Use it for fields rendered as Markdown;
10
+ plain text fields should retain their text inputs. Product summaries and
11
+ descriptions and email bodies and footers use this component.
12
+
13
+ Pass `placeholders` as an array of `{ label, value }` options to offer insertion
14
+ at the current cursor in either mode. Email forms derive these options from the
15
+ selected email definition. Supported visual formatting includes headings,
16
+ bold, italic, links, lists, quotes, code and dividers. HTML, images, tables,
17
+ checklists, reference links and other unsupported syntax are shown as inert,
18
+ protected source blocks; edit these through Markdown mode. Opening the editor
19
+ or changing modes does not rewrite the stored string. Visual edits serialize
20
+ standard Markdown formatting while retaining protected blocks. Existing
21
+ server renderers still determine what appears in published content.
@@ -0,0 +1,373 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { Editor } from '@tiptap/core'
4
+ import { EditorContent, useEditor } from '@tiptap/vue-3'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { Markdown } from '@tiptap/markdown'
7
+ import { useFormField, inputIdInjectionKey } from '#ui/composables/useFormField'
8
+ import { parseVisualMarkdown } from '../utils/visual-markdown'
9
+ import { createProtectedMarkdown, insertMarkdownPlaceholder, MarkdownPlaceholder } from '../utils/protected-markdown'
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ modelValue: string
14
+ disabled?: boolean
15
+ rows?: number
16
+ placeholder?: string
17
+ placeholders?: Array<{ label: string; value: string }>
18
+ }>(),
19
+ { rows: 8, placeholders: () => [] }
20
+ )
21
+ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
22
+ const { t } = useI18n()
23
+ const source = ref(false)
24
+ const sourceElement = ref<{ textareaRef?: HTMLTextAreaElement }>()
25
+ const { id, disabled, color, ariaAttrs, emitFormInput, emitFormBlur, emitFormFocus } = useFormField(props)
26
+ // Toolbar controls must not inherit or replace the Markdown field's input ID.
27
+ provide(inputIdInjectionKey, ref(undefined))
28
+ const pickerId = useId()
29
+ const notice = ref(false)
30
+ let syncing = false
31
+ function update(value: string) {
32
+ emit('update:modelValue', value)
33
+ emitFormInput()
34
+ }
35
+ const protectedMarkdown = createProtectedMarkdown(
36
+ () => syncing,
37
+ () => {
38
+ notice.value = true
39
+ }
40
+ )
41
+ const editor = useEditor({
42
+ extensions: [
43
+ StarterKit.configure({ underline: false, strike: false, link: { openOnClick: false } }),
44
+ Markdown,
45
+ protectedMarkdown,
46
+ MarkdownPlaceholder
47
+ ],
48
+ content: parseVisualMarkdown(props.modelValue),
49
+ editable: !disabled.value,
50
+ editorProps: { attributes: { role: 'textbox', 'aria-multiline': 'true', class: 'p-3 outline-none min-h-32' } },
51
+ onCreate: ({ editor }) => {
52
+ editor.markdown!.parse = parseVisualMarkdown
53
+ editor.setOptions({
54
+ editorProps: {
55
+ attributes: {
56
+ id: id.value || '',
57
+ ...ariaAttrs.value,
58
+ role: 'textbox',
59
+ 'aria-multiline': 'true',
60
+ class: 'p-3 outline-none min-h-32'
61
+ }
62
+ }
63
+ })
64
+ },
65
+ onUpdate: ({ editor }) => {
66
+ if (!syncing) {
67
+ update(editor.getMarkdown())
68
+ }
69
+ },
70
+ onFocus: emitFormFocus,
71
+ onBlur: emitFormBlur
72
+ })
73
+ function syncContent() {
74
+ if (!editor.value) {
75
+ return
76
+ }
77
+ syncing = true
78
+ try {
79
+ editor.value.commands.setContent(parseVisualMarkdown(props.modelValue), { emitUpdate: false })
80
+ } finally {
81
+ syncing = false
82
+ }
83
+ }
84
+ watch(
85
+ () => props.modelValue,
86
+ (value) => {
87
+ if (!source.value && editor.value?.getMarkdown() !== value) {
88
+ syncContent()
89
+ }
90
+ }
91
+ )
92
+ watch(source, (raw) => {
93
+ notice.value = false
94
+ if (!raw) {
95
+ syncContent()
96
+ }
97
+ })
98
+ watch(disabled, (value) => editor.value?.setEditable(!value))
99
+ watch(
100
+ [id, ariaAttrs],
101
+ () =>
102
+ editor.value?.setOptions({
103
+ editorProps: {
104
+ attributes: {
105
+ id: id.value || '',
106
+ ...ariaAttrs.value,
107
+ role: 'textbox',
108
+ 'aria-multiline': 'true',
109
+ class: 'p-3 outline-none min-h-32'
110
+ }
111
+ }
112
+ }),
113
+ { immediate: true }
114
+ )
115
+ const toolbarItems = computed(() => [
116
+ [
117
+ { kind: 'undo' as const, icon: 'i-lucide-undo-2', tooltip: { text: t('portalMarkdown.undo') } },
118
+ { kind: 'redo' as const, icon: 'i-lucide-redo-2', tooltip: { text: t('portalMarkdown.redo') } }
119
+ ],
120
+ [
121
+ {
122
+ kind: 'mark' as const,
123
+ mark: 'bold' as const,
124
+ icon: 'i-lucide-bold',
125
+ tooltip: { text: t('portalMarkdown.bold') }
126
+ },
127
+ {
128
+ kind: 'mark' as const,
129
+ mark: 'italic' as const,
130
+ icon: 'i-lucide-italic',
131
+ tooltip: { text: t('portalMarkdown.italic') }
132
+ },
133
+ { kind: 'mark' as const, mark: 'code' as const, icon: 'i-lucide-code', tooltip: { text: t('portalMarkdown.code') } }
134
+ ],
135
+ [
136
+ {
137
+ icon: 'i-lucide-heading',
138
+ 'aria-label': t('portalMarkdown.heading'),
139
+ items: [
140
+ { kind: 'paragraph' as const, label: t('portalMarkdown.paragraph') },
141
+ ...([1, 2, 3, 4, 5, 6] as const).map((level) => ({
142
+ kind: 'heading' as const,
143
+ level,
144
+ label: t('portalMarkdown.headingLevel', { level })
145
+ }))
146
+ ]
147
+ }
148
+ ],
149
+ ...(['bulletList', 'orderedList', 'blockquote', 'codeBlock', 'horizontalRule'] as const).map((kind, index) => [
150
+ {
151
+ kind,
152
+ icon: ['i-lucide-list', 'i-lucide-list-ordered', 'i-lucide-quote', 'i-lucide-square-code', 'i-lucide-minus'][
153
+ index
154
+ ],
155
+ tooltip: { text: t(`portalMarkdown.${kind}`) }
156
+ }
157
+ ])
158
+ ])
159
+ const toolbar = computed(() =>
160
+ toolbarItems.value.map((group) =>
161
+ group.map((item) => ({
162
+ ...item,
163
+ 'aria-label': 'tooltip' in item ? item.tooltip.text : t('portalMarkdown.heading')
164
+ }))
165
+ )
166
+ )
167
+ const hasProtected = computed(() => {
168
+ return (
169
+ props.modelValue && parseVisualMarkdown(props.modelValue).content?.some((node) => node.type === 'protectedMarkdown')
170
+ )
171
+ })
172
+ const selectedPlaceholder = ref<string>()
173
+ function insertPlaceholder(value: string) {
174
+ if (disabled.value) {
175
+ return
176
+ }
177
+ if (source.value) {
178
+ const textarea = sourceElement.value?.textareaRef
179
+ const start = textarea?.selectionStart ?? props.modelValue.length
180
+ const end = textarea?.selectionEnd ?? start
181
+ const inserted = insertMarkdownPlaceholder(props.modelValue, value, start, end)
182
+ update(inserted.value)
183
+ nextTick(() => {
184
+ textarea?.focus()
185
+ textarea?.setSelectionRange(inserted.cursor, inserted.cursor)
186
+ })
187
+ } else {
188
+ editor.value
189
+ ?.chain()
190
+ .focus()
191
+ .insertContent(
192
+ editor.value.isActive('codeBlock')
193
+ ? { type: 'text', text: value }
194
+ : { type: 'markdownPlaceholder', attrs: { raw: value } }
195
+ )
196
+ .run()
197
+ }
198
+ nextTick(() => {
199
+ selectedPlaceholder.value = undefined
200
+ })
201
+ }
202
+ const linkOpen = ref(false)
203
+ const linkState = reactive({ url: '' })
204
+ // Link validation is shared with the server's allowed Markdown link schemes.
205
+ const linkSchema = z.object({
206
+ url: z
207
+ .string()
208
+ .trim()
209
+ .regex(/^(https?:\/\/|mailto:|\/|#)/i, t('portalMarkdown.invalidLink'))
210
+ })
211
+ function applyLink() {
212
+ if (disabled.value) {
213
+ return
214
+ }
215
+ editor.value?.chain().focus().extendMarkRange('link').setLink({ href: linkState.url.trim() }).run()
216
+ linkOpen.value = false
217
+ }
218
+ function removeLink() {
219
+ if (disabled.value) {
220
+ return
221
+ }
222
+ editor.value?.chain().focus().unsetLink().run()
223
+ linkOpen.value = false
224
+ }
225
+ function openLink(instance: Editor) {
226
+ linkState.url = instance.getAttributes('link').href || ''
227
+ linkOpen.value = true
228
+ }
229
+ onBeforeUnmount(() => editor.value?.destroy())
230
+ </script>
231
+
232
+ <template>
233
+ <div class="w-full rounded-md border overflow-hidden" :class="color === 'error' ? 'border-error' : 'border-default'">
234
+ <div class="flex flex-wrap items-center gap-2 border-b border-default p-2">
235
+ <UButton
236
+ type="button"
237
+ color="neutral"
238
+ :variant="!source ? 'soft' : 'ghost'"
239
+ :aria-pressed="!source"
240
+ @click="source = false"
241
+ >{{ t('portalMarkdown.visual') }}</UButton
242
+ >
243
+ <UButton
244
+ type="button"
245
+ color="neutral"
246
+ :variant="source ? 'soft' : 'ghost'"
247
+ :aria-pressed="source"
248
+ @click="source = true"
249
+ >{{ t('portalMarkdown.source') }}</UButton
250
+ >
251
+ <USelect
252
+ v-if="placeholders.length"
253
+ :id="pickerId"
254
+ v-model="selectedPlaceholder"
255
+ :items="placeholders"
256
+ :placeholder="t('portalMarkdown.insertPlaceholder')"
257
+ :aria-label="t('portalMarkdown.insertPlaceholder')"
258
+ :disabled="disabled"
259
+ @update:model-value="insertPlaceholder(String($event))"
260
+ />
261
+ </div>
262
+ <UTextarea
263
+ v-if="source"
264
+ :id="id"
265
+ ref="sourceElement"
266
+ v-bind="ariaAttrs"
267
+ :model-value="modelValue"
268
+ :rows="rows"
269
+ :disabled="disabled"
270
+ :placeholder="placeholder"
271
+ class="w-full font-mono"
272
+ :ui="{ base: 'rounded-none ring-0' }"
273
+ @update:model-value="update(String($event))"
274
+ @focus="emitFormFocus"
275
+ @blur="emitFormBlur"
276
+ />
277
+ <ClientOnly v-else>
278
+ <div v-if="editor">
279
+ <div v-if="!disabled" class="flex flex-wrap items-center border-b border-default p-1">
280
+ <UEditorToolbar :editor="editor" :items="toolbar" class="flex-wrap" />
281
+ <UButton
282
+ type="button"
283
+ icon="i-lucide-link"
284
+ color="neutral"
285
+ variant="ghost"
286
+ :aria-label="t('portalMarkdown.link')"
287
+ @click="openLink(editor)"
288
+ />
289
+ </div>
290
+ <p v-if="hasProtected || notice" class="px-3 pt-2 text-xs text-muted" role="status">
291
+ {{ t('portalMarkdown.protected') }}
292
+ </p>
293
+ <EditorContent :editor="editor" class="portal-markdown-content" :style="{ minHeight: `${rows * 1.5}rem` }" />
294
+ </div>
295
+ <template #fallback
296
+ ><div class="p-3 whitespace-pre-wrap">{{ modelValue }}</div></template
297
+ >
298
+ </ClientOnly>
299
+ <UModal v-if="linkOpen" v-model:open="linkOpen" :title="t('portalMarkdown.link')">
300
+ <template #body>
301
+ <UForm :state="linkState" :schema="linkSchema" novalidate class="space-y-4" @submit="applyLink">
302
+ <UFormField name="url" :label="t('portalMarkdown.linkAddress')"
303
+ ><UInput v-model="linkState.url" autofocus class="w-full"
304
+ /></UFormField>
305
+ <div class="flex gap-2">
306
+ <UButton type="submit">{{ t('portalMarkdown.applyLink') }}</UButton>
307
+ <UButton type="button" color="neutral" variant="outline" @click="removeLink">{{
308
+ t('portalMarkdown.removeLink')
309
+ }}</UButton>
310
+ </div>
311
+ </UForm>
312
+ </template>
313
+ </UModal>
314
+ </div>
315
+ </template>
316
+
317
+ <style scoped>
318
+ .portal-markdown-content :deep(p),
319
+ .portal-markdown-content :deep(ul),
320
+ .portal-markdown-content :deep(ol),
321
+ .portal-markdown-content :deep(pre),
322
+ .portal-markdown-content :deep(blockquote) {
323
+ margin-block: 0.6em;
324
+ }
325
+ .portal-markdown-content :deep(ul) {
326
+ list-style: disc;
327
+ padding-left: 1.5rem;
328
+ }
329
+ .portal-markdown-content :deep(ol) {
330
+ list-style: decimal;
331
+ padding-left: 1.5rem;
332
+ }
333
+ .portal-markdown-content :deep(h1),
334
+ .portal-markdown-content :deep(h2),
335
+ .portal-markdown-content :deep(h3),
336
+ .portal-markdown-content :deep(h4),
337
+ .portal-markdown-content :deep(h5),
338
+ .portal-markdown-content :deep(h6) {
339
+ font-weight: 600;
340
+ margin-block: 0.8em 0.4em;
341
+ }
342
+ .portal-markdown-content :deep(h1) {
343
+ font-size: 1.8em;
344
+ }
345
+ .portal-markdown-content :deep(h2) {
346
+ font-size: 1.5em;
347
+ }
348
+ .portal-markdown-content :deep(h3) {
349
+ font-size: 1.25em;
350
+ }
351
+ .portal-markdown-content :deep(a) {
352
+ color: var(--ui-primary);
353
+ text-decoration: underline;
354
+ }
355
+ .portal-markdown-content :deep(blockquote) {
356
+ border-left: 3px solid var(--ui-border);
357
+ padding-left: 1rem;
358
+ }
359
+ .portal-markdown-content :deep(pre) {
360
+ background: var(--ui-bg-muted);
361
+ padding: 0.75rem;
362
+ white-space: pre-wrap;
363
+ overflow-wrap: anywhere;
364
+ }
365
+ .portal-markdown-content :deep(code) {
366
+ font-family: monospace;
367
+ background: var(--ui-bg-muted);
368
+ }
369
+ .portal-markdown-content :deep(hr) {
370
+ margin-block: 1rem;
371
+ border-color: var(--ui-border);
372
+ }
373
+ </style>
@@ -0,0 +1,70 @@
1
+ import { Node, nodeInputRule } from '@tiptap/core'
2
+ import { Plugin } from '@tiptap/pm/state'
3
+
4
+ export function createProtectedMarkdown(isSyncing: () => boolean, onBlocked: () => void) {
5
+ return Node.create({
6
+ name: 'protectedMarkdown',
7
+ group: 'block',
8
+ atom: true,
9
+ addAttributes: () => ({ raw: { default: '' } }),
10
+ parseHTML: () => [{ tag: 'pre[data-protected-markdown]', getAttrs: (element) => ({ raw: element.textContent }) }],
11
+ renderHTML: ({ node }) => ['pre', { 'data-protected-markdown': '' }, node.attrs.raw],
12
+ renderMarkdown: (node) => node.attrs?.raw || '',
13
+ addProseMirrorPlugins: () => [
14
+ new Plugin({
15
+ filterTransaction(transaction, state) {
16
+ if (!transaction.docChanged || isSyncing()) {
17
+ return true
18
+ }
19
+ const blocks = (doc: typeof state.doc) => {
20
+ const counts = new Map<string, number>()
21
+ doc.descendants((node) => {
22
+ if (node.type.name === 'protectedMarkdown') {
23
+ counts.set(node.attrs.raw, (counts.get(node.attrs.raw) || 0) + 1)
24
+ }
25
+ })
26
+ return counts
27
+ }
28
+ const after = blocks(transaction.doc)
29
+ for (const [raw, count] of blocks(state.doc)) {
30
+ if ((after.get(raw) || 0) < count) {
31
+ onBlocked()
32
+ return false
33
+ }
34
+ }
35
+ return true
36
+ }
37
+ })
38
+ ]
39
+ })
40
+ }
41
+
42
+ export function insertMarkdownPlaceholder(source: string, value: string, start = source.length, end = start) {
43
+ return { value: source.slice(0, start) + value + source.slice(end), cursor: start + value.length }
44
+ }
45
+
46
+ // Tokens must serialize literally: ordinary Markdown text escapes underscores,
47
+ // which would stop the email renderer from recognizing placeholder names.
48
+ export const MarkdownPlaceholder = Node.create({
49
+ name: 'markdownPlaceholder',
50
+ group: 'inline',
51
+ inline: true,
52
+ atom: true,
53
+ addAttributes: () => ({ raw: { default: '' } }),
54
+ parseHTML: () => [{ tag: 'span[data-markdown-placeholder]', getAttrs: (element) => ({ raw: element.textContent }) }],
55
+ renderHTML: ({ node }) => [
56
+ 'span',
57
+ { 'data-markdown-placeholder': '', class: 'rounded bg-elevated px-1 font-mono text-sm' },
58
+ node.attrs.raw
59
+ ],
60
+ renderMarkdown: (node) => node.attrs?.raw || '',
61
+ addInputRules() {
62
+ return [
63
+ nodeInputRule({
64
+ find: /({{\s*[a-z0-9_]+\s*}})$/i,
65
+ type: this.type,
66
+ getAttributes: (match) => ({ raw: match[1] })
67
+ })
68
+ ]
69
+ }
70
+ })
@@ -0,0 +1,121 @@
1
+ import { unified } from 'unified'
2
+ import remarkParse from 'remark-parse'
3
+ import remarkGfm from 'remark-gfm'
4
+ import type { JSONContent } from '@tiptap/core'
5
+
6
+ interface MarkdownNode {
7
+ type: string
8
+ value?: string
9
+ children?: MarkdownNode[]
10
+ depth?: number
11
+ url?: string
12
+ title?: string
13
+ lang?: string
14
+ meta?: string
15
+ ordered?: boolean
16
+ start?: number
17
+ checked?: boolean | null
18
+ position?: { start: { offset: number }; end: { offset: number } }
19
+ }
20
+ const parser = unified().use(remarkParse).use(remarkGfm)
21
+ const supported = new Set([
22
+ 'paragraph',
23
+ 'text',
24
+ 'strong',
25
+ 'emphasis',
26
+ 'link',
27
+ 'heading',
28
+ 'blockquote',
29
+ 'list',
30
+ 'listItem',
31
+ 'code',
32
+ 'inlineCode',
33
+ 'break',
34
+ 'thematicBreak'
35
+ ])
36
+ function unsupported(node: MarkdownNode): boolean {
37
+ return (
38
+ !supported.has(node.type) ||
39
+ (node.type === 'listItem' && node.children?.[0]?.type !== 'paragraph') ||
40
+ Boolean(node.title || node.meta || node.checked != null) ||
41
+ (node.children || []).some(unsupported)
42
+ )
43
+ }
44
+ export function placeholderText(text: string, marks: NonNullable<JSONContent['marks']> = []): JSONContent[] {
45
+ return text
46
+ .split(/({{\s*[a-z0-9_]+\s*}})/gi)
47
+ .filter(Boolean)
48
+ .map((part) =>
49
+ /^{{\s*[a-z0-9_]+\s*}}$/i.test(part)
50
+ ? { type: 'markdownPlaceholder', attrs: { raw: part }, marks }
51
+ : { type: 'text', text: part, marks }
52
+ )
53
+ }
54
+ function inline(nodes: MarkdownNode[], marks: NonNullable<JSONContent['marks']> = []): JSONContent[] {
55
+ return nodes.flatMap((node) => {
56
+ const mark = ({ strong: 'bold', emphasis: 'italic', link: 'link' } as Record<string, string>)[node.type]
57
+ if (mark) {
58
+ return inline(node.children || [], [
59
+ ...marks,
60
+ { type: mark, ...(node.type === 'link' ? { attrs: { href: node.url } } : {}) }
61
+ ])
62
+ }
63
+ if (node.type === 'break') {
64
+ return [{ type: 'hardBreak' }]
65
+ }
66
+ return node.value
67
+ ? node.type === 'inlineCode'
68
+ ? [{ type: 'text', text: node.value, marks: [...marks, { type: 'code' }] }]
69
+ : placeholderText(node.value, marks)
70
+ : []
71
+ })
72
+ }
73
+ function block(node: MarkdownNode): JSONContent {
74
+ const content = () => (node.children || []).map(block)
75
+ switch (node.type) {
76
+ case 'paragraph':
77
+ return { type: 'paragraph', content: inline(node.children || []) }
78
+ case 'heading':
79
+ return { type: 'heading', attrs: { level: node.depth }, content: inline(node.children || []) }
80
+ case 'blockquote':
81
+ return { type: 'blockquote', content: content() }
82
+ case 'thematicBreak':
83
+ return { type: 'horizontalRule' }
84
+ case 'list':
85
+ return {
86
+ type: node.ordered ? 'orderedList' : 'bulletList',
87
+ attrs: { start: node.start || 1 },
88
+ content: content()
89
+ }
90
+ case 'listItem':
91
+ return { type: 'listItem', content: content() }
92
+ case 'code':
93
+ return {
94
+ type: 'codeBlock',
95
+ attrs: { language: node.lang },
96
+ content: node.value ? [{ type: 'text', text: node.value }] : []
97
+ }
98
+ default:
99
+ throw new Error(`Unsupported Markdown: ${node.type}`)
100
+ }
101
+ }
102
+ // Adapted from Marpos Studio: preserve unsupported blocks as inert source.
103
+ // HTML, tables, images, reference links and checklists remain editable in source mode.
104
+ export function parseVisualMarkdown(source: string): JSONContent {
105
+ try {
106
+ const tree = parser.parse(source) as unknown as MarkdownNode
107
+ return {
108
+ type: 'doc',
109
+ content: (tree.children || []).map((node) =>
110
+ unsupported(node)
111
+ ? {
112
+ type: 'protectedMarkdown',
113
+ attrs: { raw: source.slice(node.position!.start.offset, node.position!.end.offset) }
114
+ }
115
+ : block(node)
116
+ )
117
+ }
118
+ } catch {
119
+ return { type: 'doc', content: [{ type: 'protectedMarkdown', attrs: { raw: source } }] }
120
+ }
121
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "portalMarkdown": {
3
+ "visual": "Visual editor",
4
+ "source": "Markdown",
5
+ "insertPlaceholder": "Insert placeholder",
6
+ "undo": "Undo",
7
+ "redo": "Redo",
8
+ "bold": "Bold",
9
+ "italic": "Italic",
10
+ "code": "Inline code",
11
+ "heading": "Text style",
12
+ "paragraph": "Paragraph",
13
+ "headingLevel": "Heading {level}",
14
+ "bulletList": "Bullet list",
15
+ "orderedList": "Numbered list",
16
+ "blockquote": "Quote",
17
+ "codeBlock": "Code block",
18
+ "horizontalRule": "Divider",
19
+ "protected": "Special content is preserved. Use Markdown mode to edit or remove those blocks.",
20
+ "link": "Add or edit link",
21
+ "linkAddress": "Link address",
22
+ "invalidLink": "Use a website address, email link or page path.",
23
+ "applyLink": "Apply link",
24
+ "removeLink": "Remove link"
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "portalMarkdown": {
3
+ "visual": "Visuele editor",
4
+ "source": "Markdown",
5
+ "insertPlaceholder": "Placeholder invoegen",
6
+ "undo": "Ongedaan maken",
7
+ "redo": "Opnieuw",
8
+ "bold": "Vet",
9
+ "italic": "Cursief",
10
+ "code": "Inline code",
11
+ "heading": "Tekststijl",
12
+ "paragraph": "Alinea",
13
+ "headingLevel": "Kop {level}",
14
+ "bulletList": "Opsomming",
15
+ "orderedList": "Genummerde lijst",
16
+ "blockquote": "Citaat",
17
+ "codeBlock": "Codeblok",
18
+ "horizontalRule": "Scheidingslijn",
19
+ "protected": "Speciale inhoud blijft behouden. Gebruik de Markdown-modus om deze blokken te wijzigen of verwijderen.",
20
+ "link": "Link toevoegen of bewerken",
21
+ "linkAddress": "Linkadres",
22
+ "invalidLink": "Gebruik een websiteadres, e-maillink of paginapad.",
23
+ "applyLink": "Link toepassen",
24
+ "removeLink": "Link verwijderen"
25
+ }
26
+ }
package/nuxt.config.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export default defineNuxtConfig({
2
2
  $meta: { name: 'nuxt-customer-portal-ui' },
3
3
  compatibilityDate: '2025-10-24',
4
- modules: ['@nuxt/ui', '@nuxtjs/i18n', '@pinia/nuxt']
4
+ modules: ['@nuxt/ui', '@nuxtjs/i18n', '@pinia/nuxt'],
5
+ i18n: {
6
+ locales: [
7
+ { code: 'en', file: 'en.json' },
8
+ { code: 'nl', file: 'nl.json' }
9
+ ]
10
+ }
5
11
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/ui",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "CHANGELOG.md",
15
15
  "app",
16
16
  "nuxt.config.ts",
17
+ "i18n",
17
18
  "portal.manifest.mjs",
18
19
  "README.md"
19
20
  ],
@@ -28,14 +29,24 @@
28
29
  "@nuxt/ui": "^4.10.0",
29
30
  "@nuxtjs/i18n": "^10.6.0",
30
31
  "@pinia/nuxt": "^1.0.1",
32
+ "@tiptap/core": "^3.29.2",
33
+ "@tiptap/markdown": "^3.29.2",
34
+ "@tiptap/pm": "^3.29.2",
35
+ "@tiptap/starter-kit": "^3.29.2",
36
+ "@tiptap/vue-3": "^3.29.2",
31
37
  "pinia": "^4.0.2",
32
- "@nuxt-customer-portal/core": "^0.4.9"
38
+ "remark-gfm": "^4.0.1",
39
+ "remark-parse": "^11.0.0",
40
+ "unified": "^11.0.5",
41
+ "zod": "^4.4.3",
42
+ "@nuxt-customer-portal/core": "^0.5.0"
33
43
  },
34
44
  "peerDependencies": {
35
45
  "nuxt": "^4.5.1",
36
46
  "vue": "^3.5.0"
37
47
  },
38
48
  "devDependencies": {
49
+ "happy-dom": "20.11.2",
39
50
  "nuxt": "^4.5.1",
40
51
  "vue": "^3.5.0"
41
52
  }
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'ui',
3
- version: '0.4.9',
3
+ version: '0.5.0',
4
4
  source: '@nuxt-customer-portal/ui',
5
5
  dependsOn: ['core']
6
6
  }