@mapomodule/form 2.0.0-beta.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/README.md +155 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +34 -0
- package/dist/module.d.cts +2 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +18 -0
- package/dist/types.d.mts +13 -0
- package/package.json +78 -0
- package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
- package/src/__tests__/registry.test.ts +105 -0
- package/src/__tests__/useFormFromSchema.test.ts +415 -0
- package/src/__tests__/useMapoForm.test.ts +319 -0
- package/src/index.ts +49 -0
- package/src/module.ts +213 -0
- package/src/runtime/components/MapoFocusPortal.vue +154 -0
- package/src/runtime/components/MapoForm.vue +247 -0
- package/src/runtime/components/MapoFormField.vue +350 -0
- package/src/runtime/components/MapoFormFlatSection.vue +163 -0
- package/src/runtime/components/MapoFormGroup.vue +283 -0
- package/src/runtime/components/MapoFormTabs.vue +116 -0
- package/src/runtime/components/MapoUnknownField.vue +74 -0
- package/src/runtime/components/fields/MapoDateField.vue +98 -0
- package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
- package/src/runtime/components/fields/MapoFileField.vue +106 -0
- package/src/runtime/components/fields/MapoFksField.vue +151 -0
- package/src/runtime/components/fields/MapoMapField.vue +117 -0
- package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
- package/src/runtime/components/fields/MapoRepeater.vue +553 -0
- package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
- package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
- package/src/runtime/components/fields/MapoTimeField.vue +59 -0
- package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
- package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
- package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
- package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
- package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
- package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
- package/src/runtime/composables/defineFormField.ts +62 -0
- package/src/runtime/composables/useCurrentLang.ts +16 -0
- package/src/runtime/composables/useFocusMode.ts +86 -0
- package/src/runtime/composables/useFormDraft.ts +119 -0
- package/src/runtime/composables/useFormFromSchema.ts +253 -0
- package/src/runtime/composables/useMapoForm.ts +484 -0
- package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
- package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
- package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
- package/src/runtime/plugins/form-registry.ts +43 -0
- package/src/runtime/registry/defaults.ts +97 -0
- package/src/runtime/types/descriptor.ts +411 -0
- package/src/runtime/types/index.ts +40 -0
- package/src/runtime/types/layout.ts +22 -0
- package/src/runtime/types/registry.ts +98 -0
- package/src/runtime/utils/flattenFieldGroups.ts +86 -0
- package/src/vue-shim.d.ts +12 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldRegistry } from "../types/index.js";
|
|
3
|
+
// @ts-expect-error — #imports is a Nuxt virtual module resolved at app build time
|
|
4
|
+
import { definePageMeta, useNuxtApp, useRuntimeConfig } from "#imports";
|
|
5
|
+
import { computed } from "vue";
|
|
6
|
+
|
|
7
|
+
definePageMeta({ layout: false });
|
|
8
|
+
|
|
9
|
+
const { $mapoFormRegistry } = useNuxtApp() as unknown as {
|
|
10
|
+
$mapoFormRegistry: FieldRegistry;
|
|
11
|
+
};
|
|
12
|
+
const config = useRuntimeConfig().public.mapoForm as {
|
|
13
|
+
groups?: { expanded?: boolean };
|
|
14
|
+
debounce?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Sorted list of all registered field types currently available in the runtime registry. */
|
|
18
|
+
const registeredTypes = computed(() => {
|
|
19
|
+
const mapping = $mapoFormRegistry?.mapping || {};
|
|
20
|
+
return Object.keys(mapping).sort();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Built-in types are those from defaultRegistry; custom types are added at runtime
|
|
24
|
+
// and detected by comparing against the standard type list.
|
|
25
|
+
const builtInTypes = new Set([
|
|
26
|
+
"text",
|
|
27
|
+
"textarea",
|
|
28
|
+
"number",
|
|
29
|
+
"boolean",
|
|
30
|
+
"switch",
|
|
31
|
+
"color",
|
|
32
|
+
"file",
|
|
33
|
+
"slider",
|
|
34
|
+
"select",
|
|
35
|
+
"date",
|
|
36
|
+
"time",
|
|
37
|
+
"datetime",
|
|
38
|
+
"fks",
|
|
39
|
+
"m2m",
|
|
40
|
+
"seo",
|
|
41
|
+
"editor",
|
|
42
|
+
"map",
|
|
43
|
+
"repeater",
|
|
44
|
+
]);
|
|
45
|
+
const userMappings = computed(() =>
|
|
46
|
+
registeredTypes.value.filter((t: string) => !builtInTypes.has(t)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const globalDebounce = computed(() => config?.debounce || 300);
|
|
50
|
+
const globalExpanded = computed(() => config?.groups?.expanded !== false);
|
|
51
|
+
|
|
52
|
+
function componentLabel(type: string): string {
|
|
53
|
+
const v = $mapoFormRegistry?.mapping?.[type];
|
|
54
|
+
if (!v) return "—";
|
|
55
|
+
if (typeof v === "string") return v;
|
|
56
|
+
if (typeof v === "function") return "(lazy)";
|
|
57
|
+
if (typeof v === "object") {
|
|
58
|
+
const name = (v as { __name?: string }).__name;
|
|
59
|
+
if (name) return name;
|
|
60
|
+
}
|
|
61
|
+
return "(component)";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function attrsKeys(type: string): string {
|
|
65
|
+
const a = $mapoFormRegistry?.attrs?.[type];
|
|
66
|
+
return a ? Object.keys(a).join(", ") : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function accessorKeys(type: string): string {
|
|
70
|
+
const ac = $mapoFormRegistry?.accessor?.[type];
|
|
71
|
+
return ac ? Object.keys(ac).join(" / ") : "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isUserType(type: string): boolean {
|
|
75
|
+
return userMappings.value.includes(type);
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="min-h-screen bg-gray-950 text-gray-100 font-mono text-sm p-6">
|
|
81
|
+
<!-- Header -->
|
|
82
|
+
<div class="flex items-center gap-3 mb-8 border-b border-gray-800 pb-4">
|
|
83
|
+
<span class="text-2xl">🗒️</span>
|
|
84
|
+
<div>
|
|
85
|
+
<h1 class="text-lg font-semibold text-white">Mapo Forms</h1>
|
|
86
|
+
<p class="text-gray-400 text-xs">Devtools panel — development only</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="grid grid-cols-3 gap-6">
|
|
91
|
+
<!-- Column 1: Global configuration -->
|
|
92
|
+
<div class="col-span-1">
|
|
93
|
+
<h2
|
|
94
|
+
class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"
|
|
95
|
+
>
|
|
96
|
+
Global config
|
|
97
|
+
</h2>
|
|
98
|
+
<div
|
|
99
|
+
class="bg-gray-900 rounded-lg border border-gray-800 p-4 space-y-3"
|
|
100
|
+
>
|
|
101
|
+
<div>
|
|
102
|
+
<span class="text-gray-500">debounce</span>
|
|
103
|
+
<span class="ml-2 text-amber-400">{{ globalDebounce }}ms</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<span class="text-gray-500">groups.expanded</span>
|
|
107
|
+
<span
|
|
108
|
+
class="ml-2"
|
|
109
|
+
:class="globalExpanded ? 'text-green-400' : 'text-red-400'"
|
|
110
|
+
>
|
|
111
|
+
{{ globalExpanded }}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<span class="text-gray-500">custom types</span>
|
|
116
|
+
<span class="ml-2 text-blue-400">{{ userMappings.length }}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- User-defined custom types -->
|
|
121
|
+
<h2
|
|
122
|
+
class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 mt-6"
|
|
123
|
+
>
|
|
124
|
+
Registered custom fields
|
|
125
|
+
</h2>
|
|
126
|
+
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
|
|
127
|
+
<div
|
|
128
|
+
v-if="userMappings.length === 0"
|
|
129
|
+
class="text-gray-500 text-xs italic"
|
|
130
|
+
>
|
|
131
|
+
No custom types (nuxt.config → mapoForm.fields.mapping)
|
|
132
|
+
</div>
|
|
133
|
+
<div
|
|
134
|
+
v-for="type in userMappings"
|
|
135
|
+
:key="type"
|
|
136
|
+
class="flex items-center gap-2 py-1"
|
|
137
|
+
>
|
|
138
|
+
<span class="w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
|
139
|
+
<span class="text-blue-300">{{ type }}</span>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Columns 2-3: Full registry -->
|
|
145
|
+
<div class="col-span-2">
|
|
146
|
+
<h2
|
|
147
|
+
class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"
|
|
148
|
+
>
|
|
149
|
+
Field registry
|
|
150
|
+
<span class="ml-2 text-gray-600 normal-case font-normal"
|
|
151
|
+
>({{ registeredTypes.length }} types)</span
|
|
152
|
+
>
|
|
153
|
+
</h2>
|
|
154
|
+
<div
|
|
155
|
+
class="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden"
|
|
156
|
+
>
|
|
157
|
+
<table class="w-full text-xs">
|
|
158
|
+
<thead>
|
|
159
|
+
<tr class="border-b border-gray-800 bg-gray-900/80">
|
|
160
|
+
<th class="text-left px-4 py-2 text-gray-500 font-medium w-8">
|
|
161
|
+
#
|
|
162
|
+
</th>
|
|
163
|
+
<th class="text-left px-4 py-2 text-gray-500 font-medium">
|
|
164
|
+
type
|
|
165
|
+
</th>
|
|
166
|
+
<th class="text-left px-4 py-2 text-gray-500 font-medium">
|
|
167
|
+
component
|
|
168
|
+
</th>
|
|
169
|
+
<th class="text-left px-4 py-2 text-gray-500 font-medium">
|
|
170
|
+
default attrs
|
|
171
|
+
</th>
|
|
172
|
+
<th class="text-left px-4 py-2 text-gray-500 font-medium">
|
|
173
|
+
accessor
|
|
174
|
+
</th>
|
|
175
|
+
</tr>
|
|
176
|
+
</thead>
|
|
177
|
+
<tbody>
|
|
178
|
+
<tr
|
|
179
|
+
v-for="(type, i) in registeredTypes"
|
|
180
|
+
:key="type"
|
|
181
|
+
class="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors"
|
|
182
|
+
>
|
|
183
|
+
<td class="px-4 py-2 text-gray-600">
|
|
184
|
+
{{ i + 1 }}
|
|
185
|
+
</td>
|
|
186
|
+
<td class="px-4 py-2">
|
|
187
|
+
<span
|
|
188
|
+
class="px-1.5 py-0.5 rounded text-xs"
|
|
189
|
+
:class="
|
|
190
|
+
isUserType(type)
|
|
191
|
+
? 'bg-blue-900/50 text-blue-300'
|
|
192
|
+
: 'bg-gray-800 text-gray-200'
|
|
193
|
+
"
|
|
194
|
+
>{{ type }}</span
|
|
195
|
+
>
|
|
196
|
+
</td>
|
|
197
|
+
<td class="px-4 py-2 text-green-300">
|
|
198
|
+
{{ componentLabel(type) }}
|
|
199
|
+
</td>
|
|
200
|
+
<td class="px-4 py-2 text-gray-400">
|
|
201
|
+
<span v-if="attrsKeys(type)" class="text-amber-300/80">{{
|
|
202
|
+
attrsKeys(type)
|
|
203
|
+
}}</span>
|
|
204
|
+
<span v-else class="text-gray-700">—</span>
|
|
205
|
+
</td>
|
|
206
|
+
<td class="px-4 py-2">
|
|
207
|
+
<span v-if="accessorKeys(type)" class="text-purple-400">{{
|
|
208
|
+
accessorKeys(type)
|
|
209
|
+
}}</span>
|
|
210
|
+
<span v-else class="text-gray-700">—</span>
|
|
211
|
+
</td>
|
|
212
|
+
</tr>
|
|
213
|
+
</tbody>
|
|
214
|
+
</table>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<!-- Quick reference -->
|
|
218
|
+
<h2
|
|
219
|
+
class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 mt-6"
|
|
220
|
+
>
|
|
221
|
+
Quick reference — add a custom type
|
|
222
|
+
</h2>
|
|
223
|
+
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
|
|
224
|
+
<pre
|
|
225
|
+
class="text-xs text-gray-300 overflow-x-auto leading-relaxed"
|
|
226
|
+
><span class="text-gray-500">// nuxt.config.ts</span>
|
|
227
|
+
<span class="text-blue-300">mapoForm</span>: {
|
|
228
|
+
fields: {
|
|
229
|
+
mapping: {
|
|
230
|
+
<span class="text-amber-300">'my-type'</span>: () => import(<span class="text-green-300">'~/components/MyField.vue'</span>),
|
|
231
|
+
},
|
|
232
|
+
attrs: {
|
|
233
|
+
<span class="text-amber-300">'my-type'</span>: { <span class="text-purple-300">someProp</span>: <span class="text-green-300">true</span> },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
<span class="text-gray-500">// descriptor</span>
|
|
239
|
+
{ key: <span class="text-green-300">'fieldKey'</span>, type: <span class="text-amber-300">'my-type'</span> }</pre>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- If MapoUnknownField is rendered -->
|
|
243
|
+
<div
|
|
244
|
+
class="mt-4 p-3 bg-amber-900/20 border border-amber-700/40 rounded-lg text-xs text-amber-300"
|
|
245
|
+
>
|
|
246
|
+
<strong>⚠ MapoUnknownField</strong> — if a yellow placeholder appears
|
|
247
|
+
in a form, the type is missing from the registry. Check
|
|
248
|
+
<code class="bg-amber-900/30 px-1 rounded">descriptor.type</code> and
|
|
249
|
+
add the mapping shown above. In development, a
|
|
250
|
+
<code class="bg-amber-900/30 px-1 rounded">console.warn</code> is
|
|
251
|
+
emitted with the full path.
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from "#app";
|
|
2
|
+
import { defaultRegistry } from "../registry/defaults.js";
|
|
3
|
+
import type { FieldRegistry, MapoFormRuntimeConfig } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
declare module "#app" {
|
|
6
|
+
interface NuxtApp {
|
|
7
|
+
$mapoFormRegistry: FieldRegistry;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "vue" {
|
|
12
|
+
interface ComponentCustomProperties {
|
|
13
|
+
$mapoFormRegistry: FieldRegistry;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default defineNuxtPlugin(() => {
|
|
18
|
+
const config = useRuntimeConfig();
|
|
19
|
+
const mapoForm = config.public.mapoForm as MapoFormRuntimeConfig | undefined;
|
|
20
|
+
const userAttrs = mapoForm?.fields?.attrs ?? {};
|
|
21
|
+
|
|
22
|
+
// Merge user attrs onto defaults: user takes priority per-type, including 'All'
|
|
23
|
+
const mergedAttrs = { ...defaultRegistry.attrs };
|
|
24
|
+
for (const [type, overrides] of Object.entries(userAttrs)) {
|
|
25
|
+
mergedAttrs[type] = { ...(mergedAttrs[type] ?? {}), ...overrides };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// mapping and accessor contain functions — not serialisable via runtimeConfig.
|
|
29
|
+
// To extend them, create a Nuxt plugin in the consumer app (ordered after this plugin):
|
|
30
|
+
// export default defineNuxtPlugin(({ $mapoFormRegistry }) => {
|
|
31
|
+
// $mapoFormRegistry.mapping['my-type'] = () => import('~/components/MyField.vue')
|
|
32
|
+
// $mapoFormRegistry.accessor['my-type'] = { get: ..., set: ... }
|
|
33
|
+
// })
|
|
34
|
+
const registry: FieldRegistry = {
|
|
35
|
+
mapping: { ...defaultRegistry.mapping },
|
|
36
|
+
attrs: mergedAttrs,
|
|
37
|
+
accessor: { ...defaultRegistry.accessor },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
provide: { mapoFormRegistry: registry },
|
|
42
|
+
};
|
|
43
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FieldRegistry } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
// Nuxt UI components are registered as lazy imports — NUI uses Vite auto-import (compile-time),
|
|
4
|
+
// so resolveComponent() at runtime would find nothing. Thin wrapper .vue files are required.
|
|
5
|
+
// Custom Mapo components are also lazy imports for tree-shaking.
|
|
6
|
+
|
|
7
|
+
export const defaultRegistry: FieldRegistry = {
|
|
8
|
+
mapping: {
|
|
9
|
+
// ─── Nuxt UI — thin wrappers that use <UInput> etc. in the template ──────
|
|
10
|
+
text: () => import("../components/fields/nui/NuiInput.vue"),
|
|
11
|
+
textarea: () => import("../components/fields/nui/NuiTextarea.vue"),
|
|
12
|
+
email: () => import("../components/fields/nui/NuiInput.vue"),
|
|
13
|
+
url: () => import("../components/fields/nui/NuiInput.vue"),
|
|
14
|
+
number: () => import("../components/fields/nui/NuiInput.vue"),
|
|
15
|
+
boolean: () => import("../components/fields/nui/NuiCheckbox.vue"),
|
|
16
|
+
switch: () => import("../components/fields/nui/NuiSwitch.vue"),
|
|
17
|
+
slider: () => import("../components/fields/nui/NuiSlider.vue"),
|
|
18
|
+
color: () => import("../components/fields/nui/NuiInput.vue"),
|
|
19
|
+
file: () => import("../components/fields/MapoFileField.vue"),
|
|
20
|
+
|
|
21
|
+
// ─── Nuxt UI with specific config ────────────────────────────────────────
|
|
22
|
+
select: () => import("../components/fields/nui/NuiSelectMenu.vue"),
|
|
23
|
+
|
|
24
|
+
// ─── Custom Mapo components (non-trivial logic) ───────────────────────────
|
|
25
|
+
date: () => import("../components/fields/MapoDateField.vue"),
|
|
26
|
+
time: () => import("../components/fields/MapoTimeField.vue"),
|
|
27
|
+
datetime: () => import("../components/fields/MapoDateTimeField.vue"),
|
|
28
|
+
fks: () => import("../components/fields/MapoFksField.vue"),
|
|
29
|
+
m2m: () => import("../components/fields/MapoFksField.vue"),
|
|
30
|
+
seo: () => import("../components/fields/MapoSeoPreview.vue"),
|
|
31
|
+
editor: () => import("../components/fields/MapoWygEditor.vue"),
|
|
32
|
+
map: () => import("../components/fields/MapoMapField.vue"),
|
|
33
|
+
repeater: () => import("../components/fields/MapoRepeater.vue"),
|
|
34
|
+
// media, media-m2m, enhanced-media: added in Phase 6
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
attrs: {
|
|
38
|
+
// Applied to all fields
|
|
39
|
+
All: {
|
|
40
|
+
class: "w-full",
|
|
41
|
+
},
|
|
42
|
+
number: {
|
|
43
|
+
type: "number",
|
|
44
|
+
},
|
|
45
|
+
email: {
|
|
46
|
+
type: "email",
|
|
47
|
+
},
|
|
48
|
+
url: {
|
|
49
|
+
type: "url",
|
|
50
|
+
},
|
|
51
|
+
color: {
|
|
52
|
+
type: "color",
|
|
53
|
+
},
|
|
54
|
+
select: {
|
|
55
|
+
// NUI v3 uses `labelKey`/`valueKey`; descriptor uses `{ text, value }` convention.
|
|
56
|
+
labelKey: "text",
|
|
57
|
+
valueKey: "value",
|
|
58
|
+
},
|
|
59
|
+
fks: {
|
|
60
|
+
itemValue: "id",
|
|
61
|
+
returnObject: true,
|
|
62
|
+
},
|
|
63
|
+
m2m: {
|
|
64
|
+
itemValue: "id",
|
|
65
|
+
returnObject: true,
|
|
66
|
+
multiple: true,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
accessor: {
|
|
71
|
+
// date/time/datetime: conversion handled internally by the Mapo*Field components
|
|
72
|
+
// (ISO string ↔ @internationalized/date CalendarDate/CalendarDateTime).
|
|
73
|
+
seo: {
|
|
74
|
+
// v1 used `permalink` key — accept both `permalink` and `url` aliases, normalise to `permalink`
|
|
75
|
+
get: ({ val }) => {
|
|
76
|
+
const v = (val as Record<string, unknown> | null | undefined) ?? {};
|
|
77
|
+
const { url, ...rest } = v as { url?: unknown; [k: string]: unknown };
|
|
78
|
+
return {
|
|
79
|
+
title: "",
|
|
80
|
+
description: "",
|
|
81
|
+
...rest,
|
|
82
|
+
permalink: rest.permalink ?? url ?? "",
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
set: ({ val }) => {
|
|
86
|
+
const v = (val as Record<string, unknown> | null | undefined) ?? {};
|
|
87
|
+
const { url, ...rest } = v as { url?: unknown; [k: string]: unknown };
|
|
88
|
+
return {
|
|
89
|
+
title: "",
|
|
90
|
+
description: "",
|
|
91
|
+
...rest,
|
|
92
|
+
permalink: rest.permalink ?? url ?? "",
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|