@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,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Up / down / remove for one item in a structured list. Buttons rather
|
|
4
|
+
* than drag handles: they work with a keyboard, on touch, and inside a
|
|
5
|
+
* popover without a drag library.
|
|
6
|
+
*/
|
|
7
|
+
import ChevronUpIcon from './icons/chevron-up.svelte';
|
|
8
|
+
import ChevronDownIcon from './icons/chevron-down.svelte';
|
|
9
|
+
import TrashIcon from './icons/trash-2.svelte';
|
|
10
|
+
import { Button } from './ui/button/index.js';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
index: number;
|
|
14
|
+
count: number;
|
|
15
|
+
onMove: (from: number, to: number) => void;
|
|
16
|
+
onRemove: (index: number) => void;
|
|
17
|
+
label?: string;
|
|
18
|
+
};
|
|
19
|
+
let { index, count, onMove, onRemove, label = 'item' }: Props = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class="vela:flex vela:items-center vela:gap-0.5 vela:shrink-0">
|
|
23
|
+
<Button
|
|
24
|
+
size="icon"
|
|
25
|
+
variant="ghost"
|
|
26
|
+
class="vela:h-6 vela:w-6"
|
|
27
|
+
aria-label={`Move ${label} up`}
|
|
28
|
+
disabled={index === 0}
|
|
29
|
+
onclick={() => onMove(index, index - 1)}
|
|
30
|
+
>
|
|
31
|
+
<ChevronUpIcon class="vela:size-3.5" />
|
|
32
|
+
</Button>
|
|
33
|
+
<Button
|
|
34
|
+
size="icon"
|
|
35
|
+
variant="ghost"
|
|
36
|
+
class="vela:h-6 vela:w-6"
|
|
37
|
+
aria-label={`Move ${label} down`}
|
|
38
|
+
disabled={index >= count - 1}
|
|
39
|
+
onclick={() => onMove(index, index + 1)}
|
|
40
|
+
>
|
|
41
|
+
<ChevronDownIcon class="vela:size-3.5" />
|
|
42
|
+
</Button>
|
|
43
|
+
<Button
|
|
44
|
+
size="icon"
|
|
45
|
+
variant="ghost"
|
|
46
|
+
class="vela:h-6 vela:w-6 vela:text-[var(--cms-status-error-text-base)]"
|
|
47
|
+
aria-label={`Remove ${label}`}
|
|
48
|
+
onclick={() => onRemove(index)}
|
|
49
|
+
>
|
|
50
|
+
<TrashIcon class="vela:size-3.5" />
|
|
51
|
+
</Button>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
index: number;
|
|
3
|
+
count: number;
|
|
4
|
+
onMove: (from: number, to: number) => void;
|
|
5
|
+
onRemove: (index: number) => void;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const ReorderButtons: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type ReorderButtons = ReturnType<typeof ReorderButtons>;
|
|
10
|
+
export default ReorderButtons;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A rich text field inside a structured editor: the same TipTap setup as
|
|
4
|
+
* the inline `CmsRichText` editor, bound to a local value instead of a
|
|
5
|
+
* CMS path.
|
|
6
|
+
*/
|
|
7
|
+
import { onDestroy, onMount, untrack } from 'svelte';
|
|
8
|
+
import { Editor } from '@tiptap/core';
|
|
9
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
10
|
+
import Toolbar from '../cms/cms-rich-text-toolbar.svelte';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (html: string) => void;
|
|
15
|
+
label?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
};
|
|
18
|
+
let { value, onChange, label }: Props = $props();
|
|
19
|
+
|
|
20
|
+
let host = $state<HTMLDivElement>();
|
|
21
|
+
let wrap = $state<HTMLDivElement>();
|
|
22
|
+
let editor = $state<Editor>();
|
|
23
|
+
let focused = $state(false);
|
|
24
|
+
let tick = $state(0);
|
|
25
|
+
|
|
26
|
+
onMount(() => {
|
|
27
|
+
editor = new Editor({
|
|
28
|
+
element: host,
|
|
29
|
+
extensions: [StarterKit.configure({ heading: { levels: [2, 3] } })],
|
|
30
|
+
content: value,
|
|
31
|
+
editorProps: { attributes: { class: 'cms-rich-text-input-pm' } },
|
|
32
|
+
onUpdate: ({ editor }) => onChange(editor.isEmpty ? '' : editor.getHTML()),
|
|
33
|
+
onTransaction: () => {
|
|
34
|
+
tick++;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
onDestroy(() => editor?.destroy());
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
if (!editor || focused) return;
|
|
42
|
+
const next = value;
|
|
43
|
+
untrack(() => {
|
|
44
|
+
if (editor && editor.getHTML() !== next) {
|
|
45
|
+
editor.commands.setContent(next, { emitUpdate: false });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="vela:flex vela:flex-col vela:gap-1.5">
|
|
52
|
+
{#if label}<span class="vela:text-[12px] vela:text-bar-text-secondary">{label}</span>{/if}
|
|
53
|
+
<div
|
|
54
|
+
class="cms-rich-text-input vela:relative vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)] vela:px-3 vela:py-2 vela:text-[13px] vela:text-bar-text"
|
|
55
|
+
class:focused
|
|
56
|
+
bind:this={wrap}
|
|
57
|
+
onfocusin={() => (focused = true)}
|
|
58
|
+
onfocusout={(e) => {
|
|
59
|
+
const next = e.relatedTarget as Node | null;
|
|
60
|
+
if (next && wrap?.contains(next)) return;
|
|
61
|
+
focused = false;
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{#if editor && focused}
|
|
65
|
+
<div class="vela:mb-2">
|
|
66
|
+
<Toolbar {editor} {tick} />
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
<div bind:this={host}></div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.cms-rich-text-input.focused {
|
|
75
|
+
border-color: var(--cms-accent, #534ab7);
|
|
76
|
+
}
|
|
77
|
+
.cms-rich-text-input :global(.cms-rich-text-input-pm) {
|
|
78
|
+
outline: none;
|
|
79
|
+
min-height: 3em;
|
|
80
|
+
line-height: 1.5;
|
|
81
|
+
}
|
|
82
|
+
.cms-rich-text-input :global(.cms-rich-text-input-pm > :first-child) {
|
|
83
|
+
margin-top: 0;
|
|
84
|
+
}
|
|
85
|
+
.cms-rich-text-input :global(.cms-rich-text-input-pm > :last-child) {
|
|
86
|
+
margin-bottom: 0;
|
|
87
|
+
}
|
|
88
|
+
.cms-rich-text-input :global(.cms-rich-text-input-pm ul),
|
|
89
|
+
.cms-rich-text-input :global(.cms-rich-text-input-pm ol) {
|
|
90
|
+
padding-left: 1.25em;
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (html: string) => void;
|
|
4
|
+
label?: string;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const RichTextInput: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type RichTextInput = ReturnType<typeof RichTextInput>;
|
|
9
|
+
export default RichTextInput;
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
import { pages } from 'virtual:vela-cms/pages';
|
|
5
5
|
import { cmsStore, type CmsScopeRef } from '../cms/cms-store.svelte.js';
|
|
6
6
|
import { getPageScope, type CmsPayload } from '../cms/scope.js';
|
|
7
|
+
import { ROOT_SCOPE_ID } from '../cms/use-cms-field.svelte.js';
|
|
8
|
+
import { toMetaTags } from '../../core/metadata.js';
|
|
9
|
+
import FieldInput, { type FieldInputType } from './field-input.svelte';
|
|
7
10
|
import {
|
|
8
|
-
DEFAULT_METADATA_SCHEMA,
|
|
9
11
|
fieldType,
|
|
12
|
+
metadataSchemaFor,
|
|
10
13
|
type CmsPageMetadataSchema,
|
|
11
14
|
type MetadataFieldSchema
|
|
12
15
|
} from './page-config.js';
|
|
@@ -15,8 +18,7 @@
|
|
|
15
18
|
import Panel from './panel.svelte';
|
|
16
19
|
import { Button } from './ui/button/index.js';
|
|
17
20
|
import * as Collapsible from './ui/collapsible/index.js';
|
|
18
|
-
import
|
|
19
|
-
import { Textarea } from './ui/textarea/index.js';
|
|
21
|
+
import * as Tabs from './ui/tabs/index.js';
|
|
20
22
|
|
|
21
23
|
type Props = { onClose: () => void; onSave: () => void | Promise<void> };
|
|
22
24
|
let { onClose, onSave }: Props = $props();
|
|
@@ -27,6 +29,20 @@
|
|
|
27
29
|
const TITLE_AMBER_AT = Math.floor(TITLE_TARGET * 0.8);
|
|
28
30
|
const DESC_AMBER_AT = Math.floor(DESC_TARGET * 0.8);
|
|
29
31
|
|
|
32
|
+
// The search snippet and the share card; everything else is advanced.
|
|
33
|
+
const PRIMARY = ['title', 'description'];
|
|
34
|
+
const SHARE = ['ogTitle', 'ogDescription', 'ogImage', 'twitterCard'];
|
|
35
|
+
const LABELS: Record<string, string> = {
|
|
36
|
+
title: 'Title',
|
|
37
|
+
description: 'Description',
|
|
38
|
+
ogTitle: 'Share title',
|
|
39
|
+
ogDescription: 'Share description',
|
|
40
|
+
ogImage: 'Share image',
|
|
41
|
+
twitterCard: 'Twitter card',
|
|
42
|
+
canonical: 'Canonical URL',
|
|
43
|
+
noindex: 'Hide from search engines'
|
|
44
|
+
};
|
|
45
|
+
|
|
30
46
|
const cms = $derived(page.data.cms as CmsPayload | undefined);
|
|
31
47
|
const pageScope = $derived(getPageScope(cms));
|
|
32
48
|
const ref = $derived<CmsScopeRef | null>(
|
|
@@ -35,25 +51,37 @@
|
|
|
35
51
|
: null
|
|
36
52
|
);
|
|
37
53
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
54
|
+
// The route's `page.cms.ts` schema merged over the defaults, so every
|
|
55
|
+
// route gets the search snippet, the share card and indexing controls.
|
|
40
56
|
const schema = $derived.by<CmsPageMetadataSchema>(() => {
|
|
41
57
|
if (!pageScope) return {};
|
|
42
|
-
|
|
43
|
-
return cfg?.metadata ?? DEFAULT_METADATA_SCHEMA;
|
|
58
|
+
return metadataSchemaFor(pages[pageScope.routeId]?.metadata);
|
|
44
59
|
});
|
|
45
60
|
|
|
46
61
|
const entries = $derived(Object.entries(schema));
|
|
47
|
-
const
|
|
62
|
+
const shareEntries = $derived(entries.filter(([k]) => SHARE.includes(k)));
|
|
63
|
+
const advancedEntries = $derived(
|
|
64
|
+
entries.filter(([k]) => !PRIMARY.includes(k) && !SHARE.includes(k))
|
|
65
|
+
);
|
|
48
66
|
|
|
49
67
|
const persistedValue = (key: string): unknown => {
|
|
50
68
|
if (!ref) return undefined;
|
|
51
69
|
return cmsStore.getValue(ref, `metadata.${key}`);
|
|
52
70
|
};
|
|
53
71
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
72
|
+
// The title template reads the root layout's branding name, exactly as
|
|
73
|
+
// `toMetaTags` does at render time.
|
|
74
|
+
const siteName = $derived.by(() => {
|
|
75
|
+
const v = cmsStore.getValue(
|
|
76
|
+
{ scopeId: ROOT_SCOPE_ID, routeId: '/', params: {} },
|
|
77
|
+
'branding.name'
|
|
78
|
+
);
|
|
79
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Local form state — typing only updates `local`; nothing reaches the
|
|
83
|
+
// draft until Save fires. That keeps the bar's "X page draft" badge from
|
|
84
|
+
// flickering as the user explores.
|
|
57
85
|
let local = $state<Record<string, unknown>>({});
|
|
58
86
|
|
|
59
87
|
$effect(() => {
|
|
@@ -62,8 +90,17 @@
|
|
|
62
90
|
local = next;
|
|
63
91
|
});
|
|
64
92
|
|
|
65
|
-
const
|
|
66
|
-
const
|
|
93
|
+
const stringValue = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
94
|
+
const titleVal = $derived(stringValue(local.title));
|
|
95
|
+
const descVal = $derived(stringValue(local.description));
|
|
96
|
+
const preview = $derived(toMetaTags(local, { siteName }));
|
|
97
|
+
const fullTitle = $derived(
|
|
98
|
+
preview.title
|
|
99
|
+
? preview.titleTemplate
|
|
100
|
+
? preview.titleTemplate.replace('%s', preview.title)
|
|
101
|
+
: preview.title
|
|
102
|
+
: ''
|
|
103
|
+
);
|
|
67
104
|
|
|
68
105
|
const breadcrumb = $derived.by(() => {
|
|
69
106
|
const host = page.url.hostname || 'localhost';
|
|
@@ -71,12 +108,10 @@
|
|
|
71
108
|
return segments.length === 0 ? host : `${host} › ${segments.join(' › ')}`;
|
|
72
109
|
});
|
|
73
110
|
|
|
74
|
-
let previewTab = $state
|
|
111
|
+
let previewTab = $state('search');
|
|
75
112
|
|
|
76
113
|
// Pull the live site's favicon — Google's result preview shows whichever
|
|
77
|
-
// favicon the document head declares, so the SEO preview should match.
|
|
78
|
-
// to absolute URLs (using `.href` rather than `.getAttribute`) so the img src
|
|
79
|
-
// renders correctly regardless of where the favicon is hosted.
|
|
114
|
+
// favicon the document head declares, so the SEO preview should match.
|
|
80
115
|
let faviconHref = $state<string | null>(null);
|
|
81
116
|
$effect(() => {
|
|
82
117
|
if (typeof document === 'undefined') return;
|
|
@@ -86,21 +121,6 @@
|
|
|
86
121
|
faviconHref = link?.href ?? null;
|
|
87
122
|
});
|
|
88
123
|
|
|
89
|
-
// Best-effort OG image: an explicit `og_image` field in the schema wins,
|
|
90
|
-
// otherwise the first image-type metadata field. The Social card stays useful
|
|
91
|
-
// even when neither exists — title/description still render against a
|
|
92
|
-
// placeholder.
|
|
93
|
-
const ogImageVal = $derived.by(() => {
|
|
94
|
-
const og = stringValue(local.og_image);
|
|
95
|
-
if (og) return og;
|
|
96
|
-
for (const [k, s] of entries) {
|
|
97
|
-
if (fieldType(s) !== 'image') continue;
|
|
98
|
-
const v = stringValue(local[k]);
|
|
99
|
-
if (v) return v;
|
|
100
|
-
}
|
|
101
|
-
return '';
|
|
102
|
-
});
|
|
103
|
-
|
|
104
124
|
const hostname = $derived(page.url.hostname || 'localhost');
|
|
105
125
|
|
|
106
126
|
const counterClass = (n: number, target: number, amberAt: number): string => {
|
|
@@ -118,33 +138,25 @@
|
|
|
118
138
|
await onSave();
|
|
119
139
|
};
|
|
120
140
|
|
|
121
|
-
const fieldLabelClass = 'vela:text-[12px] vela:text-bar-text-secondary';
|
|
122
141
|
const counterBaseClass = 'vela:text-[11px] vela:font-mono vela:tabular-nums';
|
|
123
|
-
const selectClass =
|
|
124
|
-
'vela:flex vela:h-9 vela:w-full vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)]' +
|
|
125
|
-
' vela:bg-[var(--cms-bar-bg-hover)] vela:px-3 vela:py-1 vela:text-[13px] vela:text-bar-text' +
|
|
126
|
-
' vela:focus:outline-none vela:focus:ring-2 vela:focus:ring-[var(--cms-accent)]';
|
|
127
142
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const booleanValue = (v: unknown): boolean => v === true;
|
|
132
|
-
|
|
133
|
-
const setNumber = (key: string, raw: string) => {
|
|
134
|
-
if (raw === '') {
|
|
135
|
-
local[key] = undefined;
|
|
136
|
-
} else {
|
|
137
|
-
const n = Number(raw);
|
|
138
|
-
local[key] = Number.isFinite(n) ? n : undefined;
|
|
139
|
-
}
|
|
143
|
+
const inputType = (s: MetadataFieldSchema): FieldInputType => {
|
|
144
|
+
const t = fieldType(s);
|
|
145
|
+
return t === 'string' ? 'text' : t;
|
|
140
146
|
};
|
|
141
|
-
|
|
142
147
|
const enumValues = (s: MetadataFieldSchema): readonly string[] =>
|
|
143
148
|
typeof s !== 'string' && s.type === 'enum' ? s.values : [];
|
|
149
|
+
const labelFor = (key: string) => LABELS[key] ?? key;
|
|
144
150
|
</script>
|
|
145
151
|
|
|
146
152
|
<Panel ariaLabel="SEO and metadata" {onClose}>
|
|
147
|
-
<PanelHeader title="SEO & metadata" {onClose}
|
|
153
|
+
<PanelHeader title="SEO & metadata" {onClose}>
|
|
154
|
+
{#snippet subtitle()}
|
|
155
|
+
<span class="vela:font-mono vela:text-[12px] vela:text-bar-text-secondary">
|
|
156
|
+
{page.url.pathname}
|
|
157
|
+
</span>
|
|
158
|
+
{/snippet}
|
|
159
|
+
</PanelHeader>
|
|
148
160
|
|
|
149
161
|
<form
|
|
150
162
|
class="vela:overflow-y-auto vela:px-4 vela:pb-4 vela:flex vela:flex-col vela:gap-4"
|
|
@@ -158,42 +170,13 @@
|
|
|
158
170
|
No page scope on this route.
|
|
159
171
|
</p>
|
|
160
172
|
{:else}
|
|
161
|
-
<
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
vela:bg-[var(--cms-bar-bg-hover)] vela:border vela:border-[var(--cms-bar-divider)]"
|
|
167
|
-
>
|
|
168
|
-
<button
|
|
169
|
-
type="button"
|
|
170
|
-
role="tab"
|
|
171
|
-
aria-selected={previewTab === 'search'}
|
|
172
|
-
onclick={() => (previewTab = 'search')}
|
|
173
|
-
class="vela:px-2.5 vela:py-1 vela:rounded vela:text-[11px] vela:font-medium
|
|
174
|
-
vela:cursor-pointer vela:transition-colors
|
|
175
|
-
{previewTab === 'search'
|
|
176
|
-
? 'vela:bg-bar-bg vela:text-bar-text'
|
|
177
|
-
: 'vela:text-bar-text-secondary vela:hover:text-bar-text'}"
|
|
178
|
-
>
|
|
179
|
-
Search
|
|
180
|
-
</button>
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
role="tab"
|
|
184
|
-
aria-selected={previewTab === 'social'}
|
|
185
|
-
onclick={() => (previewTab = 'social')}
|
|
186
|
-
class="vela:px-2.5 vela:py-1 vela:rounded vela:text-[11px] vela:font-medium
|
|
187
|
-
vela:cursor-pointer vela:transition-colors
|
|
188
|
-
{previewTab === 'social'
|
|
189
|
-
? 'vela:bg-bar-bg vela:text-bar-text'
|
|
190
|
-
: 'vela:text-bar-text-secondary vela:hover:text-bar-text'}"
|
|
191
|
-
>
|
|
192
|
-
Social
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
173
|
+
<Tabs.Root bind:value={previewTab}>
|
|
174
|
+
<Tabs.List aria-label="Preview format">
|
|
175
|
+
<Tabs.Trigger value="search">Search</Tabs.Trigger>
|
|
176
|
+
<Tabs.Trigger value="social">Social</Tabs.Trigger>
|
|
177
|
+
</Tabs.List>
|
|
195
178
|
|
|
196
|
-
|
|
179
|
+
<Tabs.Content value="search">
|
|
197
180
|
<div
|
|
198
181
|
class="vela:bg-[#fafafa] vela:text-[#202124] vela:rounded-lg vela:p-3.5
|
|
199
182
|
vela:border vela:border-[#e8eaed]"
|
|
@@ -215,13 +198,15 @@
|
|
|
215
198
|
<div
|
|
216
199
|
class="vela:mt-1.5 vela:text-[18px] vela:leading-snug vela:text-[#1a0dab] vela:truncate"
|
|
217
200
|
>
|
|
218
|
-
{
|
|
201
|
+
{fullTitle || 'Page title'}
|
|
219
202
|
</div>
|
|
220
203
|
<div class="vela:mt-1 vela:text-[13px] vela:leading-snug vela:text-[#4d5156]">
|
|
221
|
-
{
|
|
204
|
+
{preview.description || 'Meta description appears here.'}
|
|
222
205
|
</div>
|
|
223
206
|
</div>
|
|
224
|
-
|
|
207
|
+
</Tabs.Content>
|
|
208
|
+
|
|
209
|
+
<Tabs.Content value="social">
|
|
225
210
|
<div
|
|
226
211
|
class="vela:bg-white vela:text-[#0f1419] vela:rounded-lg vela:overflow-hidden
|
|
227
212
|
vela:border vela:border-[#e8eaed]"
|
|
@@ -229,70 +214,92 @@
|
|
|
229
214
|
<div
|
|
230
215
|
class="vela:aspect-[1.91/1] vela:bg-[#e8eaed] vela:flex vela:items-center vela:justify-center vela:overflow-hidden"
|
|
231
216
|
>
|
|
232
|
-
{#if
|
|
233
|
-
<img
|
|
217
|
+
{#if preview.openGraph?.images?.[0]}
|
|
218
|
+
<img
|
|
219
|
+
src={preview.openGraph.images[0].url}
|
|
220
|
+
alt=""
|
|
221
|
+
class="vela:w-full vela:h-full vela:object-cover"
|
|
222
|
+
/>
|
|
234
223
|
{:else}
|
|
235
|
-
<span class="vela:text-[11px] vela:text-[#5f6368]">No
|
|
224
|
+
<span class="vela:text-[11px] vela:text-[#5f6368]">No share image set</span>
|
|
236
225
|
{/if}
|
|
237
226
|
</div>
|
|
238
227
|
<div
|
|
239
228
|
class="vela:px-3.5 vela:py-2.5 vela:border-t vela:border-[#e8eaed] vela:bg-[#f7f9fa]"
|
|
240
229
|
>
|
|
241
230
|
<div class="vela:text-[11px] vela:uppercase vela:text-[#5f6368]">
|
|
242
|
-
{hostname}
|
|
231
|
+
{siteName ?? hostname}
|
|
243
232
|
</div>
|
|
244
233
|
<div
|
|
245
234
|
class="vela:mt-0.5 vela:text-[14px] vela:font-semibold vela:leading-snug vela:line-clamp-2"
|
|
246
235
|
>
|
|
247
|
-
{
|
|
236
|
+
{preview.openGraph?.title || 'Page title'}
|
|
248
237
|
</div>
|
|
249
238
|
<div
|
|
250
239
|
class="vela:mt-0.5 vela:text-[12px] vela:leading-snug vela:text-[#536471] vela:line-clamp-2"
|
|
251
240
|
>
|
|
252
|
-
{
|
|
241
|
+
{preview.openGraph?.description || 'Meta description appears here.'}
|
|
253
242
|
</div>
|
|
254
243
|
</div>
|
|
255
244
|
</div>
|
|
256
|
-
|
|
257
|
-
</
|
|
245
|
+
</Tabs.Content>
|
|
246
|
+
</Tabs.Root>
|
|
258
247
|
|
|
259
248
|
{#if 'title' in schema}
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
</div>
|
|
273
|
-
<Input
|
|
274
|
-
type="text"
|
|
275
|
-
value={stringValue(local.title)}
|
|
276
|
-
oninput={(e) => (local.title = e.currentTarget.value)}
|
|
277
|
-
/>
|
|
278
|
-
</label>
|
|
249
|
+
<FieldInput
|
|
250
|
+
type="text"
|
|
251
|
+
label="Title"
|
|
252
|
+
value={local.title}
|
|
253
|
+
onChange={(v) => (local.title = v ?? '')}
|
|
254
|
+
hint={`${titleVal.length} / ${TITLE_TARGET}`}
|
|
255
|
+
hintClass="{counterBaseClass} {counterClass(
|
|
256
|
+
titleVal.length,
|
|
257
|
+
TITLE_TARGET,
|
|
258
|
+
TITLE_AMBER_AT
|
|
259
|
+
)}"
|
|
260
|
+
/>
|
|
279
261
|
{/if}
|
|
280
262
|
|
|
281
263
|
{#if 'description' in schema}
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
264
|
+
<FieldInput
|
|
265
|
+
type="long-string"
|
|
266
|
+
label="Description"
|
|
267
|
+
value={local.description}
|
|
268
|
+
onChange={(v) => (local.description = v ?? '')}
|
|
269
|
+
hint={`${descVal.length} / ${DESC_TARGET}`}
|
|
270
|
+
hintClass="{counterBaseClass} {counterClass(descVal.length, DESC_TARGET, DESC_AMBER_AT)}"
|
|
271
|
+
/>
|
|
272
|
+
{/if}
|
|
273
|
+
|
|
274
|
+
{#if shareEntries.length > 0}
|
|
275
|
+
<Collapsible.Root class="vela:border-t vela:border-[var(--cms-bar-divider)] vela:pt-3">
|
|
276
|
+
<Collapsible.Trigger
|
|
277
|
+
class="vela:group vela:flex vela:items-center vela:justify-between vela:w-full
|
|
278
|
+
vela:text-left vela:text-[13px] vela:text-bar-text-secondary
|
|
279
|
+
vela:hover:text-bar-text vela:cursor-pointer vela:focus:outline-none"
|
|
280
|
+
>
|
|
281
|
+
<span>Share card</span>
|
|
282
|
+
<ChevronDownIcon
|
|
283
|
+
class="vela:size-4 vela:transition-transform vela:group-data-[state=open]:rotate-180"
|
|
284
|
+
/>
|
|
285
|
+
</Collapsible.Trigger>
|
|
286
|
+
<Collapsible.Content>
|
|
287
|
+
<div class="vela:flex vela:flex-col vela:gap-3 vela:mt-3">
|
|
288
|
+
<p class="vela:text-[11px] vela:text-bar-text-tertiary vela:m-0">
|
|
289
|
+
Blank fields reuse the title and description above.
|
|
290
|
+
</p>
|
|
291
|
+
{#each shareEntries as [key, fieldSchema] (key)}
|
|
292
|
+
<FieldInput
|
|
293
|
+
type={inputType(fieldSchema)}
|
|
294
|
+
label={labelFor(key)}
|
|
295
|
+
values={enumValues(fieldSchema)}
|
|
296
|
+
value={local[key]}
|
|
297
|
+
onChange={(v) => (local[key] = v)}
|
|
298
|
+
/>
|
|
299
|
+
{/each}
|
|
300
|
+
</div>
|
|
301
|
+
</Collapsible.Content>
|
|
302
|
+
</Collapsible.Root>
|
|
296
303
|
{/if}
|
|
297
304
|
|
|
298
305
|
{#if advancedEntries.length > 0}
|
|
@@ -310,65 +317,13 @@
|
|
|
310
317
|
<Collapsible.Content>
|
|
311
318
|
<div class="vela:flex vela:flex-col vela:gap-3 vela:mt-3">
|
|
312
319
|
{#each advancedEntries as [key, fieldSchema] (key)}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
checked={booleanValue(local[key])}
|
|
321
|
-
onchange={(e) => (local[key] = e.currentTarget.checked)}
|
|
322
|
-
/>
|
|
323
|
-
<span>{key}</span>
|
|
324
|
-
</label>
|
|
325
|
-
{:else}
|
|
326
|
-
<label class="vela:flex vela:flex-col vela:gap-1.5">
|
|
327
|
-
<span class={fieldLabelClass}>{key}</span>
|
|
328
|
-
{#if t === 'long-string'}
|
|
329
|
-
<Textarea
|
|
330
|
-
value={stringValue(local[key])}
|
|
331
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
332
|
-
/>
|
|
333
|
-
{:else if t === 'number'}
|
|
334
|
-
<Input
|
|
335
|
-
type="number"
|
|
336
|
-
value={numberValue(local[key]) ?? ''}
|
|
337
|
-
oninput={(e) => setNumber(key, e.currentTarget.value)}
|
|
338
|
-
/>
|
|
339
|
-
{:else if t === 'date'}
|
|
340
|
-
<Input
|
|
341
|
-
type="date"
|
|
342
|
-
value={stringValue(local[key])}
|
|
343
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
344
|
-
/>
|
|
345
|
-
{:else if t === 'image'}
|
|
346
|
-
<Input
|
|
347
|
-
type="url"
|
|
348
|
-
placeholder="https://…"
|
|
349
|
-
value={stringValue(local[key])}
|
|
350
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
351
|
-
/>
|
|
352
|
-
{:else if t === 'enum'}
|
|
353
|
-
<select
|
|
354
|
-
class={selectClass}
|
|
355
|
-
value={stringValue(local[key])}
|
|
356
|
-
onchange={(e) => (local[key] = e.currentTarget.value)}
|
|
357
|
-
>
|
|
358
|
-
<option value="">—</option>
|
|
359
|
-
{#each enumValues(fieldSchema) as v (v)}
|
|
360
|
-
<option value={v}>{v}</option>
|
|
361
|
-
{/each}
|
|
362
|
-
</select>
|
|
363
|
-
{:else}
|
|
364
|
-
<Input
|
|
365
|
-
type="text"
|
|
366
|
-
value={stringValue(local[key])}
|
|
367
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
368
|
-
/>
|
|
369
|
-
{/if}
|
|
370
|
-
</label>
|
|
371
|
-
{/if}
|
|
320
|
+
<FieldInput
|
|
321
|
+
type={inputType(fieldSchema)}
|
|
322
|
+
label={labelFor(key)}
|
|
323
|
+
values={enumValues(fieldSchema)}
|
|
324
|
+
value={local[key]}
|
|
325
|
+
onChange={(v) => (local[key] = v)}
|
|
326
|
+
/>
|
|
372
327
|
{/each}
|
|
373
328
|
</div>
|
|
374
329
|
</Collapsible.Content>
|