@getvitops/emdash 1.0.0-alpha.0 → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +279 -3
  2. package/README.md +66 -21
  3. package/dist/index.d.mts +82 -18
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +1626 -152
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +16 -13
  8. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  9. package/src/astro/blocks/Accordion.astro +39 -0
  10. package/src/astro/blocks/ActionLink.astro +4 -2
  11. package/src/astro/blocks/Callout.astro +73 -0
  12. package/src/astro/blocks/Cards.astro +96 -0
  13. package/src/astro/blocks/Carousel.astro +25 -8
  14. package/src/astro/blocks/Embed.astro +67 -0
  15. package/src/astro/blocks/Entries.astro +30 -0
  16. package/src/astro/blocks/Form.astro +194 -0
  17. package/src/astro/blocks/Gallery.astro +40 -0
  18. package/src/astro/blocks/Hero.astro +115 -0
  19. package/src/astro/blocks/ImageCompare.astro +5 -2
  20. package/src/astro/blocks/Logos.astro +48 -0
  21. package/src/astro/blocks/Marquee.astro +38 -0
  22. package/src/astro/blocks/Nav.astro +85 -0
  23. package/src/astro/blocks/NavTree.astro +48 -0
  24. package/src/astro/blocks/Pricing.astro +66 -0
  25. package/src/astro/blocks/Reviews.astro +122 -0
  26. package/src/astro/blocks/RichText.astro +14 -0
  27. package/src/astro/blocks/Section.astro +84 -0
  28. package/src/astro/blocks/Stats.astro +59 -0
  29. package/src/astro/blocks/Steps.astro +48 -0
  30. package/src/astro/blocks/embed.test.ts +43 -0
  31. package/src/astro/blocks/embed.ts +84 -0
  32. package/src/astro/blocks/form.sample.ts +33 -0
  33. package/src/astro/blocks/form.test.ts +55 -0
  34. package/src/astro/blocks/form.ts +158 -0
  35. package/src/astro/blocks/head.stub.ts +2 -0
  36. package/src/astro/blocks/nav.test.ts +51 -0
  37. package/src/astro/blocks/nav.ts +65 -0
  38. package/src/astro/blocks/render.container.test.ts +570 -0
  39. package/src/astro/blocks/tone.test.ts +53 -0
  40. package/src/astro/blocks/tone.ts +122 -0
  41. package/src/astro/blocks/value.test.ts +56 -0
  42. package/src/astro/blocks/value.ts +103 -1
  43. package/src/astro/index.ts +40 -0
  44. package/src/admin/core-editor.d.ts +0 -7
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Real-render tests for the block adapters, through Astro's container API: HTML in, HTML out.
3
+ *
4
+ * `blocks.test.ts` reads each `.astro` file as text, which proves a field name is mentioned
5
+ * but never that the component renders — a prop destructured wrong or a heading level that
6
+ * never reaches the tag passes it. These assert the markup an editor's saved block produces.
7
+ *
8
+ * Props arrive as `{ node }` here, the shape `astro-portabletext` passes (see `value.ts`).
9
+ */
10
+ import { experimental_AstroContainer as AstroContainer } from 'astro/container';
11
+ import { describe, expect, it, vi } from 'vite-plus/test';
12
+ import Callout from './Callout.astro';
13
+ import Cards from './Cards.astro';
14
+ import Embed from './Embed.astro';
15
+ import Entries from './Entries.astro';
16
+ import Form from './Form.astro';
17
+ import head from './head.stub.ts';
18
+ import { SAMPLE_FORM_BLOCK } from './form.sample.ts';
19
+ import Hero from './Hero.astro';
20
+ import Logos from './Logos.astro';
21
+ import Pricing from './Pricing.astro';
22
+ import Stats from './Stats.astro';
23
+ import Steps from './Steps.astro';
24
+
25
+ // `.astro` imports are typed as plain prop functions here (see `astro-modules.d.ts`), which the
26
+ // container's `AstroComponentFactory` parameter does not accept; the cast is the whole bridge.
27
+ type Component = Parameters<AstroContainer['renderToString']>[0];
28
+ async function render(component: unknown, node: object) {
29
+ const container = await AstroContainer.create();
30
+ return container.renderToString(component as Component, { props: { node } });
31
+ }
32
+
33
+ describe('vitops.cards', () => {
34
+ const cards = [
35
+ {
36
+ title: 'Cut costs',
37
+ body: 'you pay [less](/pricing) for what you use',
38
+ href: '/a',
39
+ linkLabel: 'More',
40
+ },
41
+ ];
42
+
43
+ it('renders the title as a heading at the chosen level (U19), not a paragraph', async () => {
44
+ const html = await render(Cards, { cards, level: '4' });
45
+ expect(html).toContain('<h4 class="font-heading">Cut costs</h4>');
46
+ expect(html).not.toContain('<p class="font-heading"');
47
+ });
48
+
49
+ it('keeps an inline link in the body and stays a <wc-cards> grid', async () => {
50
+ const html = await render(Cards, { cards });
51
+ expect(html).toContain('<a class="link" href="/pricing">less</a>');
52
+ expect(html).toContain('<wc-cards');
53
+ expect(html).toContain('class="link stretched-link"');
54
+ });
55
+
56
+ it('panel variant: tinted by tone through custom properties, with checklist and button', async () => {
57
+ const html = await render(Cards, {
58
+ variant: 'panel',
59
+ tone: 'brand-primary',
60
+ cards: [{ title: 'Pillar', checklist: 'one\ntwo', ctaLabel: 'Go', ctaHref: '/go' }],
61
+ });
62
+ expect(html).toContain('class="card subgrid-card tone"');
63
+ expect(html).toContain('--tone-bg: var(--color-bg-brand-primary-muted');
64
+ expect(html).toContain('<li>one</li>');
65
+ expect(html).toContain('class="cta" href="/go"');
66
+ // A panel holds its own button, so it is not a click-forwarding <wc-cards>.
67
+ expect(html).not.toContain('<wc-cards');
68
+ });
69
+
70
+ it('drops an unsafe link instead of rendering it', async () => {
71
+ const html = await render(Cards, { cards: [{ title: 'x', body: '[bad](javascript:alert)' }] });
72
+ expect(html).not.toContain('javascript:');
73
+ });
74
+ });
75
+
76
+ describe('vitops.hero', () => {
77
+ it('accents starred words and honours the heading level', async () => {
78
+ const html = await render(Hero, { heading: 'Two services. *One team.*', level: '3' });
79
+ expect(html).toContain(
80
+ '<h3 class="font-display">Two services. <em class="accent">One team.</em></h3>',
81
+ );
82
+ });
83
+
84
+ it('renders the optional cards slot', async () => {
85
+ const html = await render(Hero, { heading: 'Hi', cards: [{ title: 'A', body: 'b' }] });
86
+ expect(html).toContain('<h3 class="font-heading">A</h3>');
87
+ });
88
+ });
89
+
90
+ describe('vitops.logos', () => {
91
+ it('is a labelled list of images with alt text', async () => {
92
+ const html = await render(Logos, {
93
+ label: 'Key partners',
94
+ grayscale: true,
95
+ logos: [
96
+ { image: '/a.svg', alt: 'Acme', href: '/acme' },
97
+ { image: '/b.svg', alt: 'Beta' },
98
+ ],
99
+ });
100
+ expect(html).toContain('aria-label="Key partners"');
101
+ expect(html).toContain('logos--grayscale');
102
+ expect(html).toContain('alt="Acme"');
103
+ expect(html).toContain('<a href="/acme">');
104
+ });
105
+
106
+ it('renders nothing with no logos', async () => {
107
+ expect((await render(Logos, { logos: [] })).trim()).toBe('');
108
+ });
109
+ });
110
+
111
+ describe('vitops.steps', () => {
112
+ it('is an ordered list with real headings and a tone', async () => {
113
+ const html = await render(Steps, {
114
+ steps: [{ title: 'Call', text: 'We talk' }, { title: 'Build' }],
115
+ level: '4',
116
+ tone: 'success',
117
+ layout: 'row',
118
+ });
119
+ expect(html).toContain('<ol');
120
+ expect(html).toContain('steps--row');
121
+ expect(html).toContain('<h4 class="steps__title font-heading">Call</h4>');
122
+ expect(html).toContain('--tone-solid: var(--color-bg-success-solid');
123
+ });
124
+ });
125
+
126
+ describe('vitops.stats', () => {
127
+ const stats = [
128
+ { value: '82%', label: 'Faster', group: 'Ops', delta: '+3 this week', state: 'up' },
129
+ ];
130
+
131
+ it('renders each figure through <wc-counter>', async () => {
132
+ const html = await render(Stats, { stats });
133
+ expect(html).toContain('<wc-counter');
134
+ expect(html).toContain('82%');
135
+ expect(html).not.toContain('stats__delta');
136
+ });
137
+
138
+ it('tiles variant adds group, delta and state; animate off drops the element', async () => {
139
+ const html = await render(Stats, { stats, variant: 'tiles', animate: false });
140
+ expect(html).toContain('stats--tiles');
141
+ expect(html).toContain('data-state="up"');
142
+ expect(html).toContain('Ops');
143
+ expect(html).toContain('+3 this week');
144
+ expect(html).not.toContain('<wc-counter');
145
+ });
146
+
147
+ it('ignores an invented state', async () => {
148
+ const html = await render(Stats, { stats: [{ value: '1', state: 'x" onload="y' }] });
149
+ expect(html).not.toContain('onload');
150
+ });
151
+ });
152
+
153
+ describe('vitops.callout', () => {
154
+ it('is a heading, text and actions in a tinted or solid band — not a banner', async () => {
155
+ const html = await render(Callout, {
156
+ heading: 'Ready?',
157
+ body: 'Funding is [available](/funding).',
158
+ primaryLabel: 'Apply',
159
+ primaryHref: '/apply',
160
+ solid: true,
161
+ tone: 'brand-primary',
162
+ });
163
+ expect(html).toContain('class="callout tone tone--solid"');
164
+ expect(html).toContain('<h2 class="callout__title font-title">Ready?</h2>');
165
+ expect(html).toContain('<a class="link" href="/funding">available</a>');
166
+ expect(html).toContain('class="cta" href="/apply"');
167
+ expect(html).not.toContain('banner');
168
+ });
169
+ });
170
+
171
+ describe('vitops.entries', () => {
172
+ it('emits <wc-entries> with a semantic heading + dl per entry', async () => {
173
+ const html = await render(Entries, {
174
+ level: '4',
175
+ breakpoint: '40rem',
176
+ entries: [{ heading: 'Mon', pairs: 'Open | 9\nClose | 5' }],
177
+ });
178
+ expect(html).toContain('<wc-entries');
179
+ expect(html).toContain('breakpoint="40rem"');
180
+ expect(html).toContain('<h4>Mon</h4>');
181
+ expect(html).toContain('<dt>Open</dt>');
182
+ expect(html).toContain('<dd>9</dd>');
183
+ });
184
+
185
+ it('rejects a breakpoint that is not a CSS length', async () => {
186
+ const html = await render(Entries, {
187
+ breakpoint: '1" onload="x',
188
+ entries: [{ heading: 'a', pairs: 'k | v' }],
189
+ });
190
+ expect(html).not.toContain('onload');
191
+ });
192
+ });
193
+
194
+ describe('vitops.pricing', () => {
195
+ it('aligns plans through the pricing-card subgrid rows', async () => {
196
+ const html = await render(Pricing, {
197
+ plans: [
198
+ {
199
+ name: 'Basic',
200
+ price: '$9',
201
+ period: 'per month',
202
+ features: 'a\nb',
203
+ ctaLabel: 'Buy',
204
+ ctaHref: '/buy',
205
+ },
206
+ {
207
+ name: 'Pro',
208
+ price: '$29',
209
+ features: 'a\nb\nc',
210
+ featured: true,
211
+ ctaLabel: 'Buy',
212
+ ctaHref: '/buy',
213
+ },
214
+ ],
215
+ });
216
+ expect(html).toContain('class="pricing-grid"');
217
+ expect(html).toContain('--pricing-columns: 2; --pricing-card-rows: 4');
218
+ expect(html).toContain('pricing-card pricing-card--featured');
219
+ expect(html).toContain('<h3 class="font-title">Pro</h3>');
220
+ expect(html).toContain('pricing-card__feature');
221
+ });
222
+ });
223
+
224
+ describe('vitops.embed', () => {
225
+ it('ships the iframe with NO src and the address on data-consent-src', async () => {
226
+ const html = await render(Embed, {
227
+ provider: 'youtube',
228
+ url: 'https://www.youtube.com/watch?v=abc123',
229
+ title: 'Our story',
230
+ category: 'marketing',
231
+ });
232
+ expect(html).toContain('data-consent="marketing"');
233
+ expect(html).toContain('data-consent-src="https://www.youtube-nocookie.com/embed/abc123"');
234
+ expect(html).toContain('title="Our story"');
235
+ // The load-bearing rule: never a live src before consent.
236
+ expect(html).not.toMatch(/<iframe[^>]*\ssrc=/);
237
+ // And a usable fallback with no JS.
238
+ expect(html).toContain('href="https://www.youtube.com/watch?v=abc123"');
239
+ });
240
+
241
+ it('renders nothing without a title or with an address the provider refuses', async () => {
242
+ const ok = { provider: 'youtube', url: 'https://youtu.be/abc123' };
243
+ expect((await render(Embed, ok)).trim()).toBe('');
244
+ expect(
245
+ (
246
+ await render(Embed, { provider: 'youtube', url: 'https://evil.example/x', title: 't' })
247
+ ).trim(),
248
+ ).toBe('');
249
+ expect(
250
+ (await render(Embed, { provider: 'custom', url: 'javascript:alert(1)', title: 't' })).trim(),
251
+ ).toBe('');
252
+ });
253
+
254
+ it('falls back to marketing for an unknown category, so nothing ungated can be asked for', async () => {
255
+ const html = await render(Embed, {
256
+ provider: 'vimeo',
257
+ url: 'https://vimeo.com/12',
258
+ title: 't',
259
+ category: 'whatever',
260
+ });
261
+ expect(html).toContain('data-consent="marketing"');
262
+ });
263
+ });
264
+
265
+ // The data the two new blocks read: the reviews cache (a virtual module the `vitops()`
266
+ // integration serves; a container cannot register integrations) and EmDash's `getMenu`.
267
+ const fixtures = vi.hoisted(() => ({
268
+ cache: { version: 1, locations: {} } as { version: 1; locations: Record<string, unknown> },
269
+ menus: {} as Record<string, unknown>,
270
+ }));
271
+ vi.mock('virtual:getvitops/reviews', () => ({
272
+ get default() {
273
+ return fixtures.cache;
274
+ },
275
+ }));
276
+ vi.mock('virtual:getvitops/icons', () => ({
277
+ default: { ui: 'ph', brand: 'ph', overrides: {}, engine: 'none' },
278
+ }));
279
+ vi.mock('emdash', () => ({ getMenu: async (name: string) => fixtures.menus[name] ?? null }));
280
+
281
+ const review = (id: string, rating: number, extra: object = {}) => ({
282
+ id,
283
+ location: 'ottawa',
284
+ rating,
285
+ author: `Author ${id}`,
286
+ text: `Text ${id}`,
287
+ publishedAt: `2026-0${rating}-01T00:00:00Z`,
288
+ updatedAt: '2026-01-01T00:00:00Z',
289
+ ...extra,
290
+ });
291
+
292
+ describe('vitops.reviews', () => {
293
+ it('renders nothing when the cache is empty (a fresh scaffold has no reviews.json)', async () => {
294
+ fixtures.cache = { version: 1, locations: {} };
295
+ const { default: Reviews } = await import('./Reviews.astro');
296
+ // The dev-only note is allowed (vitest runs with DEV on); no card or wrapper is.
297
+ const html = await render(Reviews, { _type: 'vitops.reviews' });
298
+ expect(html).not.toContain('<article');
299
+ expect(html).not.toMatch(/class="reviews[" ]/);
300
+ });
301
+
302
+ it('selects with selectReviews: minRating, sort, limit and featured-first', async () => {
303
+ fixtures.cache = {
304
+ version: 1,
305
+ locations: {
306
+ ottawa: {
307
+ reviews: [review('a', 5), review('b', 4), review('c', 2), review('d', 5, { text: 'D' })],
308
+ },
309
+ toronto: { reviews: [review('z', 5)] },
310
+ },
311
+ };
312
+ const { default: Reviews } = await import('./Reviews.astro');
313
+ const html = await render(Reviews, {
314
+ location: 'ottawa',
315
+ minRating: '4',
316
+ sort: 'rating',
317
+ limit: 2,
318
+ featured: 'b',
319
+ });
320
+ // Featured first, then best rated; the 2-star and the other location are out.
321
+ const authors = [...html.matchAll(/Author (\w)</g)].map((m) => m[1]);
322
+ expect(authors).toEqual(['b', 'a']);
323
+ expect(html).not.toContain('Author z');
324
+ });
325
+
326
+ it('defaults avatars off, links the source with nofollow, and lays out as a subgrid', async () => {
327
+ fixtures.cache = {
328
+ version: 1,
329
+ locations: {
330
+ ottawa: {
331
+ reviews: [
332
+ review('a', 5, {
333
+ authorPhoto: 'https://lh3.googleusercontent.com/x',
334
+ url: 'https://g.co/r/1',
335
+ }),
336
+ ],
337
+ },
338
+ },
339
+ };
340
+ const { default: Reviews } = await import('./Reviews.astro');
341
+ const html = await render(Reviews, { columns: '2' });
342
+ expect(html).not.toContain('googleusercontent');
343
+ expect(html).toContain('class="subgrid"');
344
+ expect(html).toContain('--subgrid-cols: 2');
345
+ expect(html).toMatch(
346
+ /<a class="review__source link" href="https:\/\/g\.co\/r\/1" rel="nofollow/,
347
+ );
348
+ // JSON-LD is opt-in elsewhere and never emitted by the block.
349
+ expect(html).not.toContain('application/ld+json');
350
+ const withPhoto = await render(Reviews, { avatars: true });
351
+ expect(withPhoto).toContain('googleusercontent');
352
+ });
353
+
354
+ it('carousel and marquee layouts use the existing components', async () => {
355
+ fixtures.cache = { version: 1, locations: { ottawa: { reviews: [review('a', 5)] } } };
356
+ const { default: Reviews } = await import('./Reviews.astro');
357
+ const carousel = await render(Reviews, { layout: 'carousel', label: 'Praise' });
358
+ expect(carousel).toContain('<wc-carousel');
359
+ expect(carousel).toContain('aria-label="Praise"');
360
+ expect(carousel).toContain('class="carousel__slide"');
361
+ const marquee = await render(Reviews, { layout: 'marquee' });
362
+ expect(marquee).toContain('<wc-marquee');
363
+ });
364
+
365
+ it('puts tone on the surrounding band, not on the cards', async () => {
366
+ fixtures.cache = { version: 1, locations: { ottawa: { reviews: [review('a', 5)] } } };
367
+ const { default: Reviews } = await import('./Reviews.astro');
368
+ const html = await render(Reviews, { tone: 'brand-primary' });
369
+ expect(html).toContain('class="reviews tone"');
370
+ expect(html).toContain('<article class="review"');
371
+ });
372
+ });
373
+
374
+ describe('vitops.nav', () => {
375
+ const menu = {
376
+ label: 'Footer site map',
377
+ items: [
378
+ {
379
+ label: 'Services',
380
+ url: '/services',
381
+ children: [{ label: 'Web', url: '/services/web' }],
382
+ },
383
+ { label: 'Contact', url: '/contact' },
384
+ { label: 'Bad', url: 'javascript:alert(1)' },
385
+ ],
386
+ };
387
+
388
+ it('renders a named <nav> with the list, current page and no javascript: link', async () => {
389
+ fixtures.menus = { 'footer-sitemap': menu };
390
+ const { default: Nav } = await import('./Nav.astro');
391
+ const html = await render(Nav, { menu: 'footer-sitemap', depth: '2' });
392
+ expect(html).toContain('<nav aria-label="Footer site map">');
393
+ expect(html).toContain('class="navlist"');
394
+ expect(html).toContain('class="navlist__sub"');
395
+ expect(html).toContain('href="/services/web"');
396
+ expect(html).not.toContain('javascript:');
397
+ });
398
+
399
+ it('depth 1 drops the sub-links; columns/inline add their modifier', async () => {
400
+ fixtures.menus = { 'footer-sitemap': menu };
401
+ const { default: Nav } = await import('./Nav.astro');
402
+ const flat = await render(Nav, { menu: 'footer-sitemap', variant: 'columns' });
403
+ expect(flat).toContain('navlist navlist--columns');
404
+ expect(flat).not.toContain('/services/web');
405
+ });
406
+
407
+ it('never names itself like the header or footer landmark', async () => {
408
+ fixtures.menus = { primary: { label: 'Primary', items: [{ label: 'A', url: '/a' }] } };
409
+ const { default: Nav } = await import('./Nav.astro');
410
+ const html = await render(Nav, { menu: 'primary' });
411
+ expect(html).toContain('aria-label="Primary (this page)"');
412
+ });
413
+
414
+ it('heading is a real heading at the chosen level, outside the nav', async () => {
415
+ fixtures.menus = { 'footer-sitemap': menu };
416
+ const { default: Nav } = await import('./Nav.astro');
417
+ const html = await render(Nav, { menu: 'footer-sitemap', heading: 'Explore', level: '3' });
418
+ expect(html).toContain('<h3 class="font-heading">Explore</h3>');
419
+ expect(html).toContain('<nav aria-label="Explore">');
420
+ });
421
+
422
+ it('the sitenav variant renders <SiteNav> with that label', async () => {
423
+ fixtures.menus = { 'footer-sitemap': menu };
424
+ const { default: Nav } = await import('./Nav.astro');
425
+ const html = await render(Nav, { menu: 'footer-sitemap', variant: 'sitenav', _key: 'k1' });
426
+ expect(html).toContain('sitenav__list');
427
+ expect(html).toContain('aria-label="Footer site map"');
428
+ expect(html).toContain('navblock-k1');
429
+ });
430
+
431
+ it('a missing menu, an empty one or a non-slug name renders nothing', async () => {
432
+ fixtures.menus = { empty: { label: 'E', items: [] } };
433
+ const { default: Nav } = await import('./Nav.astro');
434
+ for (const name of ['nope', 'empty', '../etc'])
435
+ expect((await render(Nav, { menu: name })).trim(), name).toBe('');
436
+ });
437
+ });
438
+
439
+ // EmDash's <PortableText> reads the site's merged plugin component map from a virtual module its
440
+ // own integration provides — which a container cannot register. Provide the real adapter for the
441
+ // block the test nests, so it resolves as in production. (Not the whole `blockComponents` map:
442
+ // that imports Section.astro, which imports `emdash/ui`, which awaits this very factory.)
443
+ vi.mock('virtual:emdash/block-components', async () => ({
444
+ pluginBlockComponents: { 'vitops.logos': (await import('./Logos.astro')).default },
445
+ }));
446
+
447
+ vi.mock('virtual:emdash/config', () => ({ default: {} }));
448
+
449
+ describe('vitops_section', () => {
450
+ it('renders the head, tone and a Portable Text body', async () => {
451
+ const { default: Section } = await import('./Section.astro');
452
+ const container = await AstroContainer.create();
453
+ const html = await container.renderToString(Section as unknown as Component, {
454
+ props: {
455
+ value: {
456
+ kicker: 'Why us',
457
+ heading: 'Two services',
458
+ level: '3',
459
+ sub: 'Lead-in',
460
+ layout: 'split',
461
+ tone: 'surface',
462
+ body: [
463
+ {
464
+ _type: 'block',
465
+ _key: 'a',
466
+ style: 'normal',
467
+ children: [{ _type: 'span', _key: 'b', text: 'Body copy' }],
468
+ },
469
+ // The point of the block: another vitops.* block resolves INSIDE the body.
470
+ { _type: 'vitops.logos', _key: 'c', logos: [{ image: '/x.svg', alt: 'Acme' }] },
471
+ ],
472
+ },
473
+ },
474
+ });
475
+ expect(html).toContain('section region tone section--split');
476
+ expect(html).toContain('<h3 class="section__title font-title">Two services</h3>');
477
+ expect(html).toContain('Why us');
478
+ expect(html).toContain('Body copy');
479
+ expect(html).toContain('class="logos"');
480
+ expect(html).toContain('alt="Acme"');
481
+ }, 60_000);
482
+ });
483
+
484
+ describe('vitops.form', () => {
485
+ async function renderForm(node: object, path = '/contact') {
486
+ const container = await AstroContainer.create();
487
+ return container.renderToString(Form as unknown as Component, {
488
+ props: { node },
489
+ request: new Request(`http://localhost${path}`),
490
+ });
491
+ }
492
+
493
+ it('renders a no-JS form: real labels, a post to the endpoint, and its own page as _next', async () => {
494
+ head.turnstileSiteKey = null;
495
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
496
+ expect(html).toContain('<form method="post" action="/api/track"');
497
+ expect(html).toContain('name="_next" value="/contact"');
498
+ expect(html).toMatch(/<label class="form-group__label" for="form-1-name">\s*Your name/);
499
+ expect(html).toContain('id="form-1-name"');
500
+ expect(html).toContain('autocomplete="name"');
501
+ expect(html).toContain('type="email"');
502
+ expect(html).toContain('autocomplete="email"');
503
+ expect(html).toContain('<textarea');
504
+ expect(html).toContain('<option value="Design">Design</option>');
505
+ expect(html).toContain('type="checkbox"');
506
+ expect(html).toContain('Send message');
507
+ });
508
+
509
+ it('never submits a phone field as `phone`: the route drops that name', async () => {
510
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
511
+ expect(html).toContain('name="tel"');
512
+ expect(html).not.toContain('name="phone"');
513
+ expect(html).toContain('autocomplete="tel"');
514
+ });
515
+
516
+ it('marks required fields and ties help text to its control', async () => {
517
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
518
+ expect(html).toMatch(/id="form-2-email"[^>]*required/);
519
+ expect(html).toContain('aria-describedby="form-2-email-help"');
520
+ expect(html).toContain('id="form-2-email-help"');
521
+ expect(html).toContain('(optional)');
522
+ });
523
+
524
+ it('ships a honeypot the route recognises, hidden from assistive tech and the tab order', async () => {
525
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
526
+ expect(html).toMatch(/aria-hidden="true"[\s\S]*name="website"[^>]*tabindex="-1"/);
527
+ });
528
+
529
+ it('carries both result notices, revealed by the URL fragment, with the editor message', async () => {
530
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
531
+ expect(html).toMatch(/id="form-sent"[^>]*role="status"/);
532
+ expect(html).toMatch(/id="form-error"[^>]*role="alert"/);
533
+ expect(html).toContain('form-notice--target');
534
+ expect(html).toContain('Thanks, we will be in touch.');
535
+ });
536
+
537
+ it('has no Turnstile and no third-party reference without a site key', async () => {
538
+ head.turnstileSiteKey = null;
539
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
540
+ expect(html).not.toContain('wc-turnstile');
541
+ expect(html).not.toContain('cloudflare.com');
542
+ });
543
+
544
+ it('renders <wc-turnstile> with its no-JS explanation when a site key exists, and no script', async () => {
545
+ head.turnstileSiteKey = '0x4AAAAAAAexample1234';
546
+ try {
547
+ const html = await renderForm(SAMPLE_FORM_BLOCK);
548
+ expect(html).toContain('<wc-turnstile sitekey="0x4AAAAAAAexample1234">');
549
+ expect(html).toContain('data-turnstile-fallback');
550
+ expect(html).toContain('needs JavaScript');
551
+ // The widget script is loaded by the element on interaction, never emitted here.
552
+ expect(html).not.toContain('<script');
553
+ expect(html).not.toContain('challenges.cloudflare.com');
554
+ } finally {
555
+ head.turnstileSiteKey = null;
556
+ }
557
+ });
558
+
559
+ it('refuses an off-site action and renders nothing for a form with no fields', async () => {
560
+ const html = await renderForm({ ...SAMPLE_FORM_BLOCK, action: 'https://evil.example/steal' });
561
+ expect(html).toContain('action="/api/track"');
562
+ expect(html).not.toContain('evil.example');
563
+ expect(await renderForm({ _type: 'vitops.form', fields: [] })).not.toContain('<form');
564
+ });
565
+
566
+ it('escapes editor text', async () => {
567
+ const html = await renderForm({ ...SAMPLE_FORM_BLOCK, heading: '<script>alert(1)</script>' });
568
+ expect(html).not.toContain('<script>alert(1)');
569
+ });
570
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vite-plus/test';
2
+ import { joinStyle, resolveTone, toneChain } from './tone.ts';
3
+
4
+ describe('toneChain', () => {
5
+ it('tries the role, then its relatives, then neutral', () => {
6
+ expect(toneChain('brand-primary')).toEqual(['brand-primary', 'ui-primary', 'info', 'neutral']);
7
+ expect(toneChain('danger')).toEqual(['danger', 'neutral']);
8
+ });
9
+
10
+ it('collapses anything that is not a role identifier to neutral', () => {
11
+ expect(toneChain(undefined)).toEqual(['neutral']);
12
+ expect(toneChain('red; background: url(x)')).toEqual(['neutral']);
13
+ expect(toneChain('Danger')).toEqual(['danger', 'neutral']);
14
+ });
15
+ });
16
+
17
+ describe('resolveTone', () => {
18
+ it('is empty when nothing is set, so an unconfigured block stays transparent', () => {
19
+ expect(resolveTone()).toEqual({ className: 'tone', style: '' });
20
+ expect(resolveTone('', '')).toEqual({ className: 'tone', style: '' });
21
+ });
22
+
23
+ it('resolves a role to var() chains the framework tokens satisfy', () => {
24
+ const { style } = resolveTone('brand-secondary');
25
+ expect(style).toContain(
26
+ '--tone-bg: var(--color-bg-brand-secondary-muted, var(--color-bg-ui-secondary-muted, var(--color-bg-info-muted, var(--color-bg-neutral-muted))))',
27
+ );
28
+ expect(style).toContain('--tone-fg: var(--color-text-brand-secondary,');
29
+ // A solid chain falls back to the bare surface token for surface roles.
30
+ expect(style).toMatch(
31
+ /--tone-solid: var\(--color-bg-brand-secondary-solid,.*--color-bg-neutral\)/,
32
+ );
33
+ });
34
+
35
+ it('lets color win and accepts a hue with or without a step', () => {
36
+ expect(resolveTone('danger', 'amber').style).toContain('--tone-solid: var(--color-amber-600)');
37
+ expect(resolveTone(undefined, 'amber-300').style).toContain(
38
+ '--tone-solid: var(--color-amber-300)',
39
+ );
40
+ });
41
+
42
+ it('never lets editor input escape the style attribute', () => {
43
+ expect(resolveTone('x', 'amber); color: red').style).not.toContain('red');
44
+ expect(resolveTone('a;b', undefined).style).not.toContain(';b');
45
+ });
46
+ });
47
+
48
+ describe('joinStyle', () => {
49
+ it('skips empties and returns undefined when nothing is left', () => {
50
+ expect(joinStyle('', undefined, false)).toBeUndefined();
51
+ expect(joinStyle('--a: 1', '', '--b: 2')).toBe('--a: 1; --b: 2');
52
+ });
53
+ });