@407dev/cms-astro 0.1.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/README.md +69 -0
- package/dist/chunk-2TKIQIOE.js +118 -0
- package/dist/chunk-2TKIQIOE.js.map +1 -0
- package/dist/chunk-53OQ5RXJ.js +912 -0
- package/dist/chunk-53OQ5RXJ.js.map +1 -0
- package/dist/chunk-56OMNQLH.js +1013 -0
- package/dist/chunk-56OMNQLH.js.map +1 -0
- package/dist/chunk-5AOOMQ7K.js +1031 -0
- package/dist/chunk-5AOOMQ7K.js.map +1 -0
- package/dist/chunk-5XRUCFDC.js +743 -0
- package/dist/chunk-5XRUCFDC.js.map +1 -0
- package/dist/chunk-62ZHCOLZ.js +25 -0
- package/dist/chunk-62ZHCOLZ.js.map +1 -0
- package/dist/chunk-6AZLRSYA.js +126 -0
- package/dist/chunk-6AZLRSYA.js.map +1 -0
- package/dist/chunk-6L3ZTCFB.js +14 -0
- package/dist/chunk-6L3ZTCFB.js.map +1 -0
- package/dist/chunk-6O7ZNNDT.js +697 -0
- package/dist/chunk-6O7ZNNDT.js.map +1 -0
- package/dist/chunk-6PQIXTMK.js +697 -0
- package/dist/chunk-6PQIXTMK.js.map +1 -0
- package/dist/chunk-7AW6M7QW.js +1013 -0
- package/dist/chunk-7AW6M7QW.js.map +1 -0
- package/dist/chunk-7E3XJLKX.js +73 -0
- package/dist/chunk-7E3XJLKX.js.map +1 -0
- package/dist/chunk-7LM54OTA.js +1031 -0
- package/dist/chunk-7LM54OTA.js.map +1 -0
- package/dist/chunk-7LMUFCWX.js +126 -0
- package/dist/chunk-7LMUFCWX.js.map +1 -0
- package/dist/chunk-A3OGL4OX.js +706 -0
- package/dist/chunk-A3OGL4OX.js.map +1 -0
- package/dist/chunk-AO4UAWOV.js +1030 -0
- package/dist/chunk-AO4UAWOV.js.map +1 -0
- package/dist/chunk-B4YYLS7Y.js +14 -0
- package/dist/chunk-B4YYLS7Y.js.map +1 -0
- package/dist/chunk-BDFVGXM2.js +697 -0
- package/dist/chunk-BDFVGXM2.js.map +1 -0
- package/dist/chunk-CXM7NZVX.js +1031 -0
- package/dist/chunk-CXM7NZVX.js.map +1 -0
- package/dist/chunk-DMLOBKYE.js +96 -0
- package/dist/chunk-DMLOBKYE.js.map +1 -0
- package/dist/chunk-DXJSYGS6.js +743 -0
- package/dist/chunk-DXJSYGS6.js.map +1 -0
- package/dist/chunk-EFUSAQZM.js +72 -0
- package/dist/chunk-EFUSAQZM.js.map +1 -0
- package/dist/chunk-F7KDGPBN.js +1013 -0
- package/dist/chunk-F7KDGPBN.js.map +1 -0
- package/dist/chunk-FBSQT45X.js +126 -0
- package/dist/chunk-FBSQT45X.js.map +1 -0
- package/dist/chunk-FEPDOMVH.js +697 -0
- package/dist/chunk-FEPDOMVH.js.map +1 -0
- package/dist/chunk-FQDOL2QW.js +25 -0
- package/dist/chunk-FQDOL2QW.js.map +1 -0
- package/dist/chunk-GB3LGQJM.js +109 -0
- package/dist/chunk-GB3LGQJM.js.map +1 -0
- package/dist/chunk-HCNP6R3P.js +736 -0
- package/dist/chunk-HCNP6R3P.js.map +1 -0
- package/dist/chunk-J6WEJDRI.js +743 -0
- package/dist/chunk-J6WEJDRI.js.map +1 -0
- package/dist/chunk-JHEAPNJ2.js +117 -0
- package/dist/chunk-JHEAPNJ2.js.map +1 -0
- package/dist/chunk-JOJDJ6GW.js +25 -0
- package/dist/chunk-JOJDJ6GW.js.map +1 -0
- package/dist/chunk-KTDV6WYB.js +202 -0
- package/dist/chunk-KTDV6WYB.js.map +1 -0
- package/dist/chunk-KZWJBKPC.js +734 -0
- package/dist/chunk-KZWJBKPC.js.map +1 -0
- package/dist/chunk-LEAQBGL6.js +109 -0
- package/dist/chunk-LEAQBGL6.js.map +1 -0
- package/dist/chunk-LGZJL2Q7.js +697 -0
- package/dist/chunk-LGZJL2Q7.js.map +1 -0
- package/dist/chunk-LQPBU46R.js +109 -0
- package/dist/chunk-LQPBU46R.js.map +1 -0
- package/dist/chunk-M55NNUTY.js +1013 -0
- package/dist/chunk-M55NNUTY.js.map +1 -0
- package/dist/chunk-M6NCYCC3.js +1013 -0
- package/dist/chunk-M6NCYCC3.js.map +1 -0
- package/dist/chunk-MGN7OZAU.js +109 -0
- package/dist/chunk-MGN7OZAU.js.map +1 -0
- package/dist/chunk-MGYZOX4K.js +1013 -0
- package/dist/chunk-MGYZOX4K.js.map +1 -0
- package/dist/chunk-MY4IEKJS.js +1031 -0
- package/dist/chunk-MY4IEKJS.js.map +1 -0
- package/dist/chunk-NCQRGAB4.js +705 -0
- package/dist/chunk-NCQRGAB4.js.map +1 -0
- package/dist/chunk-NI4NHXI4.js +697 -0
- package/dist/chunk-NI4NHXI4.js.map +1 -0
- package/dist/chunk-NPI5N7AU.js +14 -0
- package/dist/chunk-NPI5N7AU.js.map +1 -0
- package/dist/chunk-QN4CPMJF.js +126 -0
- package/dist/chunk-QN4CPMJF.js.map +1 -0
- package/dist/chunk-QYUVAKMC.js +1013 -0
- package/dist/chunk-QYUVAKMC.js.map +1 -0
- package/dist/chunk-RAAT6PDT.js +25 -0
- package/dist/chunk-RAAT6PDT.js.map +1 -0
- package/dist/chunk-RDPO5VPS.js +1013 -0
- package/dist/chunk-RDPO5VPS.js.map +1 -0
- package/dist/chunk-RQHIW2PF.js +25 -0
- package/dist/chunk-RQHIW2PF.js.map +1 -0
- package/dist/chunk-RREVHA52.js +722 -0
- package/dist/chunk-RREVHA52.js.map +1 -0
- package/dist/chunk-S7PT4PZN.js +25 -0
- package/dist/chunk-S7PT4PZN.js.map +1 -0
- package/dist/chunk-SUXV5M32.js +109 -0
- package/dist/chunk-SUXV5M32.js.map +1 -0
- package/dist/chunk-SZEORUY6.js +696 -0
- package/dist/chunk-SZEORUY6.js.map +1 -0
- package/dist/chunk-T7NGGWGY.js +25 -0
- package/dist/chunk-T7NGGWGY.js.map +1 -0
- package/dist/chunk-UEVWP5VE.js +696 -0
- package/dist/chunk-UEVWP5VE.js.map +1 -0
- package/dist/chunk-UJNKNP7U.js +748 -0
- package/dist/chunk-UJNKNP7U.js.map +1 -0
- package/dist/chunk-V4XVFWFI.js +1013 -0
- package/dist/chunk-V4XVFWFI.js.map +1 -0
- package/dist/chunk-VJU2CIKZ.js +729 -0
- package/dist/chunk-VJU2CIKZ.js.map +1 -0
- package/dist/chunk-VRQUP5L3.js +202 -0
- package/dist/chunk-VRQUP5L3.js.map +1 -0
- package/dist/chunk-WBJ5E7CL.js +742 -0
- package/dist/chunk-WBJ5E7CL.js.map +1 -0
- package/dist/chunk-XJPVYMZM.js +24 -0
- package/dist/chunk-XJPVYMZM.js.map +1 -0
- package/dist/chunk-Y3Q65C7R.js +14 -0
- package/dist/chunk-Y3Q65C7R.js.map +1 -0
- package/dist/chunk-YHEGFPTR.js +1030 -0
- package/dist/chunk-YHEGFPTR.js.map +1 -0
- package/dist/chunk-ZDLBN46C.js +697 -0
- package/dist/chunk-ZDLBN46C.js.map +1 -0
- package/dist/dev/index.d.ts +122 -0
- package/dist/dev/index.js +662 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/fields/collections.d.ts +18 -0
- package/dist/fields/collections.js +37 -0
- package/dist/fields/collections.js.map +1 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +956 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.d.ts +8 -0
- package/dist/middleware/index.js +13 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/preview-BEkLMD6N.d.ts +23 -0
- package/dist/preview-D9yewK8x.d.ts +24 -0
- package/dist/preview-DJEA4fj7.d.ts +23 -0
- package/dist/preview-DeD1Jn7U.d.ts +23 -0
- package/dist/toolbar/app.d.ts +28 -0
- package/dist/toolbar/app.js +1176 -0
- package/dist/toolbar/app.js.map +1 -0
- package/dist/types-15jfCQKb.d.ts +141 -0
- package/dist/types-BREdL9Gx.d.ts +140 -0
- package/dist/types-C6sUZNa2.d.ts +104 -0
- package/dist/types-CEYS5Fzc.d.ts +112 -0
- package/dist/types-DpeHpobR.d.ts +104 -0
- package/dist/types-NaKJe6Pg.d.ts +140 -0
- package/package.json +90 -0
- package/src/CmsHead.astro +25 -0
- package/src/CmsImage.astro +62 -0
- package/src/CmsVideo.astro +185 -0
- package/src/RichText.astro +32 -0
- package/src/client.test.ts +227 -0
- package/src/client.ts +164 -0
- package/src/dev/config.test.ts +258 -0
- package/src/dev/config.ts +319 -0
- package/src/dev/index.ts +5 -0
- package/src/dev/preview-token.ts +39 -0
- package/src/dev/routes.test.ts +354 -0
- package/src/dev/routes.ts +245 -0
- package/src/dev/token.test.ts +280 -0
- package/src/dev/token.ts +201 -0
- package/src/dev/verifier.test.ts +63 -0
- package/src/dev/verifier.ts +161 -0
- package/src/fields/attrs.ts +24 -0
- package/src/fields/collection.ts +72 -0
- package/src/fields/collections.ts +35 -0
- package/src/fields/entryScope.test.ts +62 -0
- package/src/fields/entryScope.ts +240 -0
- package/src/fields/group.ts +51 -0
- package/src/fields/helpers.test.ts +51 -0
- package/src/fields/helpers.ts +214 -0
- package/src/fields/index.ts +46 -0
- package/src/fields/scope.ts +21 -0
- package/src/fields/types.ts +119 -0
- package/src/head.test.ts +74 -0
- package/src/head.ts +123 -0
- package/src/image.test.ts +42 -0
- package/src/image.ts +64 -0
- package/src/index.test.ts +76 -0
- package/src/index.ts +13 -0
- package/src/integration.ts +218 -0
- package/src/jwt.ts +131 -0
- package/src/middleware/index.ts +4 -0
- package/src/middleware/preview.test.ts +184 -0
- package/src/middleware/preview.ts +206 -0
- package/src/preview/bridge-script.test.ts +683 -0
- package/src/preview/bridge-script.ts +763 -0
- package/src/preview/origins.ts +29 -0
- package/src/preview.ts +49 -0
- package/src/richtext/RichTextNode.astro +130 -0
- package/src/richtext/render.ts +248 -0
- package/src/richtext.test.ts +139 -0
- package/src/runtime/bundle.ts +28 -0
- package/src/runtime/store.ts +130 -0
- package/src/siteFiles.test.ts +93 -0
- package/src/siteFiles.ts +111 -0
- package/src/toolbar/app.test.ts +191 -0
- package/src/toolbar/app.ts +1091 -0
- package/src/toolbar/icon.ts +5 -0
- package/src/toolbar/widgets.test.ts +153 -0
- package/src/toolbar/widgets.ts +365 -0
- package/src/types.test.ts +154 -0
- package/src/types.ts +163 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const CMS_TOOLBAR_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
3
|
+
<path d="M4 10h16"/>
|
|
4
|
+
<path d="M10 4v16"/>
|
|
5
|
+
</svg>`;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
extractImageSummary,
|
|
5
|
+
extractRichTextSummary,
|
|
6
|
+
isLightweightType,
|
|
7
|
+
resolveToolbarWidget,
|
|
8
|
+
} from './widgets';
|
|
9
|
+
|
|
10
|
+
describe('Toolbar widgets and resolution', () => {
|
|
11
|
+
describe('isLightweightType', () => {
|
|
12
|
+
it('returns true for text, number, toggle, select', () => {
|
|
13
|
+
expect(isLightweightType('text')).toBe(true);
|
|
14
|
+
expect(isLightweightType('TEXT')).toBe(true);
|
|
15
|
+
expect(isLightweightType('number')).toBe(true);
|
|
16
|
+
expect(isLightweightType('toggle')).toBe(true);
|
|
17
|
+
expect(isLightweightType('select')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns false for complex and unknown types', () => {
|
|
21
|
+
expect(isLightweightType('richtext')).toBe(false);
|
|
22
|
+
expect(isLightweightType('image')).toBe(false);
|
|
23
|
+
expect(isLightweightType('media.video')).toBe(false);
|
|
24
|
+
expect(isLightweightType('geo.location')).toBe(false);
|
|
25
|
+
expect(isLightweightType(null)).toBe(false);
|
|
26
|
+
expect(isLightweightType(undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('extractRichTextSummary', () => {
|
|
31
|
+
it('summarizes plain text string', () => {
|
|
32
|
+
expect(extractRichTextSummary('Hello world')).toBe('Hello world');
|
|
33
|
+
expect(extractRichTextSummary('<p>HTML text</p>')).toBe('HTML text');
|
|
34
|
+
expect(extractRichTextSummary('')).toBe('(empty)');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('summarizes ProseMirror doc object with first text run and node count', () => {
|
|
38
|
+
const doc = {
|
|
39
|
+
type: 'doc',
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: 'paragraph',
|
|
43
|
+
content: [{ type: 'text', text: 'Welcome to our site' }],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'paragraph',
|
|
47
|
+
content: [{ type: 'text', text: 'Second paragraph' }],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
const summary = extractRichTextSummary(doc);
|
|
52
|
+
expect(summary).toContain('Welcome to our site');
|
|
53
|
+
expect(summary).toContain('nodes');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('extractImageSummary', () => {
|
|
58
|
+
it('summarizes URL string', () => {
|
|
59
|
+
expect(extractImageSummary('https://cdn.example.com/assets/hero.png')).toBe('hero.png');
|
|
60
|
+
expect(extractImageSummary('')).toBe('(no image)');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('summarizes image object with dimensions and alt', () => {
|
|
64
|
+
const img = {
|
|
65
|
+
url: 'https://cdn.example.com/images/banner.jpg',
|
|
66
|
+
width: 1200,
|
|
67
|
+
height: 630,
|
|
68
|
+
alt: 'Header banner',
|
|
69
|
+
};
|
|
70
|
+
const summary = extractImageSummary(img);
|
|
71
|
+
expect(summary).toContain('banner.jpg');
|
|
72
|
+
expect(summary).toContain('1200×630');
|
|
73
|
+
expect(summary).toContain('Header banner');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('resolveToolbarWidget', () => {
|
|
78
|
+
const baseCtx = {
|
|
79
|
+
key: 'test.field',
|
|
80
|
+
siteId: 'site-123',
|
|
81
|
+
routePath: '/about',
|
|
82
|
+
editorOrigin: 'http://localhost:5173',
|
|
83
|
+
onSave: vi.fn().mockResolvedValue({ ok: true }),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
it('resolves text widget to text input form', () => {
|
|
87
|
+
const renderer = resolveToolbarWidget('text');
|
|
88
|
+
const el = renderer({ ...baseCtx, typeId: 'text', value: 'Hello' });
|
|
89
|
+
const input = el.querySelector('input[type="text"]') as HTMLInputElement;
|
|
90
|
+
expect(input).not.toBeNull();
|
|
91
|
+
expect(input.value).toBe('Hello');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('resolves number widget to number input form', () => {
|
|
95
|
+
const renderer = resolveToolbarWidget('number');
|
|
96
|
+
const el = renderer({ ...baseCtx, typeId: 'number', value: 42 });
|
|
97
|
+
const input = el.querySelector('input[type="number"]') as HTMLInputElement;
|
|
98
|
+
expect(input).not.toBeNull();
|
|
99
|
+
expect(input.value).toBe('42');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('resolves toggle widget to checkbox', () => {
|
|
103
|
+
const renderer = resolveToolbarWidget('toggle');
|
|
104
|
+
const el = renderer({ ...baseCtx, typeId: 'toggle', value: true });
|
|
105
|
+
const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
106
|
+
expect(input).not.toBeNull();
|
|
107
|
+
expect(input.checked).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('resolves select widget to select element with options', () => {
|
|
111
|
+
const renderer = resolveToolbarWidget('select');
|
|
112
|
+
const el = renderer({
|
|
113
|
+
...baseCtx,
|
|
114
|
+
typeId: 'select',
|
|
115
|
+
value: 'opt2',
|
|
116
|
+
constraints: { options: ['opt1', 'opt2', 'opt3'] },
|
|
117
|
+
});
|
|
118
|
+
const select = el.querySelector('select') as HTMLSelectElement;
|
|
119
|
+
expect(select).not.toBeNull();
|
|
120
|
+
expect(select.value).toBe('opt2');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('resolves richtext, image, and unknown types to summary with Open in Editor link', () => {
|
|
124
|
+
for (const typeId of ['richtext', 'image', 'custom.field', 'unknown']) {
|
|
125
|
+
const renderer = resolveToolbarWidget(typeId);
|
|
126
|
+
const el = renderer({ ...baseCtx, typeId, value: 'sample' });
|
|
127
|
+
|
|
128
|
+
// Must NOT render an editable input or textarea
|
|
129
|
+
expect(el.querySelector('input')).toBeNull();
|
|
130
|
+
expect(el.querySelector('textarea')).toBeNull();
|
|
131
|
+
expect(el.querySelector('select')).toBeNull();
|
|
132
|
+
|
|
133
|
+
// Must render Open in Editor deep link
|
|
134
|
+
const link = el.querySelector('a.cms-widget-editor-link') as HTMLAnchorElement;
|
|
135
|
+
expect(link).not.toBeNull();
|
|
136
|
+
expect(link.textContent).toContain('Open in Editor');
|
|
137
|
+
expect(link.href).toBe(
|
|
138
|
+
'http://localhost:5173/sites/site-123/editor?path=%2Fabout&field=test.field',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('falls back to default editor origin when editorOrigin is omitted', () => {
|
|
144
|
+
const renderer = resolveToolbarWidget('richtext');
|
|
145
|
+
const el = renderer({ ...baseCtx, editorOrigin: '', typeId: 'richtext', value: 'sample' });
|
|
146
|
+
const link = el.querySelector('a.cms-widget-editor-link') as HTMLAnchorElement;
|
|
147
|
+
expect(link).not.toBeNull();
|
|
148
|
+
expect(link.href).toBe(
|
|
149
|
+
'https://web.localhost/sites/site-123/editor?path=%2Fabout&field=test.field',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { DEFAULT_EDITOR_ORIGINS } from '../preview/origins';
|
|
2
|
+
|
|
3
|
+
export const LIGHTWEIGHT_TYPE_IDS = ['text', 'number', 'toggle', 'select'] as const;
|
|
4
|
+
|
|
5
|
+
export type LightweightTypeId = (typeof LIGHTWEIGHT_TYPE_IDS)[number];
|
|
6
|
+
|
|
7
|
+
export interface ToolbarWidgetContext {
|
|
8
|
+
key: string;
|
|
9
|
+
typeId?: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
defaultValue?: unknown;
|
|
12
|
+
constraints?: Record<string, unknown>;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
siteId: string;
|
|
15
|
+
routePath: string;
|
|
16
|
+
editorOrigin: string;
|
|
17
|
+
onSave?: (value: unknown) => Promise<{ ok: boolean; error?: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ToolbarWidgetRenderer = (ctx: ToolbarWidgetContext) => HTMLElement;
|
|
21
|
+
|
|
22
|
+
export function isLightweightType(typeId?: string | null): boolean {
|
|
23
|
+
if (!typeId) return false;
|
|
24
|
+
return (LIGHTWEIGHT_TYPE_IDS as readonly string[]).includes(typeId.toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extracts a concise summary string from a ProseMirror RichText object or string.
|
|
29
|
+
*/
|
|
30
|
+
export function extractRichTextSummary(value: unknown): string {
|
|
31
|
+
if (!value) return '(empty)';
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
const trimmed = value.replace(/<[^>]*>/g, '').trim();
|
|
34
|
+
return trimmed ? (trimmed.length > 50 ? `${trimmed.slice(0, 47)}...` : trimmed) : '(empty)';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value === 'object' && value !== null) {
|
|
38
|
+
const doc = value as { type?: string; content?: unknown[] };
|
|
39
|
+
let nodeCount = 0;
|
|
40
|
+
const textRuns: string[] = [];
|
|
41
|
+
|
|
42
|
+
function walk(node: unknown) {
|
|
43
|
+
if (!node || typeof node !== 'object') return;
|
|
44
|
+
nodeCount++;
|
|
45
|
+
const n = node as { type?: string; text?: string; content?: unknown[] };
|
|
46
|
+
if (typeof n.text === 'string' && n.text.trim()) {
|
|
47
|
+
textRuns.push(n.text.trim());
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(n.content)) {
|
|
50
|
+
for (const child of n.content) {
|
|
51
|
+
walk(child);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
walk(doc);
|
|
57
|
+
|
|
58
|
+
const firstRun = textRuns[0] || '';
|
|
59
|
+
const snippet = firstRun.length > 40 ? `${firstRun.slice(0, 37)}...` : firstRun;
|
|
60
|
+
return snippet
|
|
61
|
+
? `"${snippet}" (${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'})`
|
|
62
|
+
: `(${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extracts a concise summary string from an Image object or string URL.
|
|
70
|
+
*/
|
|
71
|
+
export function extractImageSummary(value: unknown): string {
|
|
72
|
+
if (!value) return '(no image)';
|
|
73
|
+
if (typeof value === 'string') {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(value, 'https://placeholder.local');
|
|
76
|
+
const filename = u.pathname.split('/').pop() || value;
|
|
77
|
+
return filename.length > 40 ? `${filename.slice(0, 37)}...` : filename;
|
|
78
|
+
} catch {
|
|
79
|
+
return value.length > 40 ? `${value.slice(0, 37)}...` : value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof value === 'object' && value !== null) {
|
|
84
|
+
const img = value as { url?: string; alt?: string; width?: number; height?: number };
|
|
85
|
+
const name = img.url ? img.url.split('/').pop() || 'image' : 'image';
|
|
86
|
+
const dim = img.width && img.height ? ` (${img.width}×${img.height})` : '';
|
|
87
|
+
const alt = img.alt ? ` — "${img.alt}"` : '';
|
|
88
|
+
const combined = `${name}${dim}${alt}`;
|
|
89
|
+
return combined.length > 50 ? `${combined.slice(0, 47)}...` : combined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return String(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createTextWidget(ctx: ToolbarWidgetContext): HTMLElement {
|
|
96
|
+
const container = document.createElement('div');
|
|
97
|
+
container.className = 'cms-widget-container';
|
|
98
|
+
|
|
99
|
+
const form = document.createElement('form');
|
|
100
|
+
form.className = 'cms-widget-form';
|
|
101
|
+
|
|
102
|
+
const input = document.createElement('input');
|
|
103
|
+
input.type = 'text';
|
|
104
|
+
input.className = 'cms-widget-input';
|
|
105
|
+
input.value = ctx.value !== undefined && ctx.value !== null ? String(ctx.value) : '';
|
|
106
|
+
input.placeholder =
|
|
107
|
+
ctx.defaultValue !== undefined && ctx.defaultValue !== null ? String(ctx.defaultValue) : '';
|
|
108
|
+
input.disabled = Boolean(ctx.disabled);
|
|
109
|
+
|
|
110
|
+
const saveBtn = document.createElement('button');
|
|
111
|
+
saveBtn.type = 'submit';
|
|
112
|
+
saveBtn.className = 'cms-widget-save-btn';
|
|
113
|
+
saveBtn.textContent = 'Save';
|
|
114
|
+
saveBtn.disabled = Boolean(ctx.disabled);
|
|
115
|
+
|
|
116
|
+
const errorBox = document.createElement('div');
|
|
117
|
+
errorBox.className = 'cms-widget-error';
|
|
118
|
+
errorBox.style.display = 'none';
|
|
119
|
+
|
|
120
|
+
form.appendChild(input);
|
|
121
|
+
form.appendChild(saveBtn);
|
|
122
|
+
container.appendChild(form);
|
|
123
|
+
container.appendChild(errorBox);
|
|
124
|
+
|
|
125
|
+
form.addEventListener('submit', async (e) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
if (!ctx.onSave || ctx.disabled) return;
|
|
128
|
+
|
|
129
|
+
saveBtn.disabled = true;
|
|
130
|
+
saveBtn.textContent = 'Saving...';
|
|
131
|
+
errorBox.style.display = 'none';
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const res = await ctx.onSave(input.value);
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
errorBox.textContent = res.error || 'Failed to save field';
|
|
137
|
+
errorBox.style.display = 'block';
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
saveBtn.disabled = Boolean(ctx.disabled);
|
|
141
|
+
saveBtn.textContent = 'Save';
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return container;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createNumberWidget(ctx: ToolbarWidgetContext): HTMLElement {
|
|
149
|
+
const container = document.createElement('div');
|
|
150
|
+
container.className = 'cms-widget-container';
|
|
151
|
+
|
|
152
|
+
const form = document.createElement('form');
|
|
153
|
+
form.className = 'cms-widget-form';
|
|
154
|
+
|
|
155
|
+
const input = document.createElement('input');
|
|
156
|
+
input.type = 'number';
|
|
157
|
+
input.className = 'cms-widget-input';
|
|
158
|
+
input.value = ctx.value !== undefined && ctx.value !== null ? String(ctx.value) : '';
|
|
159
|
+
input.placeholder =
|
|
160
|
+
ctx.defaultValue !== undefined && ctx.defaultValue !== null ? String(ctx.defaultValue) : '';
|
|
161
|
+
input.disabled = Boolean(ctx.disabled);
|
|
162
|
+
|
|
163
|
+
if (ctx.constraints?.min !== undefined) input.min = String(ctx.constraints.min);
|
|
164
|
+
if (ctx.constraints?.max !== undefined) input.max = String(ctx.constraints.max);
|
|
165
|
+
if (ctx.constraints?.step !== undefined) input.step = String(ctx.constraints.step);
|
|
166
|
+
|
|
167
|
+
const saveBtn = document.createElement('button');
|
|
168
|
+
saveBtn.type = 'submit';
|
|
169
|
+
saveBtn.className = 'cms-widget-save-btn';
|
|
170
|
+
saveBtn.textContent = 'Save';
|
|
171
|
+
saveBtn.disabled = Boolean(ctx.disabled);
|
|
172
|
+
|
|
173
|
+
const errorBox = document.createElement('div');
|
|
174
|
+
errorBox.className = 'cms-widget-error';
|
|
175
|
+
errorBox.style.display = 'none';
|
|
176
|
+
|
|
177
|
+
form.appendChild(input);
|
|
178
|
+
form.appendChild(saveBtn);
|
|
179
|
+
container.appendChild(form);
|
|
180
|
+
container.appendChild(errorBox);
|
|
181
|
+
|
|
182
|
+
form.addEventListener('submit', async (e) => {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
if (!ctx.onSave || ctx.disabled) return;
|
|
185
|
+
|
|
186
|
+
saveBtn.disabled = true;
|
|
187
|
+
saveBtn.textContent = 'Saving...';
|
|
188
|
+
errorBox.style.display = 'none';
|
|
189
|
+
|
|
190
|
+
const numVal = input.value === '' ? null : Number(input.value);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const res = await ctx.onSave(numVal);
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
errorBox.textContent = res.error || 'Failed to save field';
|
|
196
|
+
errorBox.style.display = 'block';
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
saveBtn.disabled = Boolean(ctx.disabled);
|
|
200
|
+
saveBtn.textContent = 'Save';
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return container;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createToggleWidget(ctx: ToolbarWidgetContext): HTMLElement {
|
|
208
|
+
const container = document.createElement('div');
|
|
209
|
+
container.className = 'cms-widget-container cms-widget-toggle-row';
|
|
210
|
+
|
|
211
|
+
const label = document.createElement('label');
|
|
212
|
+
label.className = 'cms-widget-toggle-label';
|
|
213
|
+
|
|
214
|
+
const input = document.createElement('input');
|
|
215
|
+
input.type = 'checkbox';
|
|
216
|
+
input.className = 'cms-widget-checkbox';
|
|
217
|
+
input.checked = Boolean(ctx.value ?? ctx.defaultValue ?? false);
|
|
218
|
+
input.disabled = Boolean(ctx.disabled);
|
|
219
|
+
|
|
220
|
+
const statusText = document.createElement('span');
|
|
221
|
+
statusText.className = 'cms-widget-toggle-text';
|
|
222
|
+
statusText.textContent = input.checked ? 'True' : 'False';
|
|
223
|
+
|
|
224
|
+
const errorBox = document.createElement('div');
|
|
225
|
+
errorBox.className = 'cms-widget-error';
|
|
226
|
+
errorBox.style.display = 'none';
|
|
227
|
+
|
|
228
|
+
label.appendChild(input);
|
|
229
|
+
label.appendChild(statusText);
|
|
230
|
+
container.appendChild(label);
|
|
231
|
+
container.appendChild(errorBox);
|
|
232
|
+
|
|
233
|
+
input.addEventListener('change', async () => {
|
|
234
|
+
statusText.textContent = input.checked ? 'True' : 'False';
|
|
235
|
+
if (!ctx.onSave || ctx.disabled) return;
|
|
236
|
+
|
|
237
|
+
input.disabled = true;
|
|
238
|
+
errorBox.style.display = 'none';
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const res = await ctx.onSave(input.checked);
|
|
242
|
+
if (!res.ok) {
|
|
243
|
+
errorBox.textContent = res.error || 'Failed to save field';
|
|
244
|
+
errorBox.style.display = 'block';
|
|
245
|
+
input.checked = !input.checked;
|
|
246
|
+
statusText.textContent = input.checked ? 'True' : 'False';
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
input.disabled = Boolean(ctx.disabled);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return container;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function createSelectWidget(ctx: ToolbarWidgetContext): HTMLElement {
|
|
257
|
+
const container = document.createElement('div');
|
|
258
|
+
container.className = 'cms-widget-container';
|
|
259
|
+
|
|
260
|
+
const select = document.createElement('select');
|
|
261
|
+
select.className = 'cms-widget-select';
|
|
262
|
+
select.disabled = Boolean(ctx.disabled);
|
|
263
|
+
|
|
264
|
+
// Optional empty placeholder option
|
|
265
|
+
const emptyOpt = document.createElement('option');
|
|
266
|
+
emptyOpt.value = '';
|
|
267
|
+
emptyOpt.textContent = '(select value)';
|
|
268
|
+
select.appendChild(emptyOpt);
|
|
269
|
+
|
|
270
|
+
const rawOptions = ctx.constraints?.options;
|
|
271
|
+
if (Array.isArray(rawOptions)) {
|
|
272
|
+
for (const opt of rawOptions) {
|
|
273
|
+
const optEl = document.createElement('option');
|
|
274
|
+
if (typeof opt === 'string') {
|
|
275
|
+
optEl.value = opt;
|
|
276
|
+
optEl.textContent = opt;
|
|
277
|
+
} else if (typeof opt === 'object' && opt !== null) {
|
|
278
|
+
const o = opt as { label?: string; value?: string };
|
|
279
|
+
optEl.value = o.value || '';
|
|
280
|
+
optEl.textContent = o.label || o.value || '';
|
|
281
|
+
}
|
|
282
|
+
select.appendChild(optEl);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const currentVal = ctx.value !== undefined && ctx.value !== null ? String(ctx.value) : '';
|
|
287
|
+
select.value = currentVal;
|
|
288
|
+
|
|
289
|
+
const errorBox = document.createElement('div');
|
|
290
|
+
errorBox.className = 'cms-widget-error';
|
|
291
|
+
errorBox.style.display = 'none';
|
|
292
|
+
|
|
293
|
+
container.appendChild(select);
|
|
294
|
+
container.appendChild(errorBox);
|
|
295
|
+
|
|
296
|
+
select.addEventListener('change', async () => {
|
|
297
|
+
if (!ctx.onSave || ctx.disabled) return;
|
|
298
|
+
|
|
299
|
+
select.disabled = true;
|
|
300
|
+
errorBox.style.display = 'none';
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const res = await ctx.onSave(select.value);
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
errorBox.textContent = res.error || 'Failed to save field';
|
|
306
|
+
errorBox.style.display = 'block';
|
|
307
|
+
select.value = currentVal;
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
select.disabled = Boolean(ctx.disabled);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return container;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function createSummaryWidget(ctx: ToolbarWidgetContext): HTMLElement {
|
|
318
|
+
const container = document.createElement('div');
|
|
319
|
+
container.className = 'cms-widget-summary-row';
|
|
320
|
+
|
|
321
|
+
const summary = document.createElement('div');
|
|
322
|
+
summary.className = 'cms-widget-summary-text';
|
|
323
|
+
|
|
324
|
+
const normType = (ctx.typeId || '').toLowerCase();
|
|
325
|
+
if (normType === 'richtext') {
|
|
326
|
+
summary.textContent = extractRichTextSummary(ctx.value ?? ctx.defaultValue);
|
|
327
|
+
} else if (normType === 'image') {
|
|
328
|
+
summary.textContent = extractImageSummary(ctx.value ?? ctx.defaultValue);
|
|
329
|
+
} else {
|
|
330
|
+
const v = ctx.value ?? ctx.defaultValue;
|
|
331
|
+
summary.textContent = v !== undefined && v !== null ? JSON.stringify(v) : '(no value)';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const cleanOrigin = (ctx.editorOrigin || DEFAULT_EDITOR_ORIGINS[0]).replace(/\/$/, '');
|
|
335
|
+
const editorUrl = `${cleanOrigin}/sites/${encodeURIComponent(ctx.siteId)}/editor?path=${encodeURIComponent(ctx.routePath || '/')}&field=${encodeURIComponent(ctx.key)}`;
|
|
336
|
+
|
|
337
|
+
const link = document.createElement('a');
|
|
338
|
+
link.href = editorUrl;
|
|
339
|
+
link.target = '_blank';
|
|
340
|
+
link.rel = 'noopener noreferrer';
|
|
341
|
+
link.className = 'cms-widget-editor-link';
|
|
342
|
+
link.innerHTML = 'Open in Editor ↗';
|
|
343
|
+
|
|
344
|
+
container.appendChild(summary);
|
|
345
|
+
container.appendChild(link);
|
|
346
|
+
|
|
347
|
+
return container;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const widgetRegistry = new Map<string, ToolbarWidgetRenderer>([
|
|
351
|
+
['text', createTextWidget],
|
|
352
|
+
['number', createNumberWidget],
|
|
353
|
+
['toggle', createToggleWidget],
|
|
354
|
+
['select', createSelectWidget],
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
export function registerToolbarWidget(typeId: string, renderer: ToolbarWidgetRenderer) {
|
|
358
|
+
widgetRegistry.set(typeId.toLowerCase(), renderer);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function resolveToolbarWidget(typeId?: string | null): ToolbarWidgetRenderer {
|
|
362
|
+
if (!typeId) return createSummaryWidget;
|
|
363
|
+
const normalized = typeId.toLowerCase();
|
|
364
|
+
return widgetRegistry.get(normalized) || createSummaryWidget;
|
|
365
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { type BoundGroupAccessor, type CollectionEntry, defineCollection, f, z } from './fields';
|
|
3
|
+
import type { CmsClient, ContentBundle } from './types';
|
|
4
|
+
|
|
5
|
+
describe('Astro SDK Type Invariants', () => {
|
|
6
|
+
it('correctly types ContentBundle shape', () => {
|
|
7
|
+
const bundle: ContentBundle = {
|
|
8
|
+
bundleVersion: 1,
|
|
9
|
+
site_id: 's1',
|
|
10
|
+
fields: { title: 'Test' },
|
|
11
|
+
collections: {
|
|
12
|
+
posts: {
|
|
13
|
+
presentation: 'visual',
|
|
14
|
+
orderable: true,
|
|
15
|
+
schema_version: 1,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
entries: {
|
|
19
|
+
posts: [
|
|
20
|
+
{
|
|
21
|
+
slug: 'post-1',
|
|
22
|
+
content: { title: 'Post' },
|
|
23
|
+
status: 'published',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
routes: [{ path: '/', kind: 'static' }],
|
|
28
|
+
settings: {},
|
|
29
|
+
redirects: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
expectTypeOf(bundle.bundleVersion).toEqualTypeOf<1>();
|
|
33
|
+
expectTypeOf(bundle.fields).toEqualTypeOf<Record<string, unknown>>();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('correctly types CmsClient methods', () => {
|
|
37
|
+
expectTypeOf<CmsClient['getBundle']>().toBeFunction();
|
|
38
|
+
expectTypeOf<CmsClient['getFields']>().toBeFunction();
|
|
39
|
+
expectTypeOf<CmsClient['getCollections']>().toBeFunction();
|
|
40
|
+
expectTypeOf<CmsClient['getEntries']>().toBeFunction();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('correctly types scope key composition and helpers', () => {
|
|
44
|
+
const reviews = f.scope('home.reviews');
|
|
45
|
+
expectTypeOf(reviews.key('title')).toEqualTypeOf<string>();
|
|
46
|
+
expectTypeOf(reviews('title')).toEqualTypeOf<string>();
|
|
47
|
+
expectTypeOf(reviews.text('title')).toEqualTypeOf<string>();
|
|
48
|
+
expectTypeOf(reviews.number('count')).toEqualTypeOf<number>();
|
|
49
|
+
expectTypeOf(reviews.toggle('enabled')).toEqualTypeOf<boolean>();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('correctly types group definition and component prop drilling', () => {
|
|
53
|
+
const Hero = f.group('Hero', {
|
|
54
|
+
heading: f.text,
|
|
55
|
+
subheading: f.text,
|
|
56
|
+
image: f.img,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const hero = Hero('home.hero');
|
|
60
|
+
expectTypeOf(hero.attrs()).toEqualTypeOf<{ 'data-cms-group': string }>();
|
|
61
|
+
expectTypeOf(hero.heading('title')).toEqualTypeOf<string>();
|
|
62
|
+
|
|
63
|
+
interface HeroProps {
|
|
64
|
+
content: BoundGroupAccessor<{
|
|
65
|
+
heading: typeof f.text;
|
|
66
|
+
subheading: typeof f.text;
|
|
67
|
+
image: typeof f.img;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const testProps: HeroProps = { content: hero };
|
|
72
|
+
expectTypeOf(testProps.content.attrs).toBeFunction();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('correctly infers Zod schema types on f.entries and f.entry', () => {
|
|
76
|
+
const postSchema = z.object({
|
|
77
|
+
title: z.string(),
|
|
78
|
+
views: z.number(),
|
|
79
|
+
tags: z.array(z.string()),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const posts = defineCollection({
|
|
83
|
+
key: 'posts',
|
|
84
|
+
schema: postSchema,
|
|
85
|
+
route: '/posts/[slug]',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
type InferredPost = z.infer<typeof postSchema>;
|
|
89
|
+
const entries = f.entries(posts);
|
|
90
|
+
expectTypeOf(entries).toEqualTypeOf<Array<CollectionEntry<InferredPost>>>();
|
|
91
|
+
|
|
92
|
+
const entry = f.entry(posts, 'hello');
|
|
93
|
+
expectTypeOf(entry).toEqualTypeOf<CollectionEntry<InferredPost> | null>();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('enforces CmsAsset and CmsImage type invariants', () => {
|
|
97
|
+
type ImageProps = {
|
|
98
|
+
src: import('./types').CmsAsset | string;
|
|
99
|
+
alt: string;
|
|
100
|
+
widths?: number[];
|
|
101
|
+
};
|
|
102
|
+
expectTypeOf<ImageProps['alt']>().toEqualTypeOf<string>();
|
|
103
|
+
|
|
104
|
+
const asset: import('./types').CmsAsset = {
|
|
105
|
+
id: 'a1',
|
|
106
|
+
url: 'https://example.com/asset.png',
|
|
107
|
+
alt: 'Sample',
|
|
108
|
+
focal_x: 0.5,
|
|
109
|
+
focal_y: 0.5,
|
|
110
|
+
mime_type: 'image/png',
|
|
111
|
+
filename: 'asset.png',
|
|
112
|
+
kind: 'image',
|
|
113
|
+
duration_ms: null,
|
|
114
|
+
renditions: {
|
|
115
|
+
hero: {
|
|
116
|
+
key: 'sites/s1/assets/a1/hero.12345678.mp4',
|
|
117
|
+
url: 'https://cdn.example.com/hero.mp4',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
status: 'ready',
|
|
121
|
+
};
|
|
122
|
+
expectTypeOf(asset.kind).toEqualTypeOf<
|
|
123
|
+
'image' | 'video' | 'audio' | 'file' | string | undefined
|
|
124
|
+
>();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('supports seed option on defineCollection and passes it through on the handle', () => {
|
|
128
|
+
const faqSchema = z.object({
|
|
129
|
+
question: z.string(),
|
|
130
|
+
answer: z.string(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const faqs = defineCollection({
|
|
134
|
+
key: 'faqs',
|
|
135
|
+
schema: faqSchema,
|
|
136
|
+
seed: [
|
|
137
|
+
{
|
|
138
|
+
slug: 'free-estimates',
|
|
139
|
+
content: {
|
|
140
|
+
question: 'Do you offer free estimates?',
|
|
141
|
+
answer: 'Yes, we do.',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(faqs.seed).toHaveLength(1);
|
|
148
|
+
expect(faqs.seed?.[0].slug).toBe('free-estimates');
|
|
149
|
+
expect(faqs.seed?.[0].content.question).toBe('Do you offer free estimates?');
|
|
150
|
+
expectTypeOf(faqs.seed).toEqualTypeOf<
|
|
151
|
+
Array<{ slug: string; content: { question: string; answer: string } }> | undefined
|
|
152
|
+
>();
|
|
153
|
+
});
|
|
154
|
+
});
|