@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.
Files changed (205) hide show
  1. package/README.md +140 -28
  2. package/dist/backend/index.d.ts +1 -1
  3. package/dist/backend/routes/seed.d.ts +6 -0
  4. package/dist/backend/routes/seed.js +96 -0
  5. package/dist/backend/routes/table.js +3 -0
  6. package/dist/backend/store/queries.d.ts +6 -1
  7. package/dist/backend/store/queries.js +89 -0
  8. package/dist/cms.js +13 -16
  9. package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
  10. package/dist/components/admin-bar/admin-bar.css +1 -1
  11. package/dist/components/admin-bar/field-input.svelte +239 -0
  12. package/dist/components/admin-bar/field-input.svelte.d.ts +30 -0
  13. package/dist/components/admin-bar/fields-form.svelte +218 -0
  14. package/dist/components/admin-bar/fields-form.svelte.d.ts +16 -0
  15. package/dist/components/admin-bar/icons/pencil.svelte +19 -0
  16. package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
  17. package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
  18. package/dist/components/admin-bar/link-input.svelte +102 -0
  19. package/dist/components/admin-bar/link-input.svelte.d.ts +9 -0
  20. package/dist/components/admin-bar/list-field.svelte +93 -0
  21. package/dist/components/admin-bar/list-field.svelte.d.ts +20 -0
  22. package/dist/components/admin-bar/locales-panel.svelte +42 -1
  23. package/dist/components/admin-bar/page-config.d.ts +8 -1
  24. package/dist/components/admin-bar/page-config.js +15 -2
  25. package/dist/components/admin-bar/page-options.d.ts +24 -0
  26. package/dist/components/admin-bar/page-options.js +53 -0
  27. package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
  28. package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
  29. package/dist/components/admin-bar/rich-text-input.svelte +92 -0
  30. package/dist/components/admin-bar/rich-text-input.svelte.d.ts +9 -0
  31. package/dist/components/admin-bar/seo-panel.svelte +144 -189
  32. package/dist/components/admin-bar/site-options-panel.svelte +115 -0
  33. package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
  34. package/dist/components/admin-bar/structured-editor-popover.svelte +259 -0
  35. package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
  36. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
  37. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
  38. package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
  39. package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
  40. package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
  41. package/dist/components/admin-bar/ui/popover/index.js +7 -0
  42. package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
  43. package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
  44. package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
  45. package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
  46. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
  47. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
  48. package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
  49. package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
  50. package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
  51. package/dist/components/admin-bar/ui/sheet/index.js +12 -0
  52. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
  53. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
  54. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
  55. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
  56. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
  57. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
  58. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
  59. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
  60. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
  61. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
  62. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
  63. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
  64. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
  65. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
  66. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
  67. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
  68. package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
  69. package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
  70. package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
  71. package/dist/components/admin-bar/ui/switch/index.js +2 -0
  72. package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
  73. package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
  74. package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
  75. package/dist/components/admin-bar/ui/tabs/index.js +7 -0
  76. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
  77. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
  78. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
  79. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
  80. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
  81. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
  82. package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
  83. package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
  84. package/dist/components/cms/cms-boolean.svelte +9 -18
  85. package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
  86. package/dist/components/cms/cms-collection.svelte +46 -0
  87. package/dist/components/cms/cms-collection.svelte.d.ts +25 -0
  88. package/dist/components/cms/cms-contact.svelte +26 -0
  89. package/dist/components/cms/cms-contact.svelte.d.ts +14 -0
  90. package/dist/components/cms/cms-date-time-editable.svelte +3 -3
  91. package/dist/components/cms/cms-date-time.svelte +17 -17
  92. package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
  93. package/dist/components/cms/cms-faq.svelte +22 -0
  94. package/dist/components/cms/cms-faq.svelte.d.ts +15 -0
  95. package/dist/components/cms/cms-gallery.svelte +31 -0
  96. package/dist/components/cms/cms-gallery.svelte.d.ts +16 -0
  97. package/dist/components/cms/cms-hours-editable.svelte +160 -0
  98. package/dist/components/cms/cms-hours-editable.svelte.d.ts +17 -0
  99. package/dist/components/cms/cms-hours.svelte +42 -0
  100. package/dist/components/cms/cms-hours.svelte.d.ts +19 -0
  101. package/dist/components/cms/cms-image-editable.svelte +13 -4
  102. package/dist/components/cms/cms-image.svelte +19 -34
  103. package/dist/components/cms/cms-image.svelte.d.ts +7 -8
  104. package/dist/components/cms/cms-link-editable.svelte +4 -60
  105. package/dist/components/cms/cms-link.svelte +15 -28
  106. package/dist/components/cms/cms-link.svelte.d.ts +4 -9
  107. package/dist/components/cms/cms-logos.svelte +29 -0
  108. package/dist/components/cms/cms-logos.svelte.d.ts +16 -0
  109. package/dist/components/cms/cms-markdown.svelte +15 -16
  110. package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
  111. package/dist/components/cms/cms-nav.svelte +22 -0
  112. package/dist/components/cms/cms-nav.svelte.d.ts +15 -0
  113. package/dist/components/cms/cms-number-editable.svelte +4 -2
  114. package/dist/components/cms/cms-number.svelte +18 -17
  115. package/dist/components/cms/cms-number.svelte.d.ts +2 -1
  116. package/dist/components/cms/cms-pricing.svelte +36 -0
  117. package/dist/components/cms/cms-pricing.svelte.d.ts +14 -0
  118. package/dist/components/cms/cms-rich-text.svelte +17 -22
  119. package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
  120. package/dist/components/cms/cms-schedule.svelte +35 -0
  121. package/dist/components/cms/cms-schedule.svelte.d.ts +16 -0
  122. package/dist/components/cms/cms-social-links.svelte +35 -0
  123. package/dist/components/cms/cms-social-links.svelte.d.ts +15 -0
  124. package/dist/components/cms/cms-stats.svelte +31 -0
  125. package/dist/components/cms/cms-stats.svelte.d.ts +16 -0
  126. package/dist/components/cms/cms-steps.svelte +31 -0
  127. package/dist/components/cms/cms-steps.svelte.d.ts +16 -0
  128. package/dist/components/cms/cms-store.svelte.d.ts +14 -0
  129. package/dist/components/cms/cms-store.svelte.js +39 -2
  130. package/dist/components/cms/cms-structured-editable.svelte +29 -0
  131. package/dist/components/cms/{cms-repeater-editable.svelte.d.ts → cms-structured-editable.svelte.d.ts} +12 -8
  132. package/dist/components/cms/cms-structured.svelte +50 -0
  133. package/dist/components/cms/cms-structured.svelte.d.ts +49 -0
  134. package/dist/components/cms/cms-team.svelte +22 -0
  135. package/dist/components/cms/cms-team.svelte.d.ts +15 -0
  136. package/dist/components/cms/cms-testimonials.svelte +36 -0
  137. package/dist/components/cms/cms-testimonials.svelte.d.ts +15 -0
  138. package/dist/components/cms/cms-text.svelte +23 -30
  139. package/dist/components/cms/cms-text.svelte.d.ts +3 -1
  140. package/dist/components/cms/cms-timeline.svelte +35 -0
  141. package/dist/components/cms/cms-timeline.svelte.d.ts +16 -0
  142. package/dist/components/cms/index.d.ts +15 -1
  143. package/dist/components/cms/index.js +15 -1
  144. package/dist/components/cms/scope.d.ts +25 -5
  145. package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
  146. package/dist/components/cms/use-cms-field.svelte.js +83 -0
  147. package/dist/core/metadata.d.ts +44 -0
  148. package/dist/core/metadata.js +59 -0
  149. package/dist/core/shapes/collection.d.ts +60 -0
  150. package/dist/core/shapes/collection.js +194 -0
  151. package/dist/core/shapes/contact.d.ts +70 -0
  152. package/dist/core/shapes/contact.js +214 -0
  153. package/dist/core/shapes/faq.d.ts +12 -0
  154. package/dist/core/shapes/faq.js +48 -0
  155. package/dist/core/shapes/form.d.ts +46 -0
  156. package/dist/core/shapes/form.js +7 -0
  157. package/dist/core/shapes/hours.d.ts +91 -0
  158. package/dist/core/shapes/hours.js +325 -0
  159. package/dist/core/shapes/image.d.ts +15 -0
  160. package/dist/core/shapes/image.js +27 -0
  161. package/dist/core/shapes/index.d.ts +31 -0
  162. package/dist/core/shapes/index.js +13 -0
  163. package/dist/core/shapes/link.d.ts +15 -0
  164. package/dist/core/shapes/link.js +42 -0
  165. package/dist/core/shapes/nav.d.ts +21 -0
  166. package/dist/core/shapes/nav.js +71 -0
  167. package/dist/core/shapes/presets.d.ts +65 -0
  168. package/dist/core/shapes/presets.js +152 -0
  169. package/dist/core/shapes/pricing.d.ts +40 -0
  170. package/dist/core/shapes/pricing.js +115 -0
  171. package/dist/core/shapes/social-links.d.ts +19 -0
  172. package/dist/core/shapes/social-links.js +83 -0
  173. package/dist/core/shapes/team.d.ts +22 -0
  174. package/dist/core/shapes/team.js +70 -0
  175. package/dist/core/shapes/testimonials.d.ts +24 -0
  176. package/dist/core/shapes/testimonials.js +86 -0
  177. package/dist/core/structured-registry.d.ts +10 -0
  178. package/dist/core/structured-registry.js +6 -0
  179. package/dist/core/structured.d.ts +131 -0
  180. package/dist/core/structured.js +235 -0
  181. package/dist/core/wire.d.ts +31 -1
  182. package/dist/editor.d.ts +26 -0
  183. package/dist/editor.js +25 -0
  184. package/dist/index.d.ts +26 -4
  185. package/dist/index.js +31 -2
  186. package/dist/server/load-cms.js +1 -0
  187. package/package.json +12 -3
  188. package/plugin/README.md +22 -2
  189. package/plugin/dist/build.d.ts +74 -0
  190. package/plugin/dist/build.js +210 -0
  191. package/plugin/dist/build.js.map +1 -0
  192. package/plugin/dist/index.d.ts +10 -1
  193. package/plugin/dist/index.js +63 -4
  194. package/plugin/dist/index.js.map +1 -1
  195. package/plugin/dist/manifest.d.ts +31 -0
  196. package/plugin/dist/manifest.js +146 -41
  197. package/plugin/dist/manifest.js.map +1 -1
  198. package/plugin/dist/parse-svelte.d.ts +15 -0
  199. package/plugin/dist/parse-svelte.js +26 -16
  200. package/plugin/dist/parse-svelte.js.map +1 -1
  201. package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
  202. package/dist/components/admin-bar/site-settings-panel.svelte.d.ts +0 -7
  203. package/dist/components/cms/cms-repeater-editable.svelte +0 -137
  204. package/dist/components/cms/cms-repeater.svelte +0 -55
  205. 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 { Input } from './ui/input/index.js';
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
- // Schema lives in the route's `page.cms.ts`. Fall back to default
39
- // title/description so every route gets at least the SEO basics.
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
- const cfg = pages[pageScope.routeId];
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 advancedEntries = $derived(entries.filter(([k]) => k !== 'title' && k !== 'description'));
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
- // Local form state — typing only updates `local`; nothing reaches
55
- // cmsStore.metadataDrafts until Save fires. That keeps the bar's "X page
56
- // draft" badge from flickering as the user explores.
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 titleVal = $derived(typeof local.title === 'string' ? local.title : '');
66
- const descVal = $derived(typeof local.description === 'string' ? local.description : '');
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<'search' | 'social'>('search');
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. Resolve
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 stringValue = (v: unknown): string => (typeof v === 'string' ? v : '');
129
- const numberValue = (v: unknown): number | null =>
130
- typeof v === 'number' && !Number.isNaN(v) ? v : null;
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 &amp; metadata" {onClose} />
153
+ <PanelHeader title="SEO &amp; 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
- <div class="vela:flex vela:flex-col vela:gap-2">
162
- <div
163
- role="tablist"
164
- aria-label="Preview format"
165
- class="vela:inline-flex vela:self-start vela:p-0.5 vela:rounded-md
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
- {#if previewTab === 'search'}
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
- {titleVal || 'Page title'}
201
+ {fullTitle || 'Page title'}
219
202
  </div>
220
203
  <div class="vela:mt-1 vela:text-[13px] vela:leading-snug vela:text-[#4d5156]">
221
- {descVal || 'Meta description appears here.'}
204
+ {preview.description || 'Meta description appears here.'}
222
205
  </div>
223
206
  </div>
224
- {:else}
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 ogImageVal}
233
- <img src={ogImageVal} alt="" class="vela:w-full vela:h-full vela:object-cover" />
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 social image set</span>
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
- {titleVal || 'Page title'}
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
- {descVal || 'Meta description appears here.'}
241
+ {preview.openGraph?.description || 'Meta description appears here.'}
253
242
  </div>
