@velastack/cms 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +104 -47
  2. package/dist/backend/index.d.ts +7 -5
  3. package/dist/backend/index.js +7 -5
  4. package/dist/backend/types.d.ts +1 -1
  5. package/dist/components/admin-bar/admin-bar-internal.svelte +54 -59
  6. package/dist/components/admin-bar/admin-bar.css +1 -1
  7. package/dist/components/admin-bar/admin-bar.svelte +11 -7
  8. package/dist/components/admin-bar/field-input.svelte +20 -1
  9. package/dist/components/admin-bar/field-input.svelte.d.ts +2 -1
  10. package/dist/components/admin-bar/fields-form.svelte +218 -0
  11. package/dist/components/admin-bar/fields-form.svelte.d.ts +16 -0
  12. package/dist/components/admin-bar/history-panel.svelte +4 -4
  13. package/dist/components/admin-bar/link-input.svelte +102 -0
  14. package/dist/components/admin-bar/link-input.svelte.d.ts +9 -0
  15. package/dist/components/admin-bar/list-field.svelte +93 -0
  16. package/dist/components/admin-bar/list-field.svelte.d.ts +20 -0
  17. package/dist/components/admin-bar/locales-panel.svelte +1 -1
  18. package/dist/components/admin-bar/page-options.d.ts +24 -0
  19. package/dist/components/admin-bar/page-options.js +53 -0
  20. package/dist/components/admin-bar/pages-panel.svelte +1 -1
  21. package/dist/components/admin-bar/rich-text-input.svelte +92 -0
  22. package/dist/components/admin-bar/rich-text-input.svelte.d.ts +9 -0
  23. package/dist/components/admin-bar/structured-editor-popover.svelte +15 -6
  24. package/dist/components/admin-bar/theme.svelte.js +1 -1
  25. package/dist/components/cms/cms-collection.svelte +46 -0
  26. package/dist/components/cms/cms-collection.svelte.d.ts +25 -0
  27. package/dist/components/cms/cms-contact.svelte +26 -0
  28. package/dist/components/cms/cms-contact.svelte.d.ts +14 -0
  29. package/dist/components/cms/cms-faq.svelte +22 -0
  30. package/dist/components/cms/cms-faq.svelte.d.ts +15 -0
  31. package/dist/components/cms/cms-gallery.svelte +31 -0
  32. package/dist/components/cms/cms-gallery.svelte.d.ts +16 -0
  33. package/dist/components/cms/cms-hours-editable.svelte +160 -0
  34. package/dist/components/cms/cms-hours-editable.svelte.d.ts +17 -0
  35. package/dist/components/cms/cms-hours.svelte +42 -0
  36. package/dist/components/cms/cms-hours.svelte.d.ts +19 -0
  37. package/dist/components/cms/cms-image.svelte +3 -25
  38. package/dist/components/cms/cms-image.svelte.d.ts +3 -11
  39. package/dist/components/cms/cms-link-editable.svelte +4 -60
  40. package/dist/components/cms/cms-link.svelte +3 -23
  41. package/dist/components/cms/cms-link.svelte.d.ts +3 -10
  42. package/dist/components/cms/cms-logos.svelte +29 -0
  43. package/dist/components/cms/cms-logos.svelte.d.ts +16 -0
  44. package/dist/components/cms/cms-nav.svelte +22 -0
  45. package/dist/components/cms/cms-nav.svelte.d.ts +15 -0
  46. package/dist/components/cms/cms-pricing.svelte +36 -0
  47. package/dist/components/cms/cms-pricing.svelte.d.ts +14 -0
  48. package/dist/components/cms/cms-schedule.svelte +35 -0
  49. package/dist/components/cms/cms-schedule.svelte.d.ts +16 -0
  50. package/dist/components/cms/cms-social-links.svelte +35 -0
  51. package/dist/components/cms/cms-social-links.svelte.d.ts +15 -0
  52. package/dist/components/cms/cms-stats.svelte +31 -0
  53. package/dist/components/cms/cms-stats.svelte.d.ts +16 -0
  54. package/dist/components/cms/cms-steps.svelte +31 -0
  55. package/dist/components/cms/cms-steps.svelte.d.ts +16 -0
  56. package/dist/components/cms/cms-store.svelte.js +8 -5
  57. package/dist/components/cms/cms-structured-editable.svelte +29 -0
  58. package/dist/components/cms/{cms-repeater-editable.svelte.d.ts → cms-structured-editable.svelte.d.ts} +12 -8
  59. package/dist/components/cms/cms-structured.svelte +50 -0
  60. package/dist/components/cms/cms-structured.svelte.d.ts +49 -0
  61. package/dist/components/cms/cms-team.svelte +22 -0
  62. package/dist/components/cms/cms-team.svelte.d.ts +15 -0
  63. package/dist/components/cms/cms-testimonials.svelte +36 -0
  64. package/dist/components/cms/cms-testimonials.svelte.d.ts +15 -0
  65. package/dist/components/cms/cms-timeline.svelte +35 -0
  66. package/dist/components/cms/cms-timeline.svelte.d.ts +16 -0
  67. package/dist/components/cms/index.d.ts +15 -1
  68. package/dist/components/cms/index.js +15 -1
  69. package/dist/core/cms-fetch.d.ts +16 -0
  70. package/dist/core/cms-fetch.js +24 -0
  71. package/dist/core/shapes/collection.d.ts +60 -0
  72. package/dist/core/shapes/collection.js +194 -0
  73. package/dist/core/shapes/contact.d.ts +70 -0
  74. package/dist/core/shapes/contact.js +214 -0
  75. package/dist/core/shapes/faq.d.ts +12 -0
  76. package/dist/core/shapes/faq.js +48 -0
  77. package/dist/core/shapes/form.d.ts +46 -0
  78. package/dist/core/shapes/form.js +7 -0
  79. package/dist/core/shapes/hours.d.ts +91 -0
  80. package/dist/core/shapes/hours.js +325 -0
  81. package/dist/core/shapes/image.d.ts +15 -0
  82. package/dist/core/shapes/image.js +27 -0
  83. package/dist/core/shapes/index.d.ts +31 -0
  84. package/dist/core/shapes/index.js +13 -0
  85. package/dist/core/shapes/link.d.ts +15 -0
  86. package/dist/core/shapes/link.js +42 -0
  87. package/dist/core/shapes/nav.d.ts +21 -0
  88. package/dist/core/shapes/nav.js +71 -0
  89. package/dist/core/shapes/presets.d.ts +65 -0
  90. package/dist/core/shapes/presets.js +152 -0
  91. package/dist/core/shapes/pricing.d.ts +40 -0
  92. package/dist/core/shapes/pricing.js +115 -0
  93. package/dist/core/shapes/social-links.d.ts +19 -0
  94. package/dist/core/shapes/social-links.js +83 -0
  95. package/dist/core/shapes/team.d.ts +22 -0
  96. package/dist/core/shapes/team.js +70 -0
  97. package/dist/core/shapes/testimonials.d.ts +24 -0
  98. package/dist/core/shapes/testimonials.js +86 -0
  99. package/dist/{components/cms → core}/structured-registry.d.ts +2 -2
  100. package/dist/core/structured.d.ts +33 -10
  101. package/dist/core/structured.js +98 -47
  102. package/dist/editor.d.ts +4 -0
  103. package/dist/editor.js +4 -0
  104. package/dist/index.d.ts +22 -8
  105. package/dist/index.js +24 -5
  106. package/dist/server/README.md +1 -1
  107. package/dist/server/api-adapter.js +1 -1
  108. package/dist/server/cms.d.ts +1 -1
  109. package/dist/server/cms.js +1 -1
  110. package/dist/server/load-cms.js +1 -1
  111. package/package.json +15 -11
  112. package/plugin/README.md +34 -4
  113. package/plugin/dist/adapter.d.ts +5 -5
  114. package/plugin/dist/adapter.js +21 -7
  115. package/plugin/dist/adapter.js.map +1 -1
  116. package/plugin/dist/build-state.d.ts +1 -0
  117. package/plugin/dist/build-state.js +10 -1
  118. package/plugin/dist/build-state.js.map +1 -1
  119. package/plugin/dist/build.d.ts +2 -2
  120. package/plugin/dist/build.js +8 -7
  121. package/plugin/dist/build.js.map +1 -1
  122. package/plugin/dist/index.js +5 -1
  123. package/plugin/dist/index.js.map +1 -1
  124. package/plugin/dist/manifest.d.ts +18 -0
  125. package/plugin/dist/manifest.js +78 -10
  126. package/plugin/dist/manifest.js.map +1 -1
  127. package/plugin/dist/transforms.d.ts +4 -4
  128. package/plugin/dist/transforms.js +4 -4
  129. package/dist/cms.d.ts +0 -6
  130. package/dist/cms.js +0 -27
  131. package/dist/components/cms/cms-repeater-editable.svelte +0 -137
  132. package/dist/components/cms/cms-repeater.svelte +0 -52
  133. package/dist/components/cms/cms-repeater.svelte.d.ts +0 -31
  134. /package/dist/{components/cms → core}/structured-registry.js +0 -0
