@fullgrainsoftware/portal 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/bin/fullgrain.mjs +3 -0
- package/dist/bookings.d.ts +169 -0
- package/dist/bookings.js +96 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +116 -0
- package/dist/cms-client.d.ts +9 -0
- package/dist/cms-client.js +38 -0
- package/dist/cms-types.d.ts +99 -0
- package/dist/cms-types.js +1 -0
- package/dist/commerce.d.ts +111 -0
- package/dist/commerce.js +91 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/inquiries.d.ts +38 -0
- package/dist/inquiries.js +52 -0
- package/dist/manifest.d.ts +297 -0
- package/dist/manifest.js +102 -0
- package/dist/preview.d.ts +49 -0
- package/dist/preview.js +165 -0
- package/dist/reader.d.ts +23 -0
- package/dist/reader.js +82 -0
- package/dist/reviews.d.ts +2 -0
- package/dist/reviews.js +13 -0
- package/package.json +50 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export type FieldType = "text" | "image" | "color" | "audio" | "video";
|
|
3
|
+
export interface ManifestField {
|
|
4
|
+
label: string;
|
|
5
|
+
type: FieldType;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
default?: string;
|
|
8
|
+
allowTypeChange?: boolean;
|
|
9
|
+
allowedTypes?: readonly FieldType[];
|
|
10
|
+
generateMp4?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ManifestSeedItem {
|
|
13
|
+
name: string;
|
|
14
|
+
values: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export interface ManifestSection {
|
|
17
|
+
key: string;
|
|
18
|
+
title: string;
|
|
19
|
+
kind: "repeatable" | "singleton";
|
|
20
|
+
allowGroupCreation?: boolean;
|
|
21
|
+
fields: Record<string, ManifestField>;
|
|
22
|
+
seed?: readonly ManifestSeedItem[];
|
|
23
|
+
}
|
|
24
|
+
export interface ManifestPage {
|
|
25
|
+
slug: string;
|
|
26
|
+
title: string;
|
|
27
|
+
sections: readonly ManifestSection[];
|
|
28
|
+
}
|
|
29
|
+
export interface SiteManifest {
|
|
30
|
+
alias: string;
|
|
31
|
+
pages: readonly ManifestPage[];
|
|
32
|
+
}
|
|
33
|
+
export declare function defineSite<const T extends SiteManifest>(manifest: T): T;
|
|
34
|
+
export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
35
|
+
alias: z.ZodString;
|
|
36
|
+
pages: z.ZodArray<z.ZodObject<{
|
|
37
|
+
slug: z.ZodString;
|
|
38
|
+
title: z.ZodString;
|
|
39
|
+
sections: z.ZodArray<z.ZodEffects<z.ZodObject<{
|
|
40
|
+
key: z.ZodString;
|
|
41
|
+
title: z.ZodString;
|
|
42
|
+
kind: z.ZodEnum<["repeatable", "singleton"]>;
|
|
43
|
+
allowGroupCreation: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
fields: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
45
|
+
label: z.ZodString;
|
|
46
|
+
type: z.ZodEnum<["text", "image", "color", "audio", "video"]>;
|
|
47
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
48
|
+
default: z.ZodOptional<z.ZodString>;
|
|
49
|
+
allowTypeChange: z.ZodOptional<z.ZodBoolean>;
|
|
50
|
+
allowedTypes: z.ZodOptional<z.ZodArray<z.ZodEnum<["text", "image", "color", "audio", "video"]>, "many">>;
|
|
51
|
+
generateMp4: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
}, "strict", z.ZodTypeAny, {
|
|
53
|
+
label: string;
|
|
54
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
55
|
+
default?: string | undefined;
|
|
56
|
+
required?: boolean | undefined;
|
|
57
|
+
allowTypeChange?: boolean | undefined;
|
|
58
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
59
|
+
generateMp4?: boolean | undefined;
|
|
60
|
+
}, {
|
|
61
|
+
label: string;
|
|
62
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
63
|
+
default?: string | undefined;
|
|
64
|
+
required?: boolean | undefined;
|
|
65
|
+
allowTypeChange?: boolean | undefined;
|
|
66
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
67
|
+
generateMp4?: boolean | undefined;
|
|
68
|
+
}>>;
|
|
69
|
+
seed: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
70
|
+
name: z.ZodString;
|
|
71
|
+
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
72
|
+
}, "strict", z.ZodTypeAny, {
|
|
73
|
+
values: Record<string, string>;
|
|
74
|
+
name: string;
|
|
75
|
+
}, {
|
|
76
|
+
values: Record<string, string>;
|
|
77
|
+
name: string;
|
|
78
|
+
}>, "many">>;
|
|
79
|
+
}, "strict", z.ZodTypeAny, {
|
|
80
|
+
key: string;
|
|
81
|
+
title: string;
|
|
82
|
+
kind: "repeatable" | "singleton";
|
|
83
|
+
fields: Record<string, {
|
|
84
|
+
label: string;
|
|
85
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
86
|
+
default?: string | undefined;
|
|
87
|
+
required?: boolean | undefined;
|
|
88
|
+
allowTypeChange?: boolean | undefined;
|
|
89
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
90
|
+
generateMp4?: boolean | undefined;
|
|
91
|
+
}>;
|
|
92
|
+
allowGroupCreation?: boolean | undefined;
|
|
93
|
+
seed?: {
|
|
94
|
+
values: Record<string, string>;
|
|
95
|
+
name: string;
|
|
96
|
+
}[] | undefined;
|
|
97
|
+
}, {
|
|
98
|
+
key: string;
|
|
99
|
+
title: string;
|
|
100
|
+
kind: "repeatable" | "singleton";
|
|
101
|
+
fields: Record<string, {
|
|
102
|
+
label: string;
|
|
103
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
104
|
+
default?: string | undefined;
|
|
105
|
+
required?: boolean | undefined;
|
|
106
|
+
allowTypeChange?: boolean | undefined;
|
|
107
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
108
|
+
generateMp4?: boolean | undefined;
|
|
109
|
+
}>;
|
|
110
|
+
allowGroupCreation?: boolean | undefined;
|
|
111
|
+
seed?: {
|
|
112
|
+
values: Record<string, string>;
|
|
113
|
+
name: string;
|
|
114
|
+
}[] | undefined;
|
|
115
|
+
}>, {
|
|
116
|
+
key: string;
|
|
117
|
+
title: string;
|
|
118
|
+
kind: "repeatable" | "singleton";
|
|
119
|
+
fields: Record<string, {
|
|
120
|
+
label: string;
|
|
121
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
122
|
+
default?: string | undefined;
|
|
123
|
+
required?: boolean | undefined;
|
|
124
|
+
allowTypeChange?: boolean | undefined;
|
|
125
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
126
|
+
generateMp4?: boolean | undefined;
|
|
127
|
+
}>;
|
|
128
|
+
allowGroupCreation?: boolean | undefined;
|
|
129
|
+
seed?: {
|
|
130
|
+
values: Record<string, string>;
|
|
131
|
+
name: string;
|
|
132
|
+
}[] | undefined;
|
|
133
|
+
}, {
|
|
134
|
+
key: string;
|
|
135
|
+
title: string;
|
|
136
|
+
kind: "repeatable" | "singleton";
|
|
137
|
+
fields: Record<string, {
|
|
138
|
+
label: string;
|
|
139
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
140
|
+
default?: string | undefined;
|
|
141
|
+
required?: boolean | undefined;
|
|
142
|
+
allowTypeChange?: boolean | undefined;
|
|
143
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
144
|
+
generateMp4?: boolean | undefined;
|
|
145
|
+
}>;
|
|
146
|
+
allowGroupCreation?: boolean | undefined;
|
|
147
|
+
seed?: {
|
|
148
|
+
values: Record<string, string>;
|
|
149
|
+
name: string;
|
|
150
|
+
}[] | undefined;
|
|
151
|
+
}>, "many">;
|
|
152
|
+
}, "strict", z.ZodTypeAny, {
|
|
153
|
+
title: string;
|
|
154
|
+
slug: string;
|
|
155
|
+
sections: {
|
|
156
|
+
key: string;
|
|
157
|
+
title: string;
|
|
158
|
+
kind: "repeatable" | "singleton";
|
|
159
|
+
fields: Record<string, {
|
|
160
|
+
label: string;
|
|
161
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
162
|
+
default?: string | undefined;
|
|
163
|
+
required?: boolean | undefined;
|
|
164
|
+
allowTypeChange?: boolean | undefined;
|
|
165
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
166
|
+
generateMp4?: boolean | undefined;
|
|
167
|
+
}>;
|
|
168
|
+
allowGroupCreation?: boolean | undefined;
|
|
169
|
+
seed?: {
|
|
170
|
+
values: Record<string, string>;
|
|
171
|
+
name: string;
|
|
172
|
+
}[] | undefined;
|
|
173
|
+
}[];
|
|
174
|
+
}, {
|
|
175
|
+
title: string;
|
|
176
|
+
slug: string;
|
|
177
|
+
sections: {
|
|
178
|
+
key: string;
|
|
179
|
+
title: string;
|
|
180
|
+
kind: "repeatable" | "singleton";
|
|
181
|
+
fields: Record<string, {
|
|
182
|
+
label: string;
|
|
183
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
184
|
+
default?: string | undefined;
|
|
185
|
+
required?: boolean | undefined;
|
|
186
|
+
allowTypeChange?: boolean | undefined;
|
|
187
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
188
|
+
generateMp4?: boolean | undefined;
|
|
189
|
+
}>;
|
|
190
|
+
allowGroupCreation?: boolean | undefined;
|
|
191
|
+
seed?: {
|
|
192
|
+
values: Record<string, string>;
|
|
193
|
+
name: string;
|
|
194
|
+
}[] | undefined;
|
|
195
|
+
}[];
|
|
196
|
+
}>, "many">;
|
|
197
|
+
}, "strict", z.ZodTypeAny, {
|
|
198
|
+
alias: string;
|
|
199
|
+
pages: {
|
|
200
|
+
title: string;
|
|
201
|
+
slug: string;
|
|
202
|
+
sections: {
|
|
203
|
+
key: string;
|
|
204
|
+
title: string;
|
|
205
|
+
kind: "repeatable" | "singleton";
|
|
206
|
+
fields: Record<string, {
|
|
207
|
+
label: string;
|
|
208
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
209
|
+
default?: string | undefined;
|
|
210
|
+
required?: boolean | undefined;
|
|
211
|
+
allowTypeChange?: boolean | undefined;
|
|
212
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
213
|
+
generateMp4?: boolean | undefined;
|
|
214
|
+
}>;
|
|
215
|
+
allowGroupCreation?: boolean | undefined;
|
|
216
|
+
seed?: {
|
|
217
|
+
values: Record<string, string>;
|
|
218
|
+
name: string;
|
|
219
|
+
}[] | undefined;
|
|
220
|
+
}[];
|
|
221
|
+
}[];
|
|
222
|
+
}, {
|
|
223
|
+
alias: string;
|
|
224
|
+
pages: {
|
|
225
|
+
title: string;
|
|
226
|
+
slug: string;
|
|
227
|
+
sections: {
|
|
228
|
+
key: string;
|
|
229
|
+
title: string;
|
|
230
|
+
kind: "repeatable" | "singleton";
|
|
231
|
+
fields: Record<string, {
|
|
232
|
+
label: string;
|
|
233
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
234
|
+
default?: string | undefined;
|
|
235
|
+
required?: boolean | undefined;
|
|
236
|
+
allowTypeChange?: boolean | undefined;
|
|
237
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
238
|
+
generateMp4?: boolean | undefined;
|
|
239
|
+
}>;
|
|
240
|
+
allowGroupCreation?: boolean | undefined;
|
|
241
|
+
seed?: {
|
|
242
|
+
values: Record<string, string>;
|
|
243
|
+
name: string;
|
|
244
|
+
}[] | undefined;
|
|
245
|
+
}[];
|
|
246
|
+
}[];
|
|
247
|
+
}>, {
|
|
248
|
+
alias: string;
|
|
249
|
+
pages: {
|
|
250
|
+
title: string;
|
|
251
|
+
slug: string;
|
|
252
|
+
sections: {
|
|
253
|
+
key: string;
|
|
254
|
+
title: string;
|
|
255
|
+
kind: "repeatable" | "singleton";
|
|
256
|
+
fields: Record<string, {
|
|
257
|
+
label: string;
|
|
258
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
259
|
+
default?: string | undefined;
|
|
260
|
+
required?: boolean | undefined;
|
|
261
|
+
allowTypeChange?: boolean | undefined;
|
|
262
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
263
|
+
generateMp4?: boolean | undefined;
|
|
264
|
+
}>;
|
|
265
|
+
allowGroupCreation?: boolean | undefined;
|
|
266
|
+
seed?: {
|
|
267
|
+
values: Record<string, string>;
|
|
268
|
+
name: string;
|
|
269
|
+
}[] | undefined;
|
|
270
|
+
}[];
|
|
271
|
+
}[];
|
|
272
|
+
}, {
|
|
273
|
+
alias: string;
|
|
274
|
+
pages: {
|
|
275
|
+
title: string;
|
|
276
|
+
slug: string;
|
|
277
|
+
sections: {
|
|
278
|
+
key: string;
|
|
279
|
+
title: string;
|
|
280
|
+
kind: "repeatable" | "singleton";
|
|
281
|
+
fields: Record<string, {
|
|
282
|
+
label: string;
|
|
283
|
+
type: "text" | "image" | "color" | "audio" | "video";
|
|
284
|
+
default?: string | undefined;
|
|
285
|
+
required?: boolean | undefined;
|
|
286
|
+
allowTypeChange?: boolean | undefined;
|
|
287
|
+
allowedTypes?: ("text" | "image" | "color" | "audio" | "video")[] | undefined;
|
|
288
|
+
generateMp4?: boolean | undefined;
|
|
289
|
+
}>;
|
|
290
|
+
allowGroupCreation?: boolean | undefined;
|
|
291
|
+
seed?: {
|
|
292
|
+
values: Record<string, string>;
|
|
293
|
+
name: string;
|
|
294
|
+
}[] | undefined;
|
|
295
|
+
}[];
|
|
296
|
+
}[];
|
|
297
|
+
}>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// Identity function whose const type parameter preserves literal keys/kinds,
|
|
3
|
+
// which is what makes readSiteContent's return type precise.
|
|
4
|
+
export function defineSite(manifest) {
|
|
5
|
+
return manifest;
|
|
6
|
+
}
|
|
7
|
+
// ---- Runtime validation (canonical copy; ab-cms/src/lib/site-manifest.ts mirrors it) ----
|
|
8
|
+
const fieldTypeSchema = z.enum(["text", "image", "color", "audio", "video"]);
|
|
9
|
+
const fieldSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
label: z.string().trim().min(1),
|
|
12
|
+
type: fieldTypeSchema,
|
|
13
|
+
required: z.boolean().optional(),
|
|
14
|
+
default: z.string().optional(),
|
|
15
|
+
allowTypeChange: z.boolean().optional(),
|
|
16
|
+
allowedTypes: z.array(fieldTypeSchema).optional(),
|
|
17
|
+
generateMp4: z.boolean().optional(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
const seedItemSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
name: z.string().trim().min(1),
|
|
23
|
+
values: z.record(z.string(), z.string()),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
const sectionSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
key: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "section key must be a valid identifier"),
|
|
29
|
+
title: z.string().trim().min(1),
|
|
30
|
+
kind: z.enum(["repeatable", "singleton"]),
|
|
31
|
+
allowGroupCreation: z.boolean().optional(),
|
|
32
|
+
fields: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, "field keys are lower-kebab"), fieldSchema),
|
|
33
|
+
seed: z.array(seedItemSchema).optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict()
|
|
36
|
+
.superRefine((section, ctx) => {
|
|
37
|
+
if (Object.keys(section.fields).length === 0) {
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "section needs at least one field" });
|
|
39
|
+
}
|
|
40
|
+
if (section.kind === "singleton" && (section.seed?.length ?? 0) > 1) {
|
|
41
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "singleton sections take at most one seed item" });
|
|
42
|
+
}
|
|
43
|
+
// A component's CMS identity is its (normalized) label, so two fields whose
|
|
44
|
+
// labels collide would collapse onto one component — last write wins,
|
|
45
|
+
// reattaching content to the wrong field. Enforce label uniqueness.
|
|
46
|
+
const seenLabels = new Set();
|
|
47
|
+
for (const field of Object.values(section.fields)) {
|
|
48
|
+
const norm = field.label.trim().toLowerCase();
|
|
49
|
+
if (seenLabels.has(norm)) {
|
|
50
|
+
ctx.addIssue({
|
|
51
|
+
code: z.ZodIssueCode.custom,
|
|
52
|
+
message: `duplicate field label "${field.label}" — field labels must be unique within a section`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
seenLabels.add(norm);
|
|
56
|
+
}
|
|
57
|
+
for (const item of section.seed ?? []) {
|
|
58
|
+
for (const valueKey of Object.keys(item.values)) {
|
|
59
|
+
if (!Object.hasOwn(section.fields, valueKey)) {
|
|
60
|
+
ctx.addIssue({
|
|
61
|
+
code: z.ZodIssueCode.custom,
|
|
62
|
+
message: `seed "${item.name}" sets unknown field "${valueKey}"`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const pageSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
slug: z.string().trim().min(1),
|
|
71
|
+
title: z.string().trim().min(1),
|
|
72
|
+
sections: z.array(sectionSchema),
|
|
73
|
+
})
|
|
74
|
+
.strict();
|
|
75
|
+
export const siteManifestSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
alias: z.string().trim().min(1),
|
|
78
|
+
pages: z.array(pageSchema).min(1),
|
|
79
|
+
})
|
|
80
|
+
.strict()
|
|
81
|
+
.superRefine((manifest, ctx) => {
|
|
82
|
+
const slugs = new Set();
|
|
83
|
+
const sectionKeys = new Set();
|
|
84
|
+
for (const page of manifest.pages) {
|
|
85
|
+
if (slugs.has(page.slug)) {
|
|
86
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate page slug "${page.slug}"` });
|
|
87
|
+
}
|
|
88
|
+
slugs.add(page.slug);
|
|
89
|
+
const titles = new Set();
|
|
90
|
+
for (const section of page.sections) {
|
|
91
|
+
if (sectionKeys.has(section.key)) {
|
|
92
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate section key "${section.key}"` });
|
|
93
|
+
}
|
|
94
|
+
sectionKeys.add(section.key);
|
|
95
|
+
const t = section.title.toLowerCase();
|
|
96
|
+
if (titles.has(t)) {
|
|
97
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate section title "${section.title}" on ${page.slug}` });
|
|
98
|
+
}
|
|
99
|
+
titles.add(t);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare const PREVIEW_PROTOCOL_VERSION = 1;
|
|
2
|
+
export declare const PREVIEW_QUERY_FLAG = "cms-preview";
|
|
3
|
+
/** Message the embedded site sends up to the dashboard. */
|
|
4
|
+
export type PreviewOutbound = {
|
|
5
|
+
source: "ab-cms-preview";
|
|
6
|
+
v: number;
|
|
7
|
+
type: "ready";
|
|
8
|
+
sections: string[];
|
|
9
|
+
} | {
|
|
10
|
+
source: "ab-cms-preview";
|
|
11
|
+
v: number;
|
|
12
|
+
type: "section-clicked";
|
|
13
|
+
title: string;
|
|
14
|
+
} | {
|
|
15
|
+
source: "ab-cms-preview";
|
|
16
|
+
v: number;
|
|
17
|
+
type: "edit-item";
|
|
18
|
+
id: string;
|
|
19
|
+
};
|
|
20
|
+
/** Message the dashboard sends down to the embedded site. */
|
|
21
|
+
export type PreviewInbound = {
|
|
22
|
+
source: "ab-cms-dashboard";
|
|
23
|
+
v: number;
|
|
24
|
+
type: "scroll-to-section";
|
|
25
|
+
title: string;
|
|
26
|
+
} | {
|
|
27
|
+
source: "ab-cms-dashboard";
|
|
28
|
+
v: number;
|
|
29
|
+
type: "refresh";
|
|
30
|
+
};
|
|
31
|
+
/** Type guard + version check for messages arriving from the dashboard. */
|
|
32
|
+
export declare function parseInbound(data: unknown): PreviewInbound | null;
|
|
33
|
+
export declare function readyMessage(sections: string[]): PreviewOutbound;
|
|
34
|
+
export declare function sectionClickedMessage(title: string): PreviewOutbound;
|
|
35
|
+
export declare function editItemMessage(id: string): PreviewOutbound;
|
|
36
|
+
export interface CmsPreviewOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Origins allowed to embed and message this preview. Messages from any other
|
|
39
|
+
* origin are ignored, and outbound messages target only these. Defaults to
|
|
40
|
+
* the Fullgrain Portal dashboard origins only — for local development against a
|
|
41
|
+
* dev panel, pass your localhost origin (e.g. ["http://localhost:3000"]).
|
|
42
|
+
*/
|
|
43
|
+
allowedOrigins?: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Activate the preview bridge. Safe to call unconditionally: it no-ops on the
|
|
47
|
+
* server and on the public (non-preview) site. Returns a cleanup function.
|
|
48
|
+
*/
|
|
49
|
+
export declare function initCmsPreview(options?: CmsPreviewOptions): () => void;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Live-preview bridge. A site includes this (via initCmsPreview) so the
|
|
2
|
+
// Fullgrain Portal dashboard can embed it in an iframe and drive it: scroll to a section,
|
|
3
|
+
// refresh after a save, and — when a visitor clicks a tagged section in the
|
|
4
|
+
// preview — tell the dashboard to select it.
|
|
5
|
+
//
|
|
6
|
+
// The site tags each section's root element with data-cms-section="<title>",
|
|
7
|
+
// matching the CMS section title. Nothing here runs on the public site: the
|
|
8
|
+
// bridge only activates inside an iframe when ?cms-preview=1 is present.
|
|
9
|
+
//
|
|
10
|
+
// Protocol is versioned and mirrored in ab-cms (src/lib/preview-protocol.ts);
|
|
11
|
+
// keep the two in sync when it changes.
|
|
12
|
+
export const PREVIEW_PROTOCOL_VERSION = 1;
|
|
13
|
+
export const PREVIEW_QUERY_FLAG = "cms-preview";
|
|
14
|
+
/** Type guard + version check for messages arriving from the dashboard. */
|
|
15
|
+
export function parseInbound(data) {
|
|
16
|
+
if (!data || typeof data !== "object")
|
|
17
|
+
return null;
|
|
18
|
+
const m = data;
|
|
19
|
+
if (m.source !== "ab-cms-dashboard" || m.v !== PREVIEW_PROTOCOL_VERSION)
|
|
20
|
+
return null;
|
|
21
|
+
if (m.type === "scroll-to-section" && typeof m.title === "string") {
|
|
22
|
+
return { source: "ab-cms-dashboard", v: PREVIEW_PROTOCOL_VERSION, type: "scroll-to-section", title: m.title };
|
|
23
|
+
}
|
|
24
|
+
if (m.type === "refresh") {
|
|
25
|
+
return { source: "ab-cms-dashboard", v: PREVIEW_PROTOCOL_VERSION, type: "refresh" };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function readyMessage(sections) {
|
|
30
|
+
return { source: "ab-cms-preview", v: PREVIEW_PROTOCOL_VERSION, type: "ready", sections };
|
|
31
|
+
}
|
|
32
|
+
export function sectionClickedMessage(title) {
|
|
33
|
+
return { source: "ab-cms-preview", v: PREVIEW_PROTOCOL_VERSION, type: "section-clicked", title };
|
|
34
|
+
}
|
|
35
|
+
export function editItemMessage(id) {
|
|
36
|
+
return { source: "ab-cms-preview", v: PREVIEW_PROTOCOL_VERSION, type: "edit-item", id };
|
|
37
|
+
}
|
|
38
|
+
// The portal's current host and the one it moves to (rebrand, 2026-10). Keep
|
|
39
|
+
// the abdevpanel origins until no site runs an SDK that predates the move.
|
|
40
|
+
// The "ab-cms-*" message sources below are the wire protocol, not branding:
|
|
41
|
+
// renaming them would break preview against every installed SDK version.
|
|
42
|
+
const DEFAULT_ORIGINS = [
|
|
43
|
+
"https://www.abdevpanel.com",
|
|
44
|
+
"https://abdevpanel.com",
|
|
45
|
+
"https://app.abdevpanel.com",
|
|
46
|
+
"https://app.fullgrainsoftware.com",
|
|
47
|
+
];
|
|
48
|
+
function isPreviewContext(allowed) {
|
|
49
|
+
if (typeof window === "undefined")
|
|
50
|
+
return false;
|
|
51
|
+
if (window.parent === window)
|
|
52
|
+
return false; // not framed
|
|
53
|
+
try {
|
|
54
|
+
if (!new URLSearchParams(window.location.search).has(PREVIEW_QUERY_FLAG))
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
// The whole bridge (including the capturing click listener that suppresses
|
|
61
|
+
// default actions) activates here, so make this gate at least as strong as
|
|
62
|
+
// the postMessage origin checks. Where the browser exposes the embedder
|
|
63
|
+
// origin, require it to be trusted; browsers without ancestorOrigins (e.g.
|
|
64
|
+
// Firefox) fall back to the framed + query-flag check.
|
|
65
|
+
const ancestors = window.location
|
|
66
|
+
.ancestorOrigins;
|
|
67
|
+
if (ancestors && ancestors.length > 0) {
|
|
68
|
+
return allowed.includes(ancestors[0]);
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function collectSectionTitles() {
|
|
73
|
+
const nodes = document.querySelectorAll("[data-cms-section]");
|
|
74
|
+
const titles = [];
|
|
75
|
+
nodes.forEach((n) => {
|
|
76
|
+
const t = n.dataset.cmsSection?.trim();
|
|
77
|
+
if (t && !titles.includes(t))
|
|
78
|
+
titles.push(t);
|
|
79
|
+
});
|
|
80
|
+
return titles;
|
|
81
|
+
}
|
|
82
|
+
function findSection(title) {
|
|
83
|
+
const target = title.trim().toLowerCase();
|
|
84
|
+
const nodes = document.querySelectorAll("[data-cms-section]");
|
|
85
|
+
for (const n of nodes) {
|
|
86
|
+
if (n.dataset.cmsSection?.trim().toLowerCase() === target)
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Activate the preview bridge. Safe to call unconditionally: it no-ops on the
|
|
93
|
+
* server and on the public (non-preview) site. Returns a cleanup function.
|
|
94
|
+
*/
|
|
95
|
+
export function initCmsPreview(options = {}) {
|
|
96
|
+
const allowed = options.allowedOrigins?.length ? options.allowedOrigins : DEFAULT_ORIGINS;
|
|
97
|
+
if (!isPreviewContext(allowed))
|
|
98
|
+
return () => { };
|
|
99
|
+
const post = (msg) => {
|
|
100
|
+
for (const origin of allowed) {
|
|
101
|
+
try {
|
|
102
|
+
window.parent.postMessage(msg, origin);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// origin not the real parent; ignore
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const announce = () => post(readyMessage(collectSectionTitles()));
|
|
110
|
+
const onMessage = (event) => {
|
|
111
|
+
if (!allowed.includes(event.origin))
|
|
112
|
+
return;
|
|
113
|
+
const msg = parseInbound(event.data);
|
|
114
|
+
if (!msg)
|
|
115
|
+
return;
|
|
116
|
+
if (msg.type === "refresh") {
|
|
117
|
+
window.location.reload();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (msg.type === "scroll-to-section") {
|
|
121
|
+
const el = findSection(msg.title);
|
|
122
|
+
if (!el)
|
|
123
|
+
return;
|
|
124
|
+
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
125
|
+
el.classList.add("cms-preview-flash");
|
|
126
|
+
window.setTimeout(() => el.classList.remove("cms-preview-flash"), 1600);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const onClick = (event) => {
|
|
130
|
+
const target = event.target;
|
|
131
|
+
// An editable item (a testimonial, a gallery card) takes priority: it opens
|
|
132
|
+
// that item's editor. Otherwise a tagged section just selects in the editor.
|
|
133
|
+
const item = target?.closest("[data-cms-editable][data-cms-item]");
|
|
134
|
+
const itemId = item?.dataset.cmsItem?.trim();
|
|
135
|
+
if (itemId) {
|
|
136
|
+
// In preview a click edits the item; don't follow the item's own link.
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
post(editItemMessage(itemId));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const section = target?.closest("[data-cms-section]");
|
|
143
|
+
const title = section?.dataset.cmsSection?.trim();
|
|
144
|
+
if (title)
|
|
145
|
+
post(sectionClickedMessage(title));
|
|
146
|
+
};
|
|
147
|
+
// A minimal highlight style, injected once, so sites need no extra CSS.
|
|
148
|
+
const style = document.createElement("style");
|
|
149
|
+
style.dataset.cmsPreview = "";
|
|
150
|
+
style.textContent =
|
|
151
|
+
".cms-preview-flash{animation:cms-preview-flash 1.6s ease-out}" +
|
|
152
|
+
"@keyframes cms-preview-flash{0%{box-shadow:inset 0 0 0 3px rgba(139,147,255,.9)}100%{box-shadow:inset 0 0 0 0 transparent}}";
|
|
153
|
+
document.head.appendChild(style);
|
|
154
|
+
window.addEventListener("message", onMessage);
|
|
155
|
+
document.addEventListener("click", onClick, true);
|
|
156
|
+
if (document.readyState === "complete")
|
|
157
|
+
announce();
|
|
158
|
+
else
|
|
159
|
+
window.addEventListener("load", announce, { once: true });
|
|
160
|
+
return () => {
|
|
161
|
+
window.removeEventListener("message", onMessage);
|
|
162
|
+
document.removeEventListener("click", onClick, true);
|
|
163
|
+
style.remove();
|
|
164
|
+
};
|
|
165
|
+
}
|
package/dist/reader.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SiteManifest } from "./manifest.js";
|
|
2
|
+
import type { CMSWebsite } from "./cms-types.js";
|
|
3
|
+
/** Per-item CMS metadata for click-to-edit. `id` is the section-group id, or
|
|
4
|
+
* "" when the item comes from the manifest seed (nothing to edit yet). */
|
|
5
|
+
export type CmsItemMeta = {
|
|
6
|
+
_cms: {
|
|
7
|
+
id: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export type SiteContent<T extends SiteManifest> = {
|
|
11
|
+
[S in T["pages"][number]["sections"][number] as S["key"]]: S["kind"] extends "repeatable" ? Array<{
|
|
12
|
+
[K in keyof S["fields"]]: string;
|
|
13
|
+
} & CmsItemMeta> : {
|
|
14
|
+
[K in keyof S["fields"]]: string;
|
|
15
|
+
} & CmsItemMeta;
|
|
16
|
+
};
|
|
17
|
+
export declare function readSiteContent<T extends SiteManifest>(manifest: T, website: CMSWebsite | null | undefined): SiteContent<T>;
|
|
18
|
+
/**
|
|
19
|
+
* Spread onto an item's wrapper element to make it click-to-edit in the
|
|
20
|
+
* dashboard preview: `<div {...cmsItemProps(item)}>`. Returns {} for seed items
|
|
21
|
+
* (no CMS id yet), leaving them non-editable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function cmsItemProps(item: CmsItemMeta): Record<string, string>;
|