@contentful/experiences-svelte 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +107 -0
  3. package/dist/ClientExperienceRenderer.svelte +81 -0
  4. package/dist/ClientExperienceRenderer.svelte.d.ts +4 -0
  5. package/dist/ClientExperienceRenderer.svelte.d.ts.map +1 -0
  6. package/dist/MissingComponent.svelte +35 -0
  7. package/dist/MissingComponent.svelte.d.ts +5 -0
  8. package/dist/MissingComponent.svelte.d.ts.map +1 -0
  9. package/dist/NodeRenderer.svelte +83 -0
  10. package/dist/NodeRenderer.svelte.d.ts +15 -0
  11. package/dist/NodeRenderer.svelte.d.ts.map +1 -0
  12. package/dist/NodesRenderer.svelte +51 -0
  13. package/dist/NodesRenderer.svelte.d.ts +14 -0
  14. package/dist/NodesRenderer.svelte.d.ts.map +1 -0
  15. package/dist/ServerExperienceRenderer.svelte +70 -0
  16. package/dist/ServerExperienceRenderer.svelte.d.ts +5 -0
  17. package/dist/ServerExperienceRenderer.svelte.d.ts.map +1 -0
  18. package/dist/WrapWithTemplate.svelte +87 -0
  19. package/dist/WrapWithTemplate.svelte.d.ts +13 -0
  20. package/dist/WrapWithTemplate.svelte.d.ts.map +1 -0
  21. package/dist/component-props.d.ts +18 -0
  22. package/dist/component-props.d.ts.map +1 -0
  23. package/dist/component-props.js +13 -0
  24. package/dist/context.d.ts +20 -0
  25. package/dist/context.d.ts.map +1 -0
  26. package/dist/context.js +61 -0
  27. package/dist/design-utils.d.ts +16 -0
  28. package/dist/design-utils.d.ts.map +1 -0
  29. package/dist/design-utils.js +27 -0
  30. package/dist/get-design-values.d.ts +14 -0
  31. package/dist/get-design-values.d.ts.map +1 -0
  32. package/dist/get-design-values.js +16 -0
  33. package/dist/index.d.ts +21 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +34 -0
  36. package/dist/server-renderer.test.d.ts +2 -0
  37. package/dist/server-renderer.test.d.ts.map +1 -0
  38. package/dist/server-renderer.test.js +566 -0
  39. package/dist/test-fixtures/ButtonFixture.svelte +8 -0
  40. package/dist/test-fixtures/ButtonFixture.svelte.d.ts +7 -0
  41. package/dist/test-fixtures/ButtonFixture.svelte.d.ts.map +1 -0
  42. package/dist/test-fixtures/CapturingComponent.svelte +18 -0
  43. package/dist/test-fixtures/CapturingComponent.svelte.d.ts +4 -0
  44. package/dist/test-fixtures/CapturingComponent.svelte.d.ts.map +1 -0
  45. package/dist/test-fixtures/ContainerFixture.svelte +12 -0
  46. package/dist/test-fixtures/ContainerFixture.svelte.d.ts +8 -0
  47. package/dist/test-fixtures/ContainerFixture.svelte.d.ts.map +1 -0
  48. package/dist/test-fixtures/HeadingFixture.svelte +8 -0
  49. package/dist/test-fixtures/HeadingFixture.svelte.d.ts +7 -0
  50. package/dist/test-fixtures/HeadingFixture.svelte.d.ts.map +1 -0
  51. package/dist/test-fixtures/ItemFixture.svelte +11 -0
  52. package/dist/test-fixtures/ItemFixture.svelte.d.ts +8 -0
  53. package/dist/test-fixtures/ItemFixture.svelte.d.ts.map +1 -0
  54. package/dist/test-fixtures/PrecedenceFixture.svelte +5 -0
  55. package/dist/test-fixtures/PrecedenceFixture.svelte.d.ts +7 -0
  56. package/dist/test-fixtures/PrecedenceFixture.svelte.d.ts.map +1 -0
  57. package/dist/test-fixtures/TemplateFixture.svelte +18 -0
  58. package/dist/test-fixtures/TemplateFixture.svelte.d.ts +9 -0
  59. package/dist/test-fixtures/TemplateFixture.svelte.d.ts.map +1 -0
  60. package/dist/test-fixtures/capture-sink.d.ts +10 -0
  61. package/dist/test-fixtures/capture-sink.d.ts.map +1 -0
  62. package/dist/test-fixtures/capture-sink.js +5 -0
  63. package/dist/types.d.ts +99 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/types.js +26 -0
  66. package/dist/use-active-viewport.svelte.d.ts +14 -0
  67. package/dist/use-active-viewport.svelte.d.ts.map +1 -0
  68. package/dist/use-active-viewport.svelte.js +83 -0
  69. package/package.json +55 -0