@@ -0,0 +1,194 @@
1
+ import { asBoolean, asItems, asNumber, asString, asStrings, isPlainObject } from '../structured.js';
2
+ import { defineForm } from './form.js';
3
+ import { asImage, asImages } from './image.js';
4
+ import { asLink, linkHref } from './link.js';
5
+ export const cmsCollection = defineForm({
6
+ component: 'CmsCollection',
7
+ label: 'Collection',
8
+ version: 1,
9
+ translatable: [],
10
+ items: {
11
+ key: 'items',
12
+ translatable: [
13
+ 'title',
14
+ 'summary',
15
+ 'body',
16
+ 'duration',
17
+ 'tags',
18
+ 'features',
19
+ 'price.period',
20
+ 'price.prefix'
21
+ ],
22
+ items: { key: 'details', translatable: ['label', 'value'] }
23
+ },
24
+ normalize: (raw) => {
25
+ const items = Array.isArray(raw) ? raw : isPlainObject(raw) ? raw.items : [];
26
+ return {
27
+ v: 1,
28
+ items: asItems(items, (r) => {
29
+ const p = isPlainObject(r.price) ? r.price : {};
30
+ return {
31
+ title: asString(r.title),
32
+ summary: asString(r.summary),
33
+ body: asString(r.body),
34
+ image: asImage(r.image),
35
+ gallery: asImages(r.gallery),
36
+ price: {
37
+ amount: asNumber(p.amount),
38
+ currency: asString(p.currency),
39
+ period: asString(p.period),
40
+ prefix: asString(p.prefix)
41
+ },
42
+ duration: asString(r.duration),
43
+ link: asLink(r.link),
44
+ tags: asStrings(r.tags).filter((t) => t !== ''),
45
+ features: asStrings(r.features).filter((f) => f !== ''),
46
+ featured: asBoolean(r.featured),
47
+ hidden: asBoolean(r.hidden),
48
+ details: asItems(r.details, (d) => ({
49
+ label: asString(d.label),
50
+ value: asString(d.value)
51
+ }))
52
+ };
53
+ })
54
+ };
55
+ },
56
+ empty: () => ({ v: 1, items: [] }),
57
+ fields: [
58
+ {
59
+ key: 'items',
60
+ label: 'Items',
61
+ type: 'list',
62
+ itemLabel: 'item',
63
+ titleKey: 'title',
64
+ blank: () => ({
65
+ title: '',
66
+ summary: '',
67
+ body: '',
68
+ image: null,
69
+ gallery: [],
70
+ price: { amount: null, currency: '', period: '', prefix: '' },
71
+ duration: '',
72
+ link: null,
73
+ tags: [],
74
+ features: [],
75
+ featured: false,
76
+ hidden: false,
77
+ details: []
78
+ }),
79
+ fields: [
80
+ { key: 'title', label: 'Title', type: 'text', group: 'Details' },
81
+ { key: 'summary', label: 'Summary', type: 'long-string', group: 'Details' },
82
+ { key: 'body', label: 'Body', type: 'html', group: 'Details' },
83
+ {
84
+ key: 'duration',
85
+ label: 'Duration',
86
+ type: 'text',
87
+ half: true,
88
+ placeholder: '3 hours',
89
+ group: 'Details'
90
+ },
91
+ { key: 'link', label: 'Link', type: 'link', group: 'Details' },
92
+ {
93
+ key: 'tags',
94
+ label: 'Tags',
95
+ type: 'strings',
96
+ placeholder: 'One per line',
97
+ group: 'Details'
98
+ },
99
+ {
100
+ key: 'features',
101
+ label: 'Features',
102
+ type: 'strings',
103
+ placeholder: 'One per line',
104
+ group: 'Details'
105
+ },
106
+ {
107
+ key: 'details',
108
+ label: 'Details',
109
+ type: 'list',
110
+ itemLabel: 'detail',
111
+ titleKey: 'label',
112
+ group: 'Details',
113
+ blank: () => ({ label: '', value: '' }),
114
+ fields: [
115
+ { key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Sleeps' },
116
+ { key: 'value', label: 'Value', type: 'text', placeholder: '4' }
117
+ ]
118
+ },
119
+ { key: 'featured', label: 'Featured', type: 'boolean', group: 'Details' },
120
+ { key: 'hidden', label: 'Hidden', type: 'boolean', group: 'Details' },
121
+ { key: 'image', label: 'Image', type: 'image', group: 'Media' },
122
+ { key: 'gallery', label: 'Gallery', type: 'images', group: 'Media' },
123
+ {
124
+ key: 'price.prefix',
125
+ label: 'Prefix',
126
+ type: 'text',
127
+ half: true,
128
+ placeholder: 'from',
129
+ group: 'Pricing'
130
+ },
131
+ { key: 'price.amount', label: 'Amount', type: 'number', group: 'Pricing' },
132
+ {
133
+ key: 'price.currency',
134
+ label: 'Currency',
135
+ type: 'text',
136
+ half: true,
137
+ placeholder: 'EUR',
138
+ group: 'Pricing'
139
+ },
140
+ { key: 'price.period', label: 'Per', type: 'text', placeholder: 'night', group: 'Pricing' }
141
+ ]
142
+ }
143
+ ]
144
+ });
145
+ /** Visible items (not hidden, with a title), optionally sliced. */
146
+ export const collectionView = (value, slice = {}) => {
147
+ let items = value.items.filter((i) => !i.hidden && i.title !== '');
148
+ if (slice.featured)
149
+ items = items.filter((i) => i.featured);
150
+ if (slice.tag)
151
+ items = items.filter((i) => i.tags.includes(slice.tag));
152
+ if (slice.limit !== undefined && slice.limit >= 0)
153
+ items = items.slice(0, slice.limit);
154
+ return items.map((i) => ({ ...i, href: i.link ? linkHref(i.link) || null : null }));
155
+ };
156
+ /** Every tag in use, in first-seen order. */
157
+ export const collectionTags = (value) => {
158
+ const out = [];
159
+ for (const item of value.items)
160
+ for (const t of item.tags)
161
+ if (!out.includes(t))
162
+ out.push(t);
163
+ return out;
164
+ };
165
+ /** `from €120 / night`, or empty when there is no amount. */
166
+ export const formatCollectionPrice = (price, locale) => {
167
+ if (price.amount === null)
168
+ return '';
169
+ let amount;
170
+ try {
171
+ amount = price.currency
172
+ ? new Intl.NumberFormat(locale, {
173
+ style: 'currency',
174
+ currency: price.currency,
175
+ maximumFractionDigits: Number.isInteger(price.amount) ? 0 : 2
176
+ }).format(price.amount)
177
+ : new Intl.NumberFormat(locale).format(price.amount);
178
+ }
179
+ catch {
180
+ amount = `${price.currency} ${price.amount}`.trim();
181
+ }
182
+ return [price.prefix, amount, price.period ? `/ ${price.period}` : ''].filter(Boolean).join(' ');
183
+ };
184
+ /** A schema.org `ItemList` of the visible items. */
185
+ export const toItemList = (value, baseUrl = '') => ({
186
+ '@context': 'https://schema.org',
187
+ '@type': 'ItemList',
188
+ itemListElement: collectionView(value).map((i, n) => {
189
+ const item = { '@type': 'ListItem', position: n + 1, name: i.title };
190
+ if (i.href)
191
+ item.url = baseUrl + i.href;
192
+ return item;
193
+ })
194
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `CmsContact`: where and how to reach the business. Labels translate;
3
+ * addresses and numbers do not. `contactView` adds the hrefs and the
4
+ * formatted address a template renders.
5
+ */
6
+ import type { Tree } from '../path.js';
7
+ export type ContactPhone = {
8
+ id: string;
9
+ label: string;
10
+ number: string;
11
+ };
12
+ export type ContactEmail = {
13
+ id: string;
14
+ label: string;
15
+ address: string;
16
+ };
17
+ export type ContactAddress = {
18
+ lines: string[];
19
+ locality: string;
20
+ region: string;
21
+ postcode: string;
22
+ country: string;
23
+ };
24
+ export type ContactLabels = {
25
+ address: string;
26
+ phone: string;
27
+ email: string;
28
+ whatsapp: string;
29
+ directions: string;
30
+ };
31
+ export type CmsContactValue = {
32
+ v: 1;
33
+ name: string;
34
+ address: ContactAddress;
35
+ phones: ContactPhone[];
36
+ emails: ContactEmail[];
37
+ whatsapp: string;
38
+ directionsUrl: string;
39
+ geo: {
40
+ lat: number;
41
+ lng: number;
42
+ } | null;
43
+ labels: ContactLabels;
44
+ };
45
+ export declare const DEFAULT_CONTACT_LABELS: ContactLabels;
46
+ export declare const cmsContact: import("./form.js").FormShape<CmsContactValue>;
47
+ export declare const telHref: (number: string) => string;
48
+ export declare const whatsappHref: (number: string) => string;
49
+ /** Address lines in the country's conventional order, empty parts dropped. */
50
+ export declare const formatAddress: (address: ContactAddress) => string[];
51
+ export type ContactView = Omit<CmsContactValue, 'phones' | 'emails'> & {
52
+ phones: Array<ContactPhone & {
53
+ href: string;
54
+ }>;
55
+ emails: Array<ContactEmail & {
56
+ href: string;
57
+ }>;
58
+ /** First phone's `tel:` href, or `null`. */
59
+ telHref: string | null;
60
+ /** First email's `mailto:`, or `null`. */
61
+ mailto: string | null;
62
+ whatsappHref: string | null;
63
+ /** `directionsUrl`, else a Google Maps link from `geo` or the address. */
64
+ mapsHref: string | null;
65
+ formattedAddress: string[];
66
+ hasAddress: boolean;
67
+ };
68
+ export declare const contactView: (value: CmsContactValue) => ContactView;
69
+ /** A schema.org `PostalAddress` node, or `null` without an address. */
70
+ export declare const toPostalAddress: (value: CmsContactValue) => Tree | null;
@@ -0,0 +1,214 @@
1
+ import { asItems, asNumber, asString, asStrings, isPlainObject } from '../structured.js';
2
+ import { defineForm } from './form.js';
3
+ export const DEFAULT_CONTACT_LABELS = {
4
+ address: 'Address',
5
+ phone: 'Phone',
6
+ email: 'Email',
7
+ whatsapp: 'WhatsApp',
8
+ directions: 'Directions'
9
+ };
10
+ export const cmsContact = defineForm({
11
+ component: 'CmsContact',
12
+ label: 'Contact details',
13
+ version: 1,
14
+ translatable: [
15
+ 'labels.address',
16
+ 'labels.phone',
17
+ 'labels.email',
18
+ 'labels.whatsapp',
19
+ 'labels.directions'
20
+ ],
21
+ items: [
22
+ { key: 'phones', translatable: ['label'] },
23
+ { key: 'emails', translatable: ['label'] }
24
+ ],
25
+ normalize: (raw) => {
26
+ const r = isPlainObject(raw) ? raw : {};
27
+ const a = isPlainObject(r.address) ? r.address : {};
28
+ const g = isPlainObject(r.geo) ? r.geo : null;
29
+ const lat = g ? asNumber(g.lat) : null;
30
+ const lng = g ? asNumber(g.lng) : null;
31
+ const labels = isPlainObject(r.labels) ? r.labels : {};
32
+ return {
33
+ v: 1,
34
+ name: asString(r.name),
35
+ address: {
36
+ lines: asStrings(a.lines).filter((l) => l !== ''),
37
+ locality: asString(a.locality),
38
+ region: asString(a.region),
39
+ postcode: asString(a.postcode),
40
+ country: asString(a.country)
41
+ },
42
+ phones: asItems(r.phones, (p) => ({
43
+ label: asString(p.label),
44
+ number: asString(p.number)
45
+ })),
46
+ emails: asItems(r.emails, (e) => ({
47
+ label: asString(e.label),
48
+ address: asString(e.address)
49
+ })),
50
+ whatsapp: asString(r.whatsapp),
51
+ directionsUrl: asString(r.directionsUrl),
52
+ geo: lat !== null && lng !== null ? { lat, lng } : null,
53
+ labels: {
54
+ address: asString(labels.address, DEFAULT_CONTACT_LABELS.address),
55
+ phone: asString(labels.phone, DEFAULT_CONTACT_LABELS.phone),
56
+ email: asString(labels.email, DEFAULT_CONTACT_LABELS.email),
57
+ whatsapp: asString(labels.whatsapp, DEFAULT_CONTACT_LABELS.whatsapp),
58
+ directions: asString(labels.directions, DEFAULT_CONTACT_LABELS.directions)
59
+ }
60
+ };
61
+ },
62
+ empty: () => ({
63
+ v: 1,
64
+ name: '',
65
+ address: { lines: [], locality: '', region: '', postcode: '', country: '' },
66
+ phones: [],
67
+ emails: [],
68
+ whatsapp: '',
69
+ directionsUrl: '',
70
+ geo: null,
71
+ labels: { ...DEFAULT_CONTACT_LABELS }
72
+ }),
73
+ fields: [
74
+ { key: 'name', label: 'Business name', type: 'text' },
75
+ {
76
+ key: 'address.lines',
77
+ label: 'Street address',
78
+ type: 'strings',
79
+ placeholder: 'One line per row'
80
+ },
81
+ { key: 'address.locality', label: 'City', type: 'text', half: true },
82
+ { key: 'address.region', label: 'Region', type: 'text' },
83
+ { key: 'address.postcode', label: 'Postcode', type: 'text', half: true },
84
+ { key: 'address.country', label: 'Country', type: 'text' },
85
+ {
86
+ key: 'phones',
87
+ label: 'Phone numbers',
88
+ type: 'list',
89
+ itemLabel: 'number',
90
+ titleKey: 'label',
91
+ blank: () => ({ label: '', number: '' }),
92
+ fields: [
93
+ { key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Front desk' },
94
+ { key: 'number', label: 'Number', type: 'text', placeholder: '+34 600 000 000' }
95
+ ]
96
+ },
97
+ {
98
+ key: 'emails',
99
+ label: 'Email addresses',
100
+ type: 'list',
101
+ itemLabel: 'address',
102
+ titleKey: 'label',
103
+ blank: () => ({ label: '', address: '' }),
104
+ fields: [
105
+ { key: 'label', label: 'Label', type: 'text', half: true, placeholder: 'Bookings' },
106
+ { key: 'address', label: 'Address', type: 'text', placeholder: 'hello@example.com' }
107
+ ]
108
+ },
109
+ { key: 'whatsapp', label: 'WhatsApp number', type: 'text', group: 'Map & links' },
110
+ { key: 'directionsUrl', label: 'Directions URL', type: 'url', group: 'Map & links' },
111
+ { key: 'geo.lat', label: 'Latitude', type: 'number', half: true, group: 'Map & links' },
112
+ { key: 'geo.lng', label: 'Longitude', type: 'number', group: 'Map & links' },
113
+ { key: 'labels.address', label: '“Address”', type: 'text', half: true, group: 'Labels' },
114
+ { key: 'labels.phone', label: '“Phone”', type: 'text', group: 'Labels' },
115
+ { key: 'labels.email', label: '“Email”', type: 'text', half: true, group: 'Labels' },
116
+ { key: 'labels.whatsapp', label: '“WhatsApp”', type: 'text', group: 'Labels' },
117
+ { key: 'labels.directions', label: '“Directions”', type: 'text', group: 'Labels' }
118
+ ]
119
+ });
120
+ // ---------------------------------------------------------------------------
121
+ // Derived view
122
+ // ---------------------------------------------------------------------------
123
+ export const telHref = (number) => `tel:${number.replace(/[^\d+]/g, '')}`;
124
+ export const whatsappHref = (number) => `https://wa.me/${number.replace(/[^\d]/g, '')}`;
125
+ /** Countries that write the postcode before the locality. Matched on ISO
126
+ * code or English name, case-insensitively. */
127
+ const POSTCODE_FIRST = new Set([
128
+ 'es',
129
+ 'spain',
130
+ 'fr',
131
+ 'france',
132
+ 'de',
133
+ 'germany',
134
+ 'it',
135
+ 'italy',
136
+ 'pt',
137
+ 'portugal',
138
+ 'nl',
139
+ 'netherlands',
140
+ 'be',
141
+ 'belgium',
142
+ 'at',
143
+ 'austria',
144
+ 'ch',
145
+ 'switzerland',
146
+ 'se',
147
+ 'sweden',
148
+ 'no',
149
+ 'norway',
150
+ 'dk',
151
+ 'denmark',
152
+ 'fi',
153
+ 'finland',
154
+ 'pl',
155
+ 'poland',
156
+ 'cz',
157
+ 'czechia',
158
+ 'mx',
159
+ 'mexico'
160
+ ]);
161
+ /** Address lines in the country's conventional order, empty parts dropped. */
162
+ export const formatAddress = (address) => {
163
+ const { lines, locality, region, postcode, country } = address;
164
+ const postcodeFirst = POSTCODE_FIRST.has(country.trim().toLowerCase());
165
+ const cityLine = postcodeFirst
166
+ ? [postcode, locality].filter(Boolean).join(' ') + (region ? ` (${region})` : '')
167
+ : [[locality, region].filter(Boolean).join(', '), postcode].filter(Boolean).join(' ');
168
+ return [...lines, cityLine, country].map((l) => l.trim()).filter((l) => l !== '');
169
+ };
170
+ export const contactView = (value) => {
171
+ const phones = value.phones
172
+ .filter((p) => p.number !== '')
173
+ .map((p) => ({ ...p, href: telHref(p.number) }));
174
+ const emails = value.emails
175
+ .filter((e) => e.address !== '')
176
+ .map((e) => ({ ...e, href: `mailto:${e.address}` }));
177
+ const formattedAddress = formatAddress(value.address);
178
+ let mapsHref = value.directionsUrl || null;
179
+ if (!mapsHref && value.geo) {
180
+ mapsHref = `https://www.google.com/maps/search/?api=1&query=${value.geo.lat},${value.geo.lng}`;
181
+ }
182
+ if (!mapsHref && formattedAddress.length > 0) {
183
+ mapsHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(formattedAddress.join(', '))}`;
184
+ }
185
+ return {
186
+ ...value,
187
+ phones,
188
+ emails,
189
+ telHref: phones[0]?.href ?? null,
190
+ mailto: emails[0]?.href ?? null,
191
+ whatsappHref: value.whatsapp ? whatsappHref(value.whatsapp) : null,
192
+ mapsHref,
193
+ formattedAddress,
194
+ hasAddress: formattedAddress.length > 0
195
+ };
196
+ };
197
+ /** A schema.org `PostalAddress` node, or `null` without an address. */
198
+ export const toPostalAddress = (value) => {
199
+ const a = value.address;
200
+ if (a.lines.length === 0 && !a.locality && !a.postcode && !a.country)
201
+ return null;
202
+ const out = { '@type': 'PostalAddress' };
203
+ if (a.lines.length)
204
+ out.streetAddress = a.lines.join(', ');
205
+ if (a.locality)
206
+ out.addressLocality = a.locality;
207
+ if (a.region)
208
+ out.addressRegion = a.region;
209
+ if (a.postcode)
210
+ out.postalCode = a.postcode;
211
+ if (a.country)
212
+ out.addressCountry = a.country;
213
+ return out;
214
+ };
@@ -0,0 +1,12 @@
1
+ import { type ListValue } from './form.js';
2
+ export type FaqItem = {
3
+ id: string;
4
+ question: string;
5
+ answer: string;
6
+ };
7
+ export type CmsFaqValue = ListValue<FaqItem>;
8
+ export declare const cmsFaq: import("./form.js").FormShape<CmsFaqValue>;
9
+ /** Questions that have both a question and an answer. */
10
+ export declare const faqView: (value: CmsFaqValue) => FaqItem[];
11
+ /** A schema.org `FAQPage` node for `<script type="application/ld+json">`. */
12
+ export declare const toFaqPage: (value: CmsFaqValue) => Record<string, unknown>;
@@ -0,0 +1,48 @@
1
+ /** `CmsFaq`: questions with rich answers, plus FAQPage JSON-LD. */
2
+ import { asItems, asString, isPlainObject } from '../structured.js';
3
+ import { defineForm } from './form.js';
4
+ export const cmsFaq = defineForm({
5
+ component: 'CmsFaq',
6
+ label: 'FAQ',
7
+ version: 1,
8
+ translatable: [],
9
+ items: { key: 'items', translatable: ['question', 'answer'] },
10
+ normalize: (raw) => {
11
+ const items = Array.isArray(raw) ? raw : isPlainObject(raw) ? raw.items : [];
12
+ return {
13
+ v: 1,
14
+ items: asItems(items, (r) => ({
15
+ question: asString(r.question),
16
+ answer: asString(r.answer)
17
+ }))
18
+ };
19
+ },
20
+ empty: () => ({ v: 1, items: [] }),
21
+ fields: [
22
+ {
23
+ key: 'items',
24
+ label: 'Questions',
25
+ type: 'list',
26
+ itemLabel: 'question',
27
+ titleKey: 'question',
28
+ blank: () => ({ question: '', answer: '' }),
29
+ fields: [
30
+ { key: 'question', label: 'Question', type: 'text' },
31
+ { key: 'answer', label: 'Answer', type: 'html' }
32
+ ]
33
+ }
34
+ ]
35
+ });
36
+ const stripTags = (html) => html.replace(/<[^>]*>/g, '').trim();
37
+ /** Questions that have both a question and an answer. */
38
+ export const faqView = (value) => value.items.filter((i) => i.question !== '' && stripTags(i.answer) !== '');
39
+ /** A schema.org `FAQPage` node for `<script type="application/ld+json">`. */
40
+ export const toFaqPage = (value) => ({
41
+ '@context': 'https://schema.org',
42
+ '@type': 'FAQPage',
43
+ mainEntity: faqView(value).map((i) => ({
44
+ '@type': 'Question',
45
+ name: i.question,
46
+ acceptedAnswer: { '@type': 'Answer', text: i.answer }
47
+ }))
48
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What the generic structured editor renders for a shape. A component's
3
+ * schema lists its fields; the popover walks them and draws one input per
4
+ * field. `list` fields hold items (stable `id` each) with their own fields,
5
+ * so a collection item's details or a pricing tier's features are edited in
6
+ * place. Templates never see this: they get the typed value.
7
+ */
8
+ import type { Tree } from '../path.js';
9
+ import { type Structured, type StructuredSchema } from '../structured.js';
10
+ export type FormFieldType = 'text' | 'long-string' | 'html' | 'number' | 'boolean' | 'date' | 'time' | 'url' | 'image' | 'images' | 'enum' | 'link' | 'strings' | 'rating' | 'list';
11
+ export type FormField = {
12
+ /** Dotted path within the object being edited (`name`, `price.amount`). */
13
+ key: string;
14
+ label: string;
15
+ type: FormFieldType;
16
+ placeholder?: string;
17
+ /** `enum`: the stored values, and optional display names. */
18
+ values?: readonly string[];
19
+ names?: Readonly<Record<string, string>>;
20
+ /** Share a row with the next field. */
21
+ half?: boolean;
22
+ /** Tab the field sits in. Fields without a group share the first tab. */
23
+ group?: string;
24
+ /** `list`: what one item is called ("tier"), its fields, a blank item
25
+ * (without `id`) and which field titles a collapsed card. */
26
+ itemLabel?: string;
27
+ fields?: readonly FormField[];
28
+ blank?: () => Tree;
29
+ titleKey?: string;
30
+ };
31
+ export type FormShape<T extends Tree> = Structured<T> & {
32
+ /** Popover header, e.g. "Opening hours". */
33
+ label: string;
34
+ fields: readonly FormField[];
35
+ };
36
+ export declare const defineForm: <T extends Tree>(schema: StructuredSchema<T> & {
37
+ label: string;
38
+ fields: readonly FormField[];
39
+ }) => FormShape<T>;
40
+ /** Shared vocabulary for the list-shaped components: `{ v: 1, items }`. */
41
+ export type ListValue<I extends {
42
+ id: string;
43
+ }> = {
44
+ v: 1;
45
+ items: I[];
46
+ };
@@ -0,0 +1,7 @@
1
+ import { defineStructured } from '../structured.js';
2
+ import { registerStructured } from '../structured-registry.js';
3
+ export const defineForm = (schema) => {
4
+ const { label, fields, ...rest } = schema;
5
+ const structured = registerStructured(defineStructured(rest));
6
+ return { ...structured, label, fields };
7
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `CmsHours`: a week of opening hours in the business's timezone, with
3
+ * holiday exceptions and the labels a template needs around them. The
4
+ * stored value is data only; `hoursView` derives what the template renders
5
+ * (folded rows, today, open-now) in the visitor's locale.
6
+ */
7
+ import type { Tree } from '../path.js';
8
+ export declare const DAY_KEYS: readonly ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
9
+ export type DayKey = (typeof DAY_KEYS)[number];
10
+ /** `HH:MM`, 24-hour. */
11
+ export type HoursRange = {
12
+ open: string;
13
+ close: string;
14
+ };
15
+ export type DayHours = {
16
+ closed: boolean;
17
+ ranges: HoursRange[];
18
+ };
19
+ export type HoursException = {
20
+ id: string;
21
+ /** `YYYY-MM-DD` */
22
+ date: string;
23
+ label: string;
24
+ closed: boolean;
25
+ ranges: HoursRange[];
26
+ };
27
+ export type HoursLabels = {
28
+ closed: string;
29
+ today: string;
30
+ openNow: string;
31
+ closedNow: string;
32
+ };
33
+ export type CmsHoursValue = {
34
+ v: 1;
35
+ /** IANA zone, e.g. `Europe/Madrid`. Empty means the visitor's zone. */
36
+ timezone: string;
37
+ days: Record<DayKey, DayHours>;
38
+ note: string;
39
+ exceptions: HoursException[];
40
+ labels: HoursLabels;
41
+ };
42
+ export declare const DEFAULT_HOURS_LABELS: HoursLabels;
43
+ export declare const cmsHours: import("./form.js").FormShape<CmsHoursValue>;
44
+ /** `09:00` → `9:00 AM` (en) / `9:00` (es). */
45
+ export declare const formatTime: (hhmm: string, locale: string) => string;
46
+ /** `9:00 AM – 5:00 PM, 6:00 – 10:00 PM`, or the closed label. */
47
+ export declare const formatRanges: (ranges: HoursRange[], locale: string, closedLabel: string) => string;
48
+ /** Short weekday names in the visitor's locale, Monday first. */
49
+ export declare const dayNames: (locale: string, weekday?: "short" | "long") => Record<DayKey, string>;
50
+ /** The current weekday, date and minute in `timezone` (the visitor's zone when empty or invalid). */
51
+ export declare const zonedNow: (timezone: string, now?: Date) => {
52
+ key: DayKey;
53
+ date: string;
54
+ minutes: number;
55
+ };
56
+ export type HoursRow = {
57
+ id: string;
58
+ keys: DayKey[];
59
+ /** `Mon – Fri`, `Sat`, `Sat, Sun` */
60
+ days: string;
61
+ /** Formatted ranges, or the closed label. */
62
+ text: string;
63
+ closed: boolean;
64
+ };
65
+ export type HoursToday = {
66
+ key: DayKey;
67
+ date: string;
68
+ /** The value's `labels.today`. */
69
+ label: string;
70
+ /** `Mon` in the visitor's locale. */
71
+ day: string;
72
+ text: string;
73
+ closed: boolean;
74
+ exception: HoursException | null;
75
+ };
76
+ export type HoursExceptionRow = HoursException & {
77
+ dateText: string;
78
+ text: string;
79
+ };
80
+ export type HoursView = CmsHoursValue & {
81
+ rows: HoursRow[];
82
+ today: HoursToday;
83
+ isOpenNow: boolean;
84
+ /** `labels.openNow` or `labels.closedNow`. */
85
+ status: string;
86
+ exceptionRows: HoursExceptionRow[];
87
+ };
88
+ export declare const formatDate: (iso: string, locale: string) => string;
89
+ export declare const hoursView: (value: CmsHoursValue, locale: string, now?: Date) => HoursView;
90
+ /** schema.org `openingHoursSpecification` entries, one per distinct range. */
91
+ export declare const toOpeningHoursSpecification: (value: CmsHoursValue) => Tree[];