@velastack/cms 0.4.0 → 0.5.1
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 +140 -28
- package/dist/backend/index.d.ts +1 -1
- package/dist/backend/routes/seed.d.ts +6 -0
- package/dist/backend/routes/seed.js +96 -0
- package/dist/backend/routes/table.js +3 -0
- package/dist/backend/store/queries.d.ts +6 -1
- package/dist/backend/store/queries.js +89 -0
- package/dist/cms.js +13 -16
- package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/field-input.svelte +239 -0
- package/dist/components/admin-bar/field-input.svelte.d.ts +30 -0
- package/dist/components/admin-bar/fields-form.svelte +218 -0
- package/dist/components/admin-bar/fields-form.svelte.d.ts +16 -0
- package/dist/components/admin-bar/icons/pencil.svelte +19 -0
- package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
- package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
- package/dist/components/admin-bar/link-input.svelte +102 -0
- package/dist/components/admin-bar/link-input.svelte.d.ts +9 -0
- package/dist/components/admin-bar/list-field.svelte +93 -0
- package/dist/components/admin-bar/list-field.svelte.d.ts +20 -0
- package/dist/components/admin-bar/locales-panel.svelte +42 -1
- package/dist/components/admin-bar/page-config.d.ts +8 -1
- package/dist/components/admin-bar/page-config.js +15 -2
- package/dist/components/admin-bar/page-options.d.ts +24 -0
- package/dist/components/admin-bar/page-options.js +53 -0
- package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
- package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
- package/dist/components/admin-bar/rich-text-input.svelte +92 -0
- package/dist/components/admin-bar/rich-text-input.svelte.d.ts +9 -0
- package/dist/components/admin-bar/seo-panel.svelte +144 -189
- package/dist/components/admin-bar/site-options-panel.svelte +115 -0
- package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte +259 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
- package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/popover/index.js +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
- package/dist/components/admin-bar/ui/sheet/index.js +12 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/switch/index.js +2 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.js +7 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
- package/dist/components/cms/cms-boolean.svelte +9 -18
- package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
- package/dist/components/cms/cms-collection.svelte +46 -0
- package/dist/components/cms/cms-collection.svelte.d.ts +25 -0
- package/dist/components/cms/cms-contact.svelte +26 -0
- package/dist/components/cms/cms-contact.svelte.d.ts +14 -0
- package/dist/components/cms/cms-date-time-editable.svelte +3 -3
- package/dist/components/cms/cms-date-time.svelte +17 -17
- package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
- package/dist/components/cms/cms-faq.svelte +22 -0
- package/dist/components/cms/cms-faq.svelte.d.ts +15 -0
- package/dist/components/cms/cms-gallery.svelte +31 -0
- package/dist/components/cms/cms-gallery.svelte.d.ts +16 -0
- package/dist/components/cms/cms-hours-editable.svelte +160 -0
- package/dist/components/cms/cms-hours-editable.svelte.d.ts +17 -0
- package/dist/components/cms/cms-hours.svelte +42 -0
- package/dist/components/cms/cms-hours.svelte.d.ts +19 -0
- package/dist/components/cms/cms-image-editable.svelte +13 -4
- package/dist/components/cms/cms-image.svelte +19 -34
- package/dist/components/cms/cms-image.svelte.d.ts +7 -8
- package/dist/components/cms/cms-link-editable.svelte +4 -60
- package/dist/components/cms/cms-link.svelte +15 -28
- package/dist/components/cms/cms-link.svelte.d.ts +4 -9
- package/dist/components/cms/cms-logos.svelte +29 -0
- package/dist/components/cms/cms-logos.svelte.d.ts +16 -0
- package/dist/components/cms/cms-markdown.svelte +15 -16
- package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
- package/dist/components/cms/cms-nav.svelte +22 -0
- package/dist/components/cms/cms-nav.svelte.d.ts +15 -0
- package/dist/components/cms/cms-number-editable.svelte +4 -2
- package/dist/components/cms/cms-number.svelte +18 -17
- package/dist/components/cms/cms-number.svelte.d.ts +2 -1
- package/dist/components/cms/cms-pricing.svelte +36 -0
- package/dist/components/cms/cms-pricing.svelte.d.ts +14 -0
- package/dist/components/cms/cms-rich-text.svelte +17 -22
- package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
- package/dist/components/cms/cms-schedule.svelte +35 -0
- package/dist/components/cms/cms-schedule.svelte.d.ts +16 -0
- package/dist/components/cms/cms-social-links.svelte +35 -0
- package/dist/components/cms/cms-social-links.svelte.d.ts +15 -0
- package/dist/components/cms/cms-stats.svelte +31 -0
- package/dist/components/cms/cms-stats.svelte.d.ts +16 -0
- package/dist/components/cms/cms-steps.svelte +31 -0
- package/dist/components/cms/cms-steps.svelte.d.ts +16 -0
- package/dist/components/cms/cms-store.svelte.d.ts +14 -0
- package/dist/components/cms/cms-store.svelte.js +39 -2
- package/dist/components/cms/cms-structured-editable.svelte +29 -0
- package/dist/components/cms/{cms-repeater-editable.svelte.d.ts → cms-structured-editable.svelte.d.ts} +12 -8
- package/dist/components/cms/cms-structured.svelte +50 -0
- package/dist/components/cms/cms-structured.svelte.d.ts +49 -0
- package/dist/components/cms/cms-team.svelte +22 -0
- package/dist/components/cms/cms-team.svelte.d.ts +15 -0
- package/dist/components/cms/cms-testimonials.svelte +36 -0
- package/dist/components/cms/cms-testimonials.svelte.d.ts +15 -0
- package/dist/components/cms/cms-text.svelte +23 -30
- package/dist/components/cms/cms-text.svelte.d.ts +3 -1
- package/dist/components/cms/cms-timeline.svelte +35 -0
- package/dist/components/cms/cms-timeline.svelte.d.ts +16 -0
- package/dist/components/cms/index.d.ts +15 -1
- package/dist/components/cms/index.js +15 -1
- package/dist/components/cms/scope.d.ts +25 -5
- package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
- package/dist/components/cms/use-cms-field.svelte.js +83 -0
- package/dist/core/metadata.d.ts +44 -0
- package/dist/core/metadata.js +59 -0
- package/dist/core/shapes/collection.d.ts +60 -0
- package/dist/core/shapes/collection.js +194 -0
- package/dist/core/shapes/contact.d.ts +70 -0
- package/dist/core/shapes/contact.js +214 -0
- package/dist/core/shapes/faq.d.ts +12 -0
- package/dist/core/shapes/faq.js +48 -0
- package/dist/core/shapes/form.d.ts +46 -0
- package/dist/core/shapes/form.js +7 -0
- package/dist/core/shapes/hours.d.ts +91 -0
- package/dist/core/shapes/hours.js +325 -0
- package/dist/core/shapes/image.d.ts +15 -0
- package/dist/core/shapes/image.js +27 -0
- package/dist/core/shapes/index.d.ts +31 -0
- package/dist/core/shapes/index.js +13 -0
- package/dist/core/shapes/link.d.ts +15 -0
- package/dist/core/shapes/link.js +42 -0
- package/dist/core/shapes/nav.d.ts +21 -0
- package/dist/core/shapes/nav.js +71 -0
- package/dist/core/shapes/presets.d.ts +65 -0
- package/dist/core/shapes/presets.js +152 -0
- package/dist/core/shapes/pricing.d.ts +40 -0
- package/dist/core/shapes/pricing.js +115 -0
- package/dist/core/shapes/social-links.d.ts +19 -0
- package/dist/core/shapes/social-links.js +83 -0
- package/dist/core/shapes/team.d.ts +22 -0
- package/dist/core/shapes/team.js +70 -0
- package/dist/core/shapes/testimonials.d.ts +24 -0
- package/dist/core/shapes/testimonials.js +86 -0
- package/dist/core/structured-registry.d.ts +10 -0
- package/dist/core/structured-registry.js +6 -0
- package/dist/core/structured.d.ts +131 -0
- package/dist/core/structured.js +235 -0
- package/dist/core/wire.d.ts +31 -1
- package/dist/editor.d.ts +26 -0
- package/dist/editor.js +25 -0
- package/dist/index.d.ts +26 -4
- package/dist/index.js +31 -2
- package/dist/server/load-cms.js +1 -0
- package/package.json +12 -3
- package/plugin/README.md +22 -2
- package/plugin/dist/build.d.ts +74 -0
- package/plugin/dist/build.js +210 -0
- package/plugin/dist/build.js.map +1 -0
- package/plugin/dist/index.d.ts +10 -1
- package/plugin/dist/index.js +63 -4
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/manifest.d.ts +31 -0
- package/plugin/dist/manifest.js +146 -41
- package/plugin/dist/manifest.js.map +1 -1
- package/plugin/dist/parse-svelte.d.ts +15 -0
- package/plugin/dist/parse-svelte.js +26 -16
- package/plugin/dist/parse-svelte.js.map +1 -1
- package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
- package/dist/components/admin-bar/site-settings-panel.svelte.d.ts +0 -7
- package/dist/components/cms/cms-repeater-editable.svelte +0 -137
- package/dist/components/cms/cms-repeater.svelte +0 -55
- package/dist/components/cms/cms-repeater.svelte.d.ts +0 -30
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The generic structured form: one input per `FormField`, grouped into
|
|
4
|
+
* tabs when fields name a `group`, `half` fields sharing a row with the
|
|
5
|
+
* next one, and `list` fields rendered as cards through `ListField`.
|
|
6
|
+
* Works over a local draft: every change emits a new whole object.
|
|
7
|
+
*/
|
|
8
|
+
import type { FormField } from '../../core/shapes/form.js';
|
|
9
|
+
import type { CmsImageValue } from '../../core/shapes/image.js';
|
|
10
|
+
import type { CmsLinkValue } from '../../core/shapes/link.js';
|
|
11
|
+
import { get, type Tree } from '../../core/path.js';
|
|
12
|
+
import { setIn } from '../../core/structured.js';
|
|
13
|
+
import FieldInput from './field-input.svelte';
|
|
14
|
+
import LinkInput from './link-input.svelte';
|
|
15
|
+
import RichTextInput from './rich-text-input.svelte';
|
|
16
|
+
import ListField from './list-field.svelte';
|
|
17
|
+
import { Button } from './ui/button/index.js';
|
|
18
|
+
import { Textarea } from './ui/textarea/index.js';
|
|
19
|
+
import * as Tabs from './ui/tabs/index.js';
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
fields: readonly FormField[];
|
|
23
|
+
value: Tree;
|
|
24
|
+
onChange: (next: Tree) => void;
|
|
25
|
+
};
|
|
26
|
+
let { fields, value, onChange }: Props = $props();
|
|
27
|
+
|
|
28
|
+
const GENERAL = 'General';
|
|
29
|
+
|
|
30
|
+
// Tabs: named groups in first-seen order; ungrouped fields share a
|
|
31
|
+
// "General" tab that comes first. One group means no tabs at all.
|
|
32
|
+
const groups = $derived.by(() => {
|
|
33
|
+
const names: string[] = [];
|
|
34
|
+
for (const f of fields) {
|
|
35
|
+
const g = f.group ?? GENERAL;
|
|
36
|
+
if (!names.includes(g)) names.push(g);
|
|
37
|
+
}
|
|
38
|
+
if (names.includes(GENERAL)) {
|
|
39
|
+
names.splice(names.indexOf(GENERAL), 1);
|
|
40
|
+
names.unshift(GENERAL);
|
|
41
|
+
}
|
|
42
|
+
return names.map((name) => ({
|
|
43
|
+
name,
|
|
44
|
+
rows: pairRows(fields.filter((f) => (f.group ?? GENERAL) === name))
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
let tab = $state('');
|
|
48
|
+
$effect(() => {
|
|
49
|
+
if (!groups.some((g) => g.name === tab)) tab = groups[0]?.name ?? '';
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A `half` field shares its row with the field after it. */
|
|
53
|
+
const pairRows = (list: readonly FormField[]): FormField[][] => {
|
|
54
|
+
const rows: FormField[][] = [];
|
|
55
|
+
for (let i = 0; i < list.length; i++) {
|
|
56
|
+
const f = list[i];
|
|
57
|
+
if (f.half && i + 1 < list.length && list[i + 1].type !== 'list') {
|
|
58
|
+
rows.push([f, list[i + 1]]);
|
|
59
|
+
i++;
|
|
60
|
+
} else rows.push([f]);
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const read = (key: string): unknown => get(value, key);
|
|
66
|
+
const write = (key: string, next: unknown) => onChange(setIn(value, key, next));
|
|
67
|
+
|
|
68
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
69
|
+
const strings = (v: unknown): string[] =>
|
|
70
|
+
Array.isArray(v) ? v.map((s) => (typeof s === 'string' ? s : '')) : [];
|
|
71
|
+
const images = (v: unknown): CmsImageValue[] =>
|
|
72
|
+
Array.isArray(v) ? (v.filter((i) => i && typeof i === 'object') as CmsImageValue[]) : [];
|
|
73
|
+
const rating = (v: unknown): number => (typeof v === 'number' ? v : 0);
|
|
74
|
+
const asObject = (v: unknown): Tree => (v && typeof v === 'object' ? (v as Tree) : { url: null });
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
{#snippet input(f: FormField)}
|
|
78
|
+
{#if f.type === 'html'}
|
|
79
|
+
<RichTextInput
|
|
80
|
+
value={str(read(f.key))}
|
|
81
|
+
label={f.label}
|
|
82
|
+
onChange={(html) => write(f.key, html)}
|
|
83
|
+
/>
|
|
84
|
+
{:else if f.type === 'link'}
|
|
85
|
+
<LinkInput
|
|
86
|
+
value={(read(f.key) as CmsLinkValue | null) ?? null}
|
|
87
|
+
label={f.label}
|
|
88
|
+
onChange={(next) => write(f.key, next)}
|
|
89
|
+
/>
|
|
90
|
+
{:else if f.type === 'strings'}
|
|
91
|
+
<label class="vela:flex vela:flex-col vela:gap-1.5">
|
|
92
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">{f.label}</span>
|
|
93
|
+
<Textarea
|
|
94
|
+
value={strings(read(f.key)).join('\n')}
|
|
95
|
+
placeholder={f.placeholder ?? 'One per line'}
|
|
96
|
+
oninput={(e) => write(f.key, e.currentTarget.value.split('\n'))}
|
|
97
|
+
/>
|
|
98
|
+
</label>
|
|
99
|
+
{:else if f.type === 'rating'}
|
|
100
|
+
<div class="vela:flex vela:flex-col vela:gap-1.5">
|
|
101
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">{f.label}</span>
|
|
102
|
+
<div class="vela:flex vela:gap-0.5" role="radiogroup" aria-label={f.label}>
|
|
103
|
+
{#each [1, 2, 3, 4, 5] as n (n)}
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
role="radio"
|
|
107
|
+
aria-checked={rating(read(f.key)) === n}
|
|
108
|
+
aria-label={`${n} of 5`}
|
|
109
|
+
class="vela:cursor-pointer vela:text-[18px] vela:leading-none {n <= rating(read(f.key))
|
|
110
|
+
? 'vela:text-[var(--cms-status-warn-text)]'
|
|
111
|
+
: 'vela:text-bar-text-tertiary'}"
|
|
112
|
+
onclick={() => write(f.key, rating(read(f.key)) === n ? null : n)}
|
|
113
|
+
>
|
|
114
|
+
★
|
|
115
|
+
</button>
|
|
116
|
+
{/each}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
{:else if f.type === 'images'}
|
|
120
|
+
{@const list = images(read(f.key))}
|
|
121
|
+
<div class="vela:flex vela:flex-col vela:gap-2">
|
|
122
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">{f.label}</span>
|
|
123
|
+
{#each list as img, i (i)}
|
|
124
|
+
<div class="vela:flex vela:items-start vela:gap-2">
|
|
125
|
+
<FieldInput
|
|
126
|
+
type="image"
|
|
127
|
+
value={img}
|
|
128
|
+
onChange={(next) =>
|
|
129
|
+
write(
|
|
130
|
+
f.key,
|
|
131
|
+
list.map((x, j) => (j === i ? next : x))
|
|
132
|
+
)}
|
|
133
|
+
class="vela:flex-1"
|
|
134
|
+
/>
|
|
135
|
+
<Button
|
|
136
|
+
size="icon"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
class="vela:h-9 vela:w-6 vela:shrink-0 vela:text-[var(--cms-status-error-text-base)]"
|
|
139
|
+
aria-label="Remove image"
|
|
140
|
+
onclick={() =>
|
|
141
|
+
write(
|
|
142
|
+
f.key,
|
|
143
|
+
list.filter((_, j) => j !== i)
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
×
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
{/each}
|
|
150
|
+
<Button
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="pill"
|
|
153
|
+
class="vela:self-start"
|
|
154
|
+
onclick={() => write(f.key, [...list, { url: null }])}
|
|
155
|
+
>
|
|
156
|
+
+ Add image
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
{:else if f.type === 'image'}
|
|
160
|
+
<FieldInput
|
|
161
|
+
type="image"
|
|
162
|
+
label={f.label}
|
|
163
|
+
value={asObject(read(f.key))}
|
|
164
|
+
placeholder={f.placeholder}
|
|
165
|
+
onChange={(next) => write(f.key, next)}
|
|
166
|
+
/>
|
|
167
|
+
{:else if f.type === 'list'}
|
|
168
|
+
<ListField
|
|
169
|
+
label={f.label}
|
|
170
|
+
itemLabel={f.itemLabel ?? 'item'}
|
|
171
|
+
titleKey={f.titleKey}
|
|
172
|
+
fields={f.fields ?? []}
|
|
173
|
+
blank={f.blank ?? (() => ({}))}
|
|
174
|
+
items={(read(f.key) as Tree[] | undefined) ?? []}
|
|
175
|
+
onChange={(items) => write(f.key, items)}
|
|
176
|
+
/>
|
|
177
|
+
{:else}
|
|
178
|
+
<FieldInput
|
|
179
|
+
type={f.type}
|
|
180
|
+
label={f.label}
|
|
181
|
+
value={read(f.key)}
|
|
182
|
+
placeholder={f.placeholder}
|
|
183
|
+
values={f.values}
|
|
184
|
+
names={f.names}
|
|
185
|
+
onChange={(next) => write(f.key, next)}
|
|
186
|
+
/>
|
|
187
|
+
{/if}
|
|
188
|
+
{/snippet}
|
|
189
|
+
|
|
190
|
+
{#snippet rows(list: FormField[][])}
|
|
191
|
+
{#each list as row, i (i)}
|
|
192
|
+
{#if row.length === 2}
|
|
193
|
+
<div class="vela:grid vela:grid-cols-2 vela:gap-2 vela:items-start">
|
|
194
|
+
{@render input(row[0])}
|
|
195
|
+
{@render input(row[1])}
|
|
196
|
+
</div>
|
|
197
|
+
{:else}
|
|
198
|
+
{@render input(row[0])}
|
|
199
|
+
{/if}
|
|
200
|
+
{/each}
|
|
201
|
+
{/snippet}
|
|
202
|
+
|
|
203
|
+
{#if groups.length > 1}
|
|
204
|
+
<Tabs.Root bind:value={tab}>
|
|
205
|
+
<Tabs.List aria-label="Sections">
|
|
206
|
+
{#each groups as g (g.name)}
|
|
207
|
+
<Tabs.Trigger value={g.name}>{g.name}</Tabs.Trigger>
|
|
208
|
+
{/each}
|
|
209
|
+
</Tabs.List>
|
|
210
|
+
{#each groups as g (g.name)}
|
|
211
|
+
<Tabs.Content value={g.name} class="vela:flex vela:flex-col vela:gap-3 vela:pt-3">
|
|
212
|
+
{@render rows(g.rows)}
|
|
213
|
+
</Tabs.Content>
|
|
214
|
+
{/each}
|
|
215
|
+
</Tabs.Root>
|
|
216
|
+
{:else if groups.length === 1}
|
|
217
|
+
{@render rows(groups[0].rows)}
|
|
218
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic structured form: one input per `FormField`, grouped into
|
|
3
|
+
* tabs when fields name a `group`, `half` fields sharing a row with the
|
|
4
|
+
* next one, and `list` fields rendered as cards through `ListField`.
|
|
5
|
+
* Works over a local draft: every change emits a new whole object.
|
|
6
|
+
*/
|
|
7
|
+
import type { FormField } from '../../core/shapes/form.js';
|
|
8
|
+
import { type Tree } from '../../core/path.js';
|
|
9
|
+
type Props = {
|
|
10
|
+
fields: readonly FormField[];
|
|
11
|
+
value: Tree;
|
|
12
|
+
onChange: (next: Tree) => void;
|
|
13
|
+
};
|
|
14
|
+
declare const FieldsForm: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type FieldsForm = ReturnType<typeof FieldsForm>;
|
|
16
|
+
export default FieldsForm;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Lucide `pencil` icon path data (https://lucide.dev) — ISC License,
|
|
3
|
+
// Copyright (c) Lucide Icons and Contributors. See ./types.ts.
|
|
4
|
+
import Icon from './icon.svelte';
|
|
5
|
+
import type { IconNode } from './types.js';
|
|
6
|
+
|
|
7
|
+
const iconNode: IconNode = [
|
|
8
|
+
[
|
|
9
|
+
'path',
|
|
10
|
+
{
|
|
11
|
+
d: 'M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
['path', { d: 'm15 5 4 4' }]
|
|
15
|
+
];
|
|
16
|
+
let props = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<Icon {iconNode} {...props} />
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A link field inside a structured editor: a page from the site or an
|
|
4
|
+
* external URL, plus "open in a new tab". `value` is a `CmsLinkValue`
|
|
5
|
+
* or `null`; `onChange(null)` clears it.
|
|
6
|
+
*/
|
|
7
|
+
import { untrack } from 'svelte';
|
|
8
|
+
import { page } from '$app/state';
|
|
9
|
+
import type { CmsLinkValue } from '../../core/shapes/link.js';
|
|
10
|
+
import { fetchPageOptions, pageOptionValue, type PageOption } from './page-options.js';
|
|
11
|
+
import { Input } from './ui/input/index.js';
|
|
12
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
value: CmsLinkValue | null;
|
|
16
|
+
onChange: (next: CmsLinkValue | null) => void;
|
|
17
|
+
label?: string;
|
|
18
|
+
};
|
|
19
|
+
let { value, onChange, label }: Props = $props();
|
|
20
|
+
|
|
21
|
+
const endpoint = $derived(page.data.cms?.endpoint ?? '/api/cms');
|
|
22
|
+
const selectClass =
|
|
23
|
+
'vela:flex vela:h-9 vela:w-full vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)]' +
|
|
24
|
+
' vela:bg-[var(--cms-bar-bg-hover)] vela:px-3 vela:py-1 vela:text-[13px] vela:text-bar-text' +
|
|
25
|
+
' vela:focus:outline-none vela:focus:ring-2 vela:focus:ring-[var(--cms-accent)]';
|
|
26
|
+
|
|
27
|
+
let mode = $state<'page' | 'url'>(
|
|
28
|
+
untrack(() => (value?.href && !value.routeId ? 'url' : 'page'))
|
|
29
|
+
);
|
|
30
|
+
let options = $state<PageOption[] | null>(null);
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (mode === 'page' && options === null) {
|
|
33
|
+
void fetchPageOptions(endpoint).then((o) => (options = o));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const selected = $derived(
|
|
38
|
+
value?.routeId ? pageOptionValue(value.routeId, value.params ?? {}) : ''
|
|
39
|
+
);
|
|
40
|
+
const newTab = $derived(value?.newTab === true);
|
|
41
|
+
|
|
42
|
+
const emit = (next: CmsLinkValue) => {
|
|
43
|
+
onChange(next.href || next.routeId ? next : null);
|
|
44
|
+
};
|
|
45
|
+
const choosePage = (key: string) => {
|
|
46
|
+
const opt = options?.find((o) => o.value === key);
|
|
47
|
+
if (!opt) return emit({ newTab });
|
|
48
|
+
emit({ routeId: opt.routeId, params: opt.params, newTab });
|
|
49
|
+
};
|
|
50
|
+
const setUrl = (href: string) => emit({ href: href.trim(), newTab });
|
|
51
|
+
const setNewTab = (checked: boolean) => {
|
|
52
|
+
if (value) emit({ ...value, newTab: checked });
|
|
53
|
+
};
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<div class="vela:flex vela:flex-col vela:gap-1.5">
|
|
57
|
+
<div class="vela:flex vela:items-center vela:justify-between">
|
|
58
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">{label ?? 'Link'}</span>
|
|
59
|
+
<span
|
|
60
|
+
class="vela:inline-flex vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)] vela:p-0.5 vela:text-[11px]"
|
|
61
|
+
role="radiogroup"
|
|
62
|
+
aria-label="Link type"
|
|
63
|
+
>
|
|
64
|
+
{#each [['page', 'Page'], ['url', 'URL']] as const as [key, name] (key)}
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
role="radio"
|
|
68
|
+
aria-checked={mode === key}
|
|
69
|
+
class="vela:rounded vela:px-2 vela:py-0.5 vela:cursor-pointer vela:text-bar-text-secondary aria-checked:vela:bg-[var(--cms-bar-bg-hover)] aria-checked:vela:text-bar-text"
|
|
70
|
+
onclick={() => (mode = key)}
|
|
71
|
+
>
|
|
72
|
+
{name}
|
|
73
|
+
</button>
|
|
74
|
+
{/each}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
{#if mode === 'page'}
|
|
78
|
+
<select
|
|
79
|
+
class={selectClass}
|
|
80
|
+
value={selected}
|
|
81
|
+
onchange={(e) => choosePage(e.currentTarget.value)}
|
|
82
|
+
>
|
|
83
|
+
<option value="">{options === null ? 'Loading…' : '— No link —'}</option>
|
|
84
|
+
{#each options ?? [] as opt (opt.value)}
|
|
85
|
+
<option value={opt.value}>{opt.label}</option>
|
|
86
|
+
{/each}
|
|
87
|
+
</select>
|
|
88
|
+
{:else}
|
|
89
|
+
<Input
|
|
90
|
+
type="url"
|
|
91
|
+
value={value?.href ?? ''}
|
|
92
|
+
placeholder="https://…"
|
|
93
|
+
oninput={(e) => setUrl(e.currentTarget.value)}
|
|
94
|
+
/>
|
|
95
|
+
{/if}
|
|
96
|
+
<label
|
|
97
|
+
class="vela:flex vela:items-center vela:gap-2 vela:text-[12px] vela:text-bar-text-secondary"
|
|
98
|
+
>
|
|
99
|
+
<Checkbox checked={newTab} onCheckedChange={(c) => setNewTab(c === true)} disabled={!value} />
|
|
100
|
+
Open in a new tab
|
|
101
|
+
</label>
|
|
102
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CmsLinkValue } from '../../core/shapes/link.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
value: CmsLinkValue | null;
|
|
4
|
+
onChange: (next: CmsLinkValue | null) => void;
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const LinkInput: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type LinkInput = ReturnType<typeof LinkInput>;
|
|
9
|
+
export default LinkInput;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A list of items inside a structured form: one collapsible card per
|
|
4
|
+
* item, up/down/remove buttons, and an add button. Every item carries a
|
|
5
|
+
* stable `id` (new ones get a random one), so translations and reorders
|
|
6
|
+
* never lose track of an item.
|
|
7
|
+
*/
|
|
8
|
+
import type { FormField } from '../../core/shapes/form.js';
|
|
9
|
+
import type { Tree } from '../../core/path.js';
|
|
10
|
+
import { moveItem, newItemId } from '../../core/structured.js';
|
|
11
|
+
import FieldsForm from './fields-form.svelte';
|
|
12
|
+
import ReorderButtons from './reorder-buttons.svelte';
|
|
13
|
+
import ChevronDownIcon from './icons/chevron-down.svelte';
|
|
14
|
+
import ChevronRightIcon from './icons/chevron-right.svelte';
|
|
15
|
+
import { Button } from './ui/button/index.js';
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
label: string;
|
|
19
|
+
itemLabel: string;
|
|
20
|
+
titleKey?: string;
|
|
21
|
+
fields: readonly FormField[];
|
|
22
|
+
blank: () => Tree;
|
|
23
|
+
items: Tree[];
|
|
24
|
+
onChange: (items: Tree[]) => void;
|
|
25
|
+
};
|
|
26
|
+
let { label, itemLabel, titleKey, fields, blank, items, onChange }: Props = $props();
|
|
27
|
+
|
|
28
|
+
let open = $state(new Set<string>());
|
|
29
|
+
const toggle = (id: string) => {
|
|
30
|
+
const next = new Set(open);
|
|
31
|
+
if (next.has(id)) next.delete(id);
|
|
32
|
+
else next.add(id);
|
|
33
|
+
open = next;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const idOf = (item: Tree, i: number): string =>
|
|
37
|
+
typeof item.id === 'string' && item.id !== '' ? item.id : `item-${i}`;
|
|
38
|
+
|
|
39
|
+
const titleOf = (item: Tree, i: number): string => {
|
|
40
|
+
const t = titleKey ? item[titleKey] : undefined;
|
|
41
|
+
if (typeof t === 'string' && t.trim() !== '') return t;
|
|
42
|
+
return `${itemLabel[0].toUpperCase()}${itemLabel.slice(1)} ${i + 1}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const add = () => {
|
|
46
|
+
const id = newItemId();
|
|
47
|
+
onChange([...items, { id, ...blank() }]);
|
|
48
|
+
open = new Set([...open, id]);
|
|
49
|
+
};
|
|
50
|
+
const replace = (i: number, next: Tree) => onChange(items.map((it, j) => (j === i ? next : it)));
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div class="vela:flex vela:flex-col vela:gap-2">
|
|
54
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">{label}</span>
|
|
55
|
+
{#each items as item, i (idOf(item, i))}
|
|
56
|
+
{@const id = idOf(item, i)}
|
|
57
|
+
{@const expanded = open.has(id)}
|
|
58
|
+
<div class="vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)]">
|
|
59
|
+
<div class="vela:flex vela:items-center vela:gap-1 vela:pl-1 vela:pr-1.5 vela:py-1">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class="vela:flex vela:flex-1 vela:min-w-0 vela:items-center vela:gap-1.5 vela:cursor-pointer vela:text-left vela:text-[13px] vela:text-bar-text"
|
|
63
|
+
aria-expanded={expanded}
|
|
64
|
+
onclick={() => toggle(id)}
|
|
65
|
+
>
|
|
66
|
+
{#if expanded}
|
|
67
|
+
<ChevronDownIcon class="vela:size-3.5 vela:shrink-0 vela:text-bar-text-tertiary" />
|
|
68
|
+
{:else}
|
|
69
|
+
<ChevronRightIcon class="vela:size-3.5 vela:shrink-0 vela:text-bar-text-tertiary" />
|
|
70
|
+
{/if}
|
|
71
|
+
<span class="vela:truncate">{titleOf(item, i)}</span>
|
|
72
|
+
</button>
|
|
73
|
+
<ReorderButtons
|
|
74
|
+
index={i}
|
|
75
|
+
count={items.length}
|
|
76
|
+
label={itemLabel}
|
|
77
|
+
onMove={(from, to) => onChange(moveItem(items, from, to))}
|
|
78
|
+
onRemove={(idx) => onChange(items.filter((_, j) => j !== idx))}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
{#if expanded}
|
|
82
|
+
<div
|
|
83
|
+
class="vela:flex vela:flex-col vela:gap-3 vela:border-t vela:border-[var(--cms-bar-divider)] vela:p-3"
|
|
84
|
+
>
|
|
85
|
+
<FieldsForm {fields} value={item} onChange={(next) => replace(i, next)} />
|
|
86
|
+
</div>
|
|
87
|
+
{/if}
|
|
88
|
+
</div>
|
|
89
|
+
{/each}
|
|
90
|
+
<Button variant="outline" size="pill" class="vela:self-start" onclick={add}>
|
|
91
|
+
+ Add {itemLabel}
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list of items inside a structured form: one collapsible card per
|
|
3
|
+
* item, up/down/remove buttons, and an add button. Every item carries a
|
|
4
|
+
* stable `id` (new ones get a random one), so translations and reorders
|
|
5
|
+
* never lose track of an item.
|
|
6
|
+
*/
|
|
7
|
+
import type { FormField } from '../../core/shapes/form.js';
|
|
8
|
+
import type { Tree } from '../../core/path.js';
|
|
9
|
+
type Props = {
|
|
10
|
+
label: string;
|
|
11
|
+
itemLabel: string;
|
|
12
|
+
titleKey?: string;
|
|
13
|
+
fields: readonly FormField[];
|
|
14
|
+
blank: () => Tree;
|
|
15
|
+
items: Tree[];
|
|
16
|
+
onChange: (items: Tree[]) => void;
|
|
17
|
+
};
|
|
18
|
+
declare const ListField: import("svelte").Component<Props, {}, "">;
|
|
19
|
+
type ListField = ReturnType<typeof ListField>;
|
|
20
|
+
export default ListField;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { page } from '$app/state';
|
|
2
3
|
import { cmsStore, type ReleaseItem } from '../cms/cms-store.svelte.js';
|
|
4
|
+
import type { CmsPayload } from '../cms/scope.js';
|
|
5
|
+
import { getStructured } from '../../core/structured-registry.js';
|
|
6
|
+
import { countTranslations, extractTranslations } from '../../core/structured.js';
|
|
3
7
|
import PanelFooter from './panel-footer.svelte';
|
|
4
8
|
import PanelHeader from './panel-header.svelte';
|
|
5
9
|
import Panel from './panel.svelte';
|
|
@@ -42,12 +46,48 @@
|
|
|
42
46
|
|
|
43
47
|
const counts = $derived(cmsStore.workingCopyCountsByLocale);
|
|
44
48
|
|
|
49
|
+
// Translatable strings inside structured values on this route, per
|
|
50
|
+
// locale: every usage the manifest recorded whose component registered a
|
|
51
|
+
// structured schema. Overlays for the other locales are loaded on open so
|
|
52
|
+
// stored translations count.
|
|
53
|
+
$effect(() => {
|
|
54
|
+
for (const locale of locales) {
|
|
55
|
+
if (locale !== defaultLocale) void cmsStore.loadLocaleOverlay(locale);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const stringCounts = $derived.by(() => {
|
|
60
|
+
const out = new Map<string, { total: number; missing: number }>();
|
|
61
|
+
const cms = page.data.cms as CmsPayload | undefined;
|
|
62
|
+
if (!cms) return out;
|
|
63
|
+
for (const locale of locales) {
|
|
64
|
+
if (locale === defaultLocale) continue;
|
|
65
|
+
let total = 0;
|
|
66
|
+
let missing = 0;
|
|
67
|
+
for (const scope of Object.values(cms.scopes)) {
|
|
68
|
+
const ref = { scopeId: scope.scopeId, routeId: scope.routeId, params: scope.params };
|
|
69
|
+
for (const usage of scope.usages ?? []) {
|
|
70
|
+
const schema = getStructured(usage.component);
|
|
71
|
+
if (!schema) continue;
|
|
72
|
+
const value = schema.read(cmsStore.getValue(ref, usage.name, defaultLocale), undefined);
|
|
73
|
+
const overlay = extractTranslations(cmsStore.getValue(ref, usage.name, locale));
|
|
74
|
+
const c = countTranslations(value, overlay, schema);
|
|
75
|
+
total += c.total;
|
|
76
|
+
missing += c.missing;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
out.set(locale, { total, missing });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
});
|
|
83
|
+
|
|
45
84
|
type Row = {
|
|
46
85
|
locale: string;
|
|
47
86
|
isDefault: boolean;
|
|
48
87
|
isCurrent: boolean;
|
|
49
88
|
edits: number;
|
|
50
89
|
missing: number;
|
|
90
|
+
strings: { total: number; missing: number } | null;
|
|
51
91
|
};
|
|
52
92
|
|
|
53
93
|
const rows = $derived.by<Row[]>(() => {
|
|
@@ -65,7 +105,8 @@
|
|
|
65
105
|
isDefault: locale === defaultLocale,
|
|
66
106
|
isCurrent: locale === currentLocale,
|
|
67
107
|
edits: counts[locale]?.total ?? 0,
|
|
68
|
-
missing
|
|
108
|
+
missing,
|
|
109
|
+
strings: stringCounts.get(locale) ?? null
|
|
69
110
|
};
|
|
70
111
|
});
|
|
71
112
|
});
|
|
@@ -57,5 +57,12 @@ export declare const normalizeField: (f: CmsPageField) => NormalizedField;
|
|
|
57
57
|
* it was declared in shorthand string form or object form.
|
|
58
58
|
*/
|
|
59
59
|
export declare const fieldType: (schema: MetadataFieldSchema) => MetadataPrimitive | "enum";
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Metadata every route gets, with or without a `page.cms.ts`: the search
|
|
62
|
+
* snippet, the share card, and the two indexing controls. A route's own
|
|
63
|
+
* `metadata` schema is merged over this, so it only ever adds or overrides.
|
|
64
|
+
* The Vite plugin's `DEFAULT_PAGE_METADATA_PATHS` mirrors these keys.
|
|
65
|
+
*/
|
|
61
66
|
export declare const DEFAULT_METADATA_SCHEMA: CmsPageMetadataSchema;
|
|
67
|
+
/** A route's effective metadata schema: the defaults plus its own keys. */
|
|
68
|
+
export declare const metadataSchemaFor: (own: CmsPageMetadataSchema | undefined) => CmsPageMetadataSchema;
|
|
@@ -17,8 +17,21 @@ export const normalizeField = (f) => typeof f === 'string'
|
|
|
17
17
|
* it was declared in shorthand string form or object form.
|
|
18
18
|
*/
|
|
19
19
|
export const fieldType = (schema) => typeof schema === 'string' ? schema : schema.type;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Metadata every route gets, with or without a `page.cms.ts`: the search
|
|
22
|
+
* snippet, the share card, and the two indexing controls. A route's own
|
|
23
|
+
* `metadata` schema is merged over this, so it only ever adds or overrides.
|
|
24
|
+
* The Vite plugin's `DEFAULT_PAGE_METADATA_PATHS` mirrors these keys.
|
|
25
|
+
*/
|
|
21
26
|
export const DEFAULT_METADATA_SCHEMA = {
|
|
22
27
|
title: 'string',
|
|
23
|
-
description: 'long-string'
|
|
28
|
+
description: 'long-string',
|
|
29
|
+
ogTitle: 'string',
|
|
30
|
+
ogDescription: 'long-string',
|
|
31
|
+
ogImage: 'image',
|
|
32
|
+
twitterCard: { type: 'enum', values: ['summary', 'summary_large_image'] },
|
|
33
|
+
canonical: 'string',
|
|
34
|
+
noindex: 'boolean'
|
|
24
35
|
};
|
|
36
|
+
/** A route's effective metadata schema: the defaults plus its own keys. */
|
|
37
|
+
export const metadataSchemaFor = (own) => own ? { ...DEFAULT_METADATA_SCHEMA, ...own } : DEFAULT_METADATA_SCHEMA;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type PageEntry = {
|
|
2
|
+
params: Record<string, string>;
|
|
3
|
+
isDraft: boolean;
|
|
4
|
+
isDeletePending: boolean;
|
|
5
|
+
redirectTo?: string;
|
|
6
|
+
gone?: boolean;
|
|
7
|
+
};
|
|
8
|
+
type PageRoute = {
|
|
9
|
+
routeId: string;
|
|
10
|
+
ownedParams: string[];
|
|
11
|
+
entries: PageEntry[];
|
|
12
|
+
};
|
|
13
|
+
export type PageOption = {
|
|
14
|
+
/** `routeId::{params}` — stable key for a select. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** The resolved URL, shown to the editor. */
|
|
17
|
+
label: string;
|
|
18
|
+
routeId: string;
|
|
19
|
+
params: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
export declare const pageOptionValue: (routeId: string, params: Record<string, string>) => string;
|
|
22
|
+
export declare const toPageOptions: (routes: PageRoute[]) => PageOption[];
|
|
23
|
+
export declare const fetchPageOptions: (endpoint: string) => Promise<PageOption[]>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's pages as link targets, for the page picker in link editors.
|
|
3
|
+
* Fetched once per endpoint and shared by every picker on the page.
|
|
4
|
+
*/
|
|
5
|
+
import { resolveRouteUrl } from './resolve-route.js';
|
|
6
|
+
export const pageOptionValue = (routeId, params) => `${routeId}::${JSON.stringify(params)}`;
|
|
7
|
+
export const toPageOptions = (routes) => {
|
|
8
|
+
const opts = [];
|
|
9
|
+
for (const r of routes) {
|
|
10
|
+
const entries = r.entries.length > 0
|
|
11
|
+
? r.entries
|
|
12
|
+
: [{ params: {}, isDraft: false, isDeletePending: false }];
|
|
13
|
+
for (const ent of entries) {
|
|
14
|
+
// Tombstones (redirected / gone) aren't real link targets.
|
|
15
|
+
if (ent.redirectTo || ent.gone)
|
|
16
|
+
continue;
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = resolveRouteUrl(r.routeId, ent.params);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
opts.push({
|
|
25
|
+
value: pageOptionValue(r.routeId, ent.params),
|
|
26
|
+
label: url,
|
|
27
|
+
routeId: r.routeId,
|
|
28
|
+
params: ent.params
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
opts.sort((a, b) => a.label.localeCompare(b.label));
|
|
33
|
+
return opts;
|
|
34
|
+
};
|
|
35
|
+
const cache = new Map();
|
|
36
|
+
export const fetchPageOptions = (endpoint) => {
|
|
37
|
+
let p = cache.get(endpoint);
|
|
38
|
+
if (!p) {
|
|
39
|
+
p = fetch(`${endpoint}/pages`, { credentials: 'include' })
|
|
40
|
+
.then(async (res) => {
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
return [];
|
|
43
|
+
const data = (await res.json());
|
|
44
|
+
return toPageOptions(data.routes);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
cache.delete(endpoint);
|
|
48
|
+
return [];
|
|
49
|
+
});
|
|
50
|
+
cache.set(endpoint, p);
|
|
51
|
+
}
|
|
52
|
+
return p;
|
|
53
|
+
};
|