254
243
  </div>
255
244
  </div>
256
- {/if}
257
- </div>
245
+ </Tabs.Content>
246
+ </Tabs.Root>
258
247
 
259
248
  {#if 'title' in schema}
260
- <label class="vela:flex vela:flex-col vela:gap-1.5">
261
- <div class="vela:flex vela:items-center vela:justify-between">
262
- <span class={fieldLabelClass}>Title</span>
263
- <span
264
- class="{counterBaseClass} {counterClass(
265
- titleVal.length,
266
- TITLE_TARGET,
267
- TITLE_AMBER_AT
268
- )}"
269
- >
270
- {titleVal.length} / {TITLE_TARGET}
271
- </span>
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
- <label class="vela:flex vela:flex-col vela:gap-1.5">
283
- <div class="vela:flex vela:items-center vela:justify-between">
284
- <span class={fieldLabelClass}>Description</span>
285
- <span
286
- class="{counterBaseClass} {counterClass(descVal.length, DESC_TARGET, DESC_AMBER_AT)}"
287
- >
288
- {descVal.length} / {DESC_TARGET}
289
- </span>
290
- </div>
291
- <Textarea
292
- value={stringValue(local.description)}
293
- oninput={(e) => (local.description = e.currentTarget.value)}
294
- />
295
- </label>
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
- {@const t = fieldType(fieldSchema)}
314
- {#if t === 'boolean'}
315
- <label
316
- class="vela:flex vela:items-center vela:gap-2 vela:text-[13px] vela:text-bar-text"
317
- >
318
- <input
319
- type="checkbox"
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>