@@ -0,0 +1,566 @@
1
+ import { render } from '@testing-library/svelte';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { resolveExperience } from '@contentful/experiences-core';
4
+ import ServerExperienceRenderer from './ServerExperienceRenderer.svelte';
5
+ import CapturingComponent from './test-fixtures/CapturingComponent.svelte';
6
+ import ButtonFixture from './test-fixtures/ButtonFixture.svelte';
7
+ import ContainerFixture from './test-fixtures/ContainerFixture.svelte';
8
+ import HeadingFixture from './test-fixtures/HeadingFixture.svelte';
9
+ import ItemFixture from './test-fixtures/ItemFixture.svelte';
10
+ import PrecedenceFixture from './test-fixtures/PrecedenceFixture.svelte';
11
+ import TemplateFixture from './test-fixtures/TemplateFixture.svelte';
12
+ import { captureSink } from './test-fixtures/capture-sink.js';
13
+ import { toCss } from './design-utils.js';
14
+ const VIEWPORTS = [
15
+ { id: 'desktop', query: '*', displayName: 'Desktop', previewSize: '100%' },
16
+ { id: 'tablet', query: '<992px', displayName: 'Tablet', previewSize: '100%' },
17
+ { id: 'mobile', query: '<576px', displayName: 'Mobile', previewSize: '100%' },
18
+ ];
19
+ const m = (value) => ({ type: 'ManualDesignValue', value });
20
+ const vbv = (values) => ({
21
+ type: 'ValuesByViewport',
22
+ values,
23
+ });
24
+ const dt = (value) => ({ type: 'DesignToken', value });
25
+ function componentNode(typeId, rest = {}) {
26
+ return {
27
+ componentType: {
28
+ sys: {
29
+ type: 'ResourceLink',
30
+ linkType: 'Contentful:ComponentType',
31
+ urn: `crn:contentful:::experience:spaces/$self/environments/$self/componentTypes/${typeId}`,
32
+ },
33
+ },
34
+ ...rest,
35
+ };
36
+ }
37
+ beforeEach(() => {
38
+ captureSink.splice(0);
39
+ });
40
+ const config = {
41
+ components: {
42
+ 'contentful-container': ContainerFixture,
43
+ 'contentful-heading': HeadingFixture,
44
+ 'contentful-button': ButtonFixture,
45
+ },
46
+ };
47
+ const payload = {
48
+ viewports: VIEWPORTS,
49
+ nodes: [
50
+ componentNode('contentful-container', {
51
+ id: 'page',
52
+ contentProperties: {},
53
+ designProperties: {
54
+ cfPadding: vbv({ desktop: m('40px'), mobile: m('12px') }),
55
+ },
56
+ slots: {
57
+ children: [
58
+ componentNode('contentful-heading', {
59
+ id: 'heading',
60
+ contentProperties: { text: 'Build faster' },
61
+ designProperties: {
62
+ cfFontSize: vbv({ desktop: m('32px'), mobile: m('20px') }),
63
+ },
64
+ }),
65
+ componentNode('contentful-button', {
66
+ id: 'btn',
67
+ contentProperties: { label: 'Get started' },
68
+ designProperties: {
69
+ cfBackgroundColor: vbv({
70
+ desktop: m('#4f39f6'),
71
+ tablet: m('#ff0000'),
72
+ mobile: m('#00aa00'),
73
+ }),
74
+ },
75
+ }),
76
+ ],
77
+ },
78
+ }),
79
+ ],
80
+ };
81
+ describe('ServerExperienceRenderer', () => {
82
+ it('renders a nested experience with desktop-resolved design props by default', async () => {
83
+ const plan = await resolveExperience(payload, config);
84
+ const { container } = render(ServerExperienceRenderer, {
85
+ props: { experience: plan, config },
86
+ });
87
+ const html = container.innerHTML;
88
+ expect(html).toContain('data-padding="40px"');
89
+ expect(html).toContain('data-font-size="32px"');
90
+ expect(html).toContain('data-bg="#4f39f6"');
91
+ expect(html).toContain('Build faster');
92
+ expect(html).toContain('Get started');
93
+ });
94
+ it('honors initialViewportId when resolving design props', async () => {
95
+ const plan = await resolveExperience(payload, config);
96
+ const { container } = render(ServerExperienceRenderer, {
97
+ props: { experience: plan, config, initialViewportId: 'mobile' },
98
+ });
99
+ const html = container.innerHTML;
100
+ expect(html).toContain('data-padding="12px"');
101
+ expect(html).toContain('data-font-size="20px"');
102
+ expect(html).toContain('data-bg="#00aa00"');
103
+ });
104
+ it('cascades design values when the active viewport has none', async () => {
105
+ const plan = await resolveExperience(payload, config);
106
+ const { container } = render(ServerExperienceRenderer, {
107
+ props: { experience: plan, config, initialViewportId: 'tablet' },
108
+ });
109
+ const html = container.innerHTML;
110
+ expect(html).toContain('data-font-size="32px"'); // cascaded from desktop
111
+ expect(html).toContain('data-bg="#ff0000"'); // tablet-specific
112
+ });
113
+ it('renders nothing meaningful when plan is null/undefined', () => {
114
+ const { container: nullContainer } = render(ServerExperienceRenderer, {
115
+ props: { experience: null, config },
116
+ });
117
+ expect(nullContainer.querySelector('*')).toBeNull();
118
+ const { container: undefContainer } = render(ServerExperienceRenderer, {
119
+ props: { experience: undefined, config },
120
+ });
121
+ expect(undefContainer.querySelector('*')).toBeNull();
122
+ });
123
+ it('exposes the active viewport on render context (defaults to viewport[0])', async () => {
124
+ const captureConfig = { components: { capture: CapturingComponent } };
125
+ const plan = await resolveExperience({ viewports: VIEWPORTS, nodes: [componentNode('capture')] }, captureConfig);
126
+ render(ServerExperienceRenderer, {
127
+ props: { experience: plan, config: captureConfig },
128
+ });
129
+ expect(captureSink.length).toBe(1);
130
+ const ctx = captureSink[0].experience;
131
+ expect(ctx.activeViewportIndex).toBe(0);
132
+ expect(ctx.activeViewport).toBe(VIEWPORTS[0]);
133
+ expect(ctx.viewports).toBe(VIEWPORTS);
134
+ });
135
+ it('honors initialViewportId when computing the active viewport', async () => {
136
+ const captureConfig = { components: { capture: CapturingComponent } };
137
+ const plan = await resolveExperience({ viewports: VIEWPORTS, nodes: [componentNode('capture')] }, captureConfig);
138
+ render(ServerExperienceRenderer, {
139
+ props: {
140
+ experience: plan,
141
+ config: captureConfig,
142
+ initialViewportId: 'mobile',
143
+ },
144
+ });
145
+ const ctx = captureSink[0].experience;
146
+ expect(ctx.activeViewportIndex).toBe(2);
147
+ expect(ctx.activeViewport).toBe(VIEWPORTS[2]);
148
+ });
149
+ it('renders missing-component fallback in preview mode', () => {
150
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
151
+ const justContainer = {
152
+ components: { 'contentful-container': ContainerFixture },
153
+ };
154
+ const planWithMissing = {
155
+ viewports: VIEWPORTS,
156
+ nodes: [
157
+ {
158
+ nodeId: 'root',
159
+ registration: { componentTypeId: 'contentful-container' },
160
+ props: { content: {}, design: {} },
161
+ slots: {
162
+ children: [
163
+ {
164
+ nodeId: 'ghost',
165
+ registration: { componentTypeId: 'NotRegistered' },
166
+ props: { content: {}, design: {} },
167
+ slots: {},
168
+ },
169
+ ],
170
+ },
171
+ },
172
+ ],
173
+ };
174
+ const { container: previewContainer } = render(ServerExperienceRenderer, {
175
+ props: {
176
+ experience: planWithMissing,
177
+ config: justContainer,
178
+ context: { isPreview: true },
179
+ },
180
+ });
181
+ expect(previewContainer.innerHTML).toContain('data-experiences-missing="NotRegistered"');
182
+ const { container: prodContainer } = render(ServerExperienceRenderer, {
183
+ props: { experience: planWithMissing, config: justContainer },
184
+ });
185
+ expect(prodContainer.innerHTML).not.toContain('data-experiences-missing');
186
+ warn.mockRestore();
187
+ });
188
+ it('merges defaults beneath content (content wins)', async () => {
189
+ const itemConfig = {
190
+ components: {
191
+ item: {
192
+ defaults: { variant: 'fallback', priority: 'low' },
193
+ component: ItemFixture,
194
+ },
195
+ },
196
+ };
197
+ const plan = await resolveExperience({
198
+ viewports: VIEWPORTS,
199
+ nodes: [
200
+ componentNode('item', {
201
+ id: 'i',
202
+ contentProperties: { variant: 'fromContent' },
203
+ }),
204
+ ],
205
+ }, itemConfig);
206
+ const { container } = render(ServerExperienceRenderer, {
207
+ props: { experience: plan, config: itemConfig },
208
+ });
209
+ expect(container.innerHTML).toContain('data-variant="fromContent"');
210
+ expect(container.innerHTML).toContain('data-priority="low"');
211
+ });
212
+ it('respects merge precedence: defaults < content < resolved', () => {
213
+ const precedenceConfig = {
214
+ components: {
215
+ item: {
216
+ defaults: { value: 'fromDefault' },
217
+ component: PrecedenceFixture,
218
+ },
219
+ },
220
+ };
221
+ const planWithResolved = {
222
+ viewports: VIEWPORTS,
223
+ nodes: [
224
+ {
225
+ nodeId: 'r',
226
+ registration: { componentTypeId: 'item' },
227
+ props: {
228
+ content: { value: 'fromContent' },
229
+ design: {},
230
+ resolved: { value: 'fromResolveData' },
231
+ },
232
+ slots: {},
233
+ },
234
+ ],
235
+ };
236
+ const { container } = render(ServerExperienceRenderer, {
237
+ props: { experience: planWithResolved, config: precedenceConfig },
238
+ });
239
+ expect(container.innerHTML).toContain('data-value="fromResolveData"');
240
+ });
241
+ it('wraps rendered nodes with the registered template', async () => {
242
+ const tplConfig = {
243
+ components: { item: PrecedenceFixture },
244
+ templates: {
245
+ page: { component: TemplateFixture, defaults: { title: 'Default Title' } },
246
+ },
247
+ };
248
+ const tplPayload = {
249
+ sys: {
250
+ template: {
251
+ sys: {
252
+ type: 'ResourceLink',
253
+ linkType: 'Contentful:Template',
254
+ urn: 'crn:contentful:::experience:spaces/$self/environments/$self/templates/page',
255
+ },
256
+ },
257
+ },
258
+ viewports: VIEWPORTS,
259
+ nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'inside' } })],
260
+ };
261
+ const plan = await resolveExperience(tplPayload, tplConfig);
262
+ const { container } = render(ServerExperienceRenderer, {
263
+ props: { experience: plan, config: tplConfig },
264
+ });
265
+ expect(container.innerHTML).toContain('data-template="page"');
266
+ expect(container.innerHTML).toContain('data-title="Default Title"');
267
+ expect(container.innerHTML).toContain('inside');
268
+ });
269
+ it('renders nodes unwrapped + warns when the template is not registered', async () => {
270
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
271
+ const cfg = { components: { item: PrecedenceFixture } };
272
+ const tplPayload = {
273
+ sys: {
274
+ template: {
275
+ sys: {
276
+ type: 'ResourceLink',
277
+ linkType: 'Contentful:Template',
278
+ urn: 'crn:contentful:::experience:spaces/$self/environments/$self/templates/missing-template',
279
+ },
280
+ },
281
+ },
282
+ viewports: VIEWPORTS,
283
+ nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'unwrapped' } })],
284
+ };
285
+ const plan = await resolveExperience(tplPayload, cfg);
286
+ const { container } = render(ServerExperienceRenderer, {
287
+ props: { experience: plan, config: cfg },
288
+ });
289
+ expect(container.innerHTML).toContain('data-value="unwrapped"');
290
+ expect(container.innerHTML).not.toContain('data-template');
291
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing-template'));
292
+ warn.mockRestore();
293
+ });
294
+ });
295
+ describe('ServerExperienceRenderer — bare-component registrations', () => {
296
+ it('accepts a bare Svelte component as a registry entry', async () => {
297
+ const cfg = { components: { item: PrecedenceFixture } };
298
+ const plan = await resolveExperience({
299
+ viewports: VIEWPORTS,
300
+ nodes: [componentNode('item', { id: 'b', contentProperties: { value: 'hi' } })],
301
+ }, cfg);
302
+ const { container } = render(ServerExperienceRenderer, {
303
+ props: { experience: plan, config: cfg },
304
+ });
305
+ expect(container.innerHTML).toContain('data-value="hi"');
306
+ });
307
+ it('does NOT spread experience/contentful as props onto components', async () => {
308
+ const cfg = { components: { capture: CapturingComponent } };
309
+ const plan = await resolveExperience({
310
+ viewports: VIEWPORTS,
311
+ nodes: [
312
+ componentNode('capture', {
313
+ id: 'c',
314
+ contentProperties: { text: 'hi' },
315
+ }),
316
+ ],
317
+ }, cfg);
318
+ render(ServerExperienceRenderer, {
319
+ props: { experience: plan, config: cfg },
320
+ });
321
+ const keys = Object.keys(captureSink[0].props);
322
+ expect(keys).toContain('text');
323
+ expect(keys).not.toContain('experience');
324
+ expect(keys).not.toContain('contentful');
325
+ // Children is always injected as a Snippet by the renderer.
326
+ expect(keys).toContain('children');
327
+ });
328
+ });
329
+ describe('ServerExperienceRenderer — getContentfulComponent()', () => {
330
+ it('exposes the raw Contentful payload to descendants', async () => {
331
+ const cfg = { components: { capture: CapturingComponent } };
332
+ const plan = await resolveExperience({
333
+ viewports: VIEWPORTS,
334
+ nodes: [
335
+ componentNode('capture', {
336
+ id: 'btn-1',
337
+ contentProperties: { label: 'Buy now' },
338
+ designProperties: { cfPadding: vbv({ desktop: m('40px') }) },
339
+ }),
340
+ ],
341
+ }, cfg);
342
+ render(ServerExperienceRenderer, {
343
+ props: { experience: plan, config: cfg },
344
+ });
345
+ const contentful = captureSink[0].contentful;
346
+ expect(contentful).toMatchObject({
347
+ componentTypeId: 'capture',
348
+ nodeId: 'btn-1',
349
+ content: { label: 'Buy now' },
350
+ design: { cfPadding: vbv({ desktop: m('40px') }) },
351
+ resolved: undefined,
352
+ });
353
+ });
354
+ it('contentful.resolved carries the resolveData return value', async () => {
355
+ const cfg = {
356
+ components: {
357
+ capture: {
358
+ resolveData: () => ({ enriched: 'yes' }),
359
+ component: CapturingComponent,
360
+ },
361
+ },
362
+ };
363
+ const plan = await resolveExperience({ viewports: VIEWPORTS, nodes: [componentNode('capture', { id: 'i' })] }, cfg);
364
+ render(ServerExperienceRenderer, {
365
+ props: { experience: plan, config: cfg },
366
+ });
367
+ expect(captureSink[0].contentful?.resolved).toEqual({ enriched: 'yes' });
368
+ });
369
+ });
370
+ describe('ServerExperienceRenderer — resolveToken', () => {
371
+ it('passes DesignToken envelopes through the resolver before render', async () => {
372
+ const cfg = {
373
+ components: { 'contentful-button': ButtonFixture },
374
+ resolveToken: (ref) => (ref.value === 'color/surface/hero' ? '#4f39f6' : undefined),
375
+ };
376
+ const plan = await resolveExperience({
377
+ viewports: VIEWPORTS,
378
+ nodes: [
379
+ componentNode('contentful-button', {
380
+ id: 'b',
381
+ contentProperties: { label: 'Go' },
382
+ designProperties: { cfBackgroundColor: dt('color/surface/hero') },
383
+ }),
384
+ ],
385
+ }, cfg);
386
+ const { container } = render(ServerExperienceRenderer, {
387
+ props: { experience: plan, config: cfg },
388
+ });
389
+ expect(container.innerHTML).toContain('data-bg="#4f39f6"');
390
+ expect(container.innerHTML).not.toContain('DesignToken');
391
+ });
392
+ it('warns and drops the key from the design bag when the resolver returns undefined', async () => {
393
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
394
+ const cfg = {
395
+ components: { 'contentful-button': CapturingComponent },
396
+ resolveToken: () => undefined,
397
+ };
398
+ const plan = await resolveExperience({
399
+ viewports: VIEWPORTS,
400
+ nodes: [
401
+ componentNode('contentful-button', {
402
+ id: 'b',
403
+ contentProperties: { label: 'Go' },
404
+ designProperties: { cfBackgroundColor: dt('color/unknown') },
405
+ }),
406
+ ],
407
+ }, cfg);
408
+ render(ServerExperienceRenderer, {
409
+ props: { experience: plan, config: cfg },
410
+ });
411
+ expect(captureSink[0].designValues).not.toHaveProperty('cfBackgroundColor');
412
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('color/unknown'));
413
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('contentful-button'));
414
+ warn.mockRestore();
415
+ });
416
+ it('leaves envelopes untouched when no resolver is supplied (backward-compatible)', async () => {
417
+ const cfg = { components: { 'contentful-button': ButtonFixture } };
418
+ const plan = await resolveExperience({
419
+ viewports: VIEWPORTS,
420
+ nodes: [
421
+ componentNode('contentful-button', {
422
+ id: 'b',
423
+ contentProperties: { label: 'Go' },
424
+ designProperties: { cfBackgroundColor: dt('color/surface/hero') },
425
+ }),
426
+ ],
427
+ }, cfg);
428
+ const { container } = render(ServerExperienceRenderer, {
429
+ props: { experience: plan, config: cfg },
430
+ });
431
+ // Svelte stringifies the envelope object into "[object Object]" on the
432
+ // attribute — the point is that the raw envelope reaches the component
433
+ // unchanged, so the customer can inspect it or resolve it themselves.
434
+ expect(container.innerHTML).toContain('data-bg="[object Object]"');
435
+ });
436
+ it('runs on page-level template design props too', async () => {
437
+ const tplConfig = {
438
+ components: { item: PrecedenceFixture },
439
+ templates: { page: TemplateFixture },
440
+ resolveToken: (ref) => (ref.value === 'brand/canvas' ? '#111827' : undefined),
441
+ };
442
+ const tplPayload = {
443
+ sys: {
444
+ template: {
445
+ sys: {
446
+ type: 'ResourceLink',
447
+ linkType: 'Contentful:Template',
448
+ urn: 'crn:contentful:::experience:spaces/$self/environments/$self/templates/page',
449
+ },
450
+ },
451
+ },
452
+ viewports: VIEWPORTS,
453
+ nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'ok' } })],
454
+ };
455
+ const plan = await resolveExperience(tplPayload, tplConfig);
456
+ // Templates don't carry design props in the current XDA payload shape;
457
+ // seed one on the resolved plan so we exercise the template path.
458
+ plan.template.props.design = { cfBackground: dt('brand/canvas') };
459
+ const { container } = render(ServerExperienceRenderer, {
460
+ props: { experience: plan, config: tplConfig },
461
+ });
462
+ expect(container.innerHTML).toContain('data-bg="#111827"');
463
+ });
464
+ });
465
+ describe('ServerExperienceRenderer — design values are not injected as props', () => {
466
+ it('does NOT spread resolved design values onto component props', async () => {
467
+ const cfg = { components: { capture: CapturingComponent } };
468
+ const plan = await resolveExperience({
469
+ viewports: VIEWPORTS,
470
+ nodes: [
471
+ componentNode('capture', {
472
+ id: 'p',
473
+ contentProperties: { label: 'keep me' },
474
+ designProperties: { cfBackgroundColor: m('#f00'), cfPadding: m('10px') },
475
+ }),
476
+ ],
477
+ }, cfg);
478
+ render(ServerExperienceRenderer, {
479
+ props: { experience: plan, config: cfg },
480
+ });
481
+ const keys = Object.keys(captureSink[0].props);
482
+ // Content still flows as a prop; design does not.
483
+ expect(keys).toContain('label');
484
+ expect(keys).not.toContain('cfBackgroundColor');
485
+ expect(keys).not.toContain('cfPadding');
486
+ // The values are still readable through getDesignValues().
487
+ expect(captureSink[0].designValues).toEqual({
488
+ cfBackgroundColor: '#f00',
489
+ cfPadding: '10px',
490
+ });
491
+ });
492
+ });
493
+ describe('ServerExperienceRenderer — getDesignValues()', () => {
494
+ it('returns the resolved design bag for the current node', async () => {
495
+ const cfg = {
496
+ components: { capture: CapturingComponent },
497
+ resolveToken: (ref) => (ref.value === 'brand/primary' ? '#4f39f6' : undefined),
498
+ };
499
+ const plan = await resolveExperience({
500
+ viewports: VIEWPORTS,
501
+ nodes: [
502
+ componentNode('capture', {
503
+ id: 'p',
504
+ designProperties: {
505
+ cfBackgroundColor: dt('brand/primary'),
506
+ cfPadding: m('24px'),
507
+ },
508
+ }),
509
+ ],
510
+ }, cfg);
511
+ render(ServerExperienceRenderer, { props: { experience: plan, config: cfg } });
512
+ expect(captureSink[0].designValues).toEqual({
513
+ cfBackgroundColor: '#4f39f6',
514
+ cfPadding: '24px',
515
+ });
516
+ });
517
+ it('honors the active viewport when reading design values', async () => {
518
+ const cfg = { components: { capture: CapturingComponent } };
519
+ const plan = await resolveExperience({
520
+ viewports: VIEWPORTS,
521
+ nodes: [
522
+ componentNode('capture', {
523
+ id: 'p',
524
+ designProperties: {
525
+ cfPadding: vbv({ desktop: m('40px'), mobile: m('12px') }),
526
+ },
527
+ }),
528
+ ],
529
+ }, cfg);
530
+ render(ServerExperienceRenderer, {
531
+ props: { experience: plan, config: cfg, initialViewportId: 'mobile' },
532
+ });
533
+ expect(captureSink[0].designValues).toEqual({ cfPadding: '12px' });
534
+ });
535
+ });
536
+ describe('toCss (Svelte)', () => {
537
+ it('converts bare (non-cf) CSS keys — the shape real payloads use', () => {
538
+ expect(toCss({ fontSize: '20px', backgroundColor: '#4f39f6' })).toEqual({
539
+ fontSize: '20px',
540
+ backgroundColor: '#4f39f6',
541
+ });
542
+ });
543
+ it('still handles cf-prefixed and kebab/snake CSS keys', () => {
544
+ expect(toCss({ cfBackgroundColor: '#4f39f6', 'font-size': '10px', font_weight: 700 })).toEqual({
545
+ backgroundColor: '#4f39f6',
546
+ fontSize: '10px',
547
+ fontWeight: 700,
548
+ });
549
+ });
550
+ it('drops keys that are not known CSS properties (variant, as, ratio, target)', () => {
551
+ expect(toCss({ backgroundColor: '#4f39f6', variant: 'h1', as: 'h2', ratio: '1:2', target: '_self' })).toEqual({ backgroundColor: '#4f39f6' });
552
+ });
553
+ it('drops non-scalar (object/array/bool) values by design', () => {
554
+ // toCss produces something spreadable into an inline style — booleans
555
+ // and objects can't be inline-styled without opinionation, so they drop.
556
+ expect(toCss({
557
+ padding: '10px',
558
+ reverse: true,
559
+ nested: { color: 'red' },
560
+ })).toEqual({ padding: '10px' });
561
+ });
562
+ it('respects include/exclude lists', () => {
563
+ expect(toCss({ backgroundColor: '#4f39f6', padding: '10px' }, { include: ['backgroundColor'] })).toEqual({ backgroundColor: '#4f39f6' });
564
+ expect(toCss({ backgroundColor: '#4f39f6', padding: '10px' }, { exclude: ['padding'] })).toEqual({ backgroundColor: '#4f39f6' });
565
+ });
566
+ });
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import { getDesignValues } from '../get-design-values.js';
3
+
4
+ let { label }: { label?: string } = $props();
5
+ const design = $derived(getDesignValues());
6
+ </script>
7
+
8
+ <button type="button" data-bg={design.cfBackgroundColor as string}>{label}</button>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ label?: string;
3
+ };
4
+ declare const ButtonFixture: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type ButtonFixture = ReturnType<typeof ButtonFixture>;
6
+ export default ButtonFixture;
7
+ //# sourceMappingURL=ButtonFixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ButtonFixture.svelte.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/ButtonFixture.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAY7C,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,18 @@
1
+ <!--
2
+ Test fixture: pushes its full props plus the current experience /
3
+ contentful context into a module-level `captureSink` that tests pull from.
4
+ -->
5
+ <script lang="ts">
6
+ import { getExperience, getContentfulComponent } from '../context.js';
7
+ import { getDesignValues } from '../get-design-values.js';
8
+ import { captureSink } from './capture-sink.js';
9
+
10
+ // Capture every prop the renderer passes in.
11
+ const rest = $props();
12
+ captureSink.push({
13
+ props: { ...rest },
14
+ experience: getExperience(),
15
+ contentful: getContentfulComponent(),
16
+ designValues: getDesignValues<Record<string, unknown>>(),
17
+ });
18
+ </script>
@@ -0,0 +1,4 @@
1
+ declare const CapturingComponent: import("svelte").Component<$$ComponentProps, {}, "">;
2
+ type CapturingComponent = ReturnType<typeof CapturingComponent>;
3
+ export default CapturingComponent;
4
+ //# sourceMappingURL=CapturingComponent.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CapturingComponent.svelte.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/CapturingComponent.svelte.ts"],"names":[],"mappings":"AAyBA,QAAA,MAAM,kBAAkB,sDAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ import { getDesignValues } from '../get-design-values.js';
5
+
6
+ let { children }: { children?: Snippet } = $props();
7
+ const design = $derived(getDesignValues());
8
+ </script>
9
+
10
+ <div data-padding={design.cfPadding as string}>
11
+ {#if children}{@render children()}{/if}
12
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children?: Snippet;
4
+ };
5
+ declare const ContainerFixture: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ContainerFixture = ReturnType<typeof ContainerFixture>;
7
+ export default ContainerFixture;
8
+ //# sourceMappingURL=ContainerFixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContainerFixture.svelte.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/ContainerFixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAIrC,KAAK,gBAAgB,GAAI;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAgBjD,QAAA,MAAM,gBAAgB,sDAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import { getDesignValues } from '../get-design-values.js';
3
+
4
+ let { text }: { text?: string } = $props();
5
+ const design = $derived(getDesignValues());
6
+ </script>
7
+
8
+ <h1 data-font-size={design.cfFontSize as string}>{text}</h1>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ text?: string;
3
+ };
4
+ declare const HeadingFixture: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type HeadingFixture = ReturnType<typeof HeadingFixture>;
6
+ export default HeadingFixture;
7
+ //# sourceMappingURL=HeadingFixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HeadingFixture.svelte.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/HeadingFixture.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAY5C,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}