@getvitops/emdash 2.0.0-alpha.1 → 2.0.0-alpha.3
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/CHANGELOG.md +52 -0
- package/README.md +18 -16
- package/dist/index.d.mts +26 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +467 -167
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/astro/WidgetArea.astro +31 -9
- package/src/astro/blocks/Badges.astro +1 -1
- package/src/astro/blocks/Callout.astro +1 -1
- package/src/astro/blocks/Cards.astro +4 -3
- package/src/astro/blocks/Form.astro +18 -0
- package/src/astro/blocks/Hero.astro +25 -10
- package/src/astro/blocks/Logos.astro +8 -5
- package/src/astro/blocks/NavTree.astro +1 -1
- package/src/astro/blocks/Reviews.astro +1 -1
- package/src/astro/blocks/Section.astro +15 -9
- package/src/astro/blocks/Stats.astro +3 -2
- package/src/astro/blocks/Steps.astro +3 -3
- package/src/astro/blocks/WidgetArea.astro +1 -1
- package/src/astro/blocks/attrs.test.ts +19 -1
- package/src/astro/blocks/attrs.ts +14 -2
- package/src/astro/blocks/cycle.container.test.ts +73 -0
- package/src/astro/blocks/form.sample.ts +8 -2
- package/src/astro/blocks/form.ts +16 -5
- package/src/astro/blocks/nav.ts +1 -1
- package/src/astro/blocks/render.container.test.ts +91 -19
- package/src/astro/blocks/tone.test.ts +10 -0
- package/src/astro/blocks/tone.ts +48 -6
- package/src/astro/blocks/unrendered.ts +23 -0
- package/src/astro/blocks/value.test.ts +27 -1
- package/src/astro/blocks/value.ts +16 -0
- package/src/astro/index.ts +34 -24
package/src/astro/blocks/nav.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure decisions behind the `
|
|
2
|
+
* Pure decisions behind the `vitops_nav` block, kept out of `Nav.astro` so they are asserted in
|
|
3
3
|
* `nav.test.ts` without rendering (the split `sitenav.ts` makes for `<SiteNav>`). The menu-tree
|
|
4
4
|
* helpers themselves — depth trimming, `aria-current` — are `@getvitops/astro`'s, not copied.
|
|
5
5
|
*/
|
|
@@ -30,7 +30,7 @@ async function render(component: unknown, node: object) {
|
|
|
30
30
|
return container.renderToString(component as Component, { props: { node } });
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
describe('
|
|
33
|
+
describe('vitops_cards', () => {
|
|
34
34
|
const cards = [
|
|
35
35
|
{
|
|
36
36
|
title: 'Cut costs',
|
|
@@ -42,7 +42,7 @@ describe('vitops.cards', () => {
|
|
|
42
42
|
|
|
43
43
|
it('renders the title as a heading at the chosen level (U19), not a paragraph', async () => {
|
|
44
44
|
const html = await render(Cards, { cards, level: '4' });
|
|
45
|
-
expect(html).toContain('<h4 class="font-heading">Cut costs</h4>');
|
|
45
|
+
expect(html).toContain('<h4 class="card__title font-heading">Cut costs</h4>');
|
|
46
46
|
expect(html).not.toContain('<p class="font-heading"');
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -73,7 +73,7 @@ describe('vitops.cards', () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
describe('
|
|
76
|
+
describe('vitops_hero', () => {
|
|
77
77
|
it('accents starred words and honours the heading level', async () => {
|
|
78
78
|
const html = await render(Hero, { heading: 'Two services. *One team.*', level: '3' });
|
|
79
79
|
expect(html).toContain(
|
|
@@ -83,11 +83,14 @@ describe('vitops.hero', () => {
|
|
|
83
83
|
|
|
84
84
|
it('renders the optional cards slot', async () => {
|
|
85
85
|
const html = await render(Hero, { heading: 'Hi', cards: [{ title: 'A', body: 'b' }] });
|
|
86
|
-
|
|
86
|
+
// the hero defaults to h1, so its option cards sit one level down
|
|
87
|
+
expect(html).toContain('<h2 class="font-heading">A</h2>');
|
|
88
|
+
const under2 = await render(Hero, { heading: 'Hi', level: '2', cards: [{ title: 'A' }] });
|
|
89
|
+
expect(under2).toContain('<h3 class="font-heading">A</h3>');
|
|
87
90
|
});
|
|
88
91
|
});
|
|
89
92
|
|
|
90
|
-
describe('
|
|
93
|
+
describe('vitops_logos', () => {
|
|
91
94
|
it('is a labelled list of images with alt text', async () => {
|
|
92
95
|
const html = await render(Logos, {
|
|
93
96
|
label: 'Key partners',
|
|
@@ -103,12 +106,28 @@ describe('vitops.logos', () => {
|
|
|
103
106
|
expect(html).toContain('<a href="/acme">');
|
|
104
107
|
});
|
|
105
108
|
|
|
109
|
+
it('shows the name as text when an entry has no image, linked or not', async () => {
|
|
110
|
+
const html = await render(Logos, {
|
|
111
|
+
logos: [
|
|
112
|
+
{ alt: 'Acme Partners', href: '/acme' },
|
|
113
|
+
{ alt: 'Beta Group' },
|
|
114
|
+
{ image: '/c.svg', alt: 'Gamma' },
|
|
115
|
+
{ image: '', alt: '' },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
expect(html).toContain('<span class="logos__name font-heading">Acme Partners</span>');
|
|
119
|
+
expect(html).toContain('<span class="logos__name font-heading">Beta Group</span>');
|
|
120
|
+
expect(html).toContain('<a href="/acme"><span');
|
|
121
|
+
expect(html).toContain('alt="Gamma"');
|
|
122
|
+
expect(html.match(/logos__item/g)).toHaveLength(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
106
125
|
it('renders nothing with no logos', async () => {
|
|
107
126
|
expect((await render(Logos, { logos: [] })).trim()).toBe('');
|
|
108
127
|
});
|
|
109
128
|
});
|
|
110
129
|
|
|
111
|
-
describe('
|
|
130
|
+
describe('vitops_steps', () => {
|
|
112
131
|
it('is an ordered list with real headings and a tone', async () => {
|
|
113
132
|
const html = await render(Steps, {
|
|
114
133
|
steps: [{ title: 'Call', text: 'We talk' }, { title: 'Build' }],
|
|
@@ -123,7 +142,7 @@ describe('vitops.steps', () => {
|
|
|
123
142
|
});
|
|
124
143
|
});
|
|
125
144
|
|
|
126
|
-
describe('
|
|
145
|
+
describe('vitops_stats', () => {
|
|
127
146
|
const stats = [
|
|
128
147
|
{ value: '82%', label: 'Faster', group: 'Ops', delta: '+3 this week', state: 'up' },
|
|
129
148
|
];
|
|
@@ -150,7 +169,7 @@ describe('vitops.stats', () => {
|
|
|
150
169
|
});
|
|
151
170
|
});
|
|
152
171
|
|
|
153
|
-
describe('
|
|
172
|
+
describe('vitops_callout', () => {
|
|
154
173
|
it('is a heading, text and actions in a tinted or solid band — not a banner', async () => {
|
|
155
174
|
const html = await render(Callout, {
|
|
156
175
|
heading: 'Ready?',
|
|
@@ -168,7 +187,7 @@ describe('vitops.callout', () => {
|
|
|
168
187
|
});
|
|
169
188
|
});
|
|
170
189
|
|
|
171
|
-
describe('
|
|
190
|
+
describe('vitops_entries', () => {
|
|
172
191
|
it('emits <wc-entries> with a semantic heading + dl per entry', async () => {
|
|
173
192
|
const html = await render(Entries, {
|
|
174
193
|
level: '4',
|
|
@@ -191,7 +210,7 @@ describe('vitops.entries', () => {
|
|
|
191
210
|
});
|
|
192
211
|
});
|
|
193
212
|
|
|
194
|
-
describe('
|
|
213
|
+
describe('vitops_pricing', () => {
|
|
195
214
|
it('aligns plans through the pricing-card subgrid rows', async () => {
|
|
196
215
|
const html = await render(Pricing, {
|
|
197
216
|
plans: [
|
|
@@ -221,7 +240,7 @@ describe('vitops.pricing', () => {
|
|
|
221
240
|
});
|
|
222
241
|
});
|
|
223
242
|
|
|
224
|
-
describe('
|
|
243
|
+
describe('vitops_embed', () => {
|
|
225
244
|
it('ships the iframe with NO src and the address on data-consent-src', async () => {
|
|
226
245
|
const html = await render(Embed, {
|
|
227
246
|
provider: 'youtube',
|
|
@@ -289,12 +308,12 @@ const review = (id: string, rating: number, extra: object = {}) => ({
|
|
|
289
308
|
...extra,
|
|
290
309
|
});
|
|
291
310
|
|
|
292
|
-
describe('
|
|
311
|
+
describe('vitops_reviews', () => {
|
|
293
312
|
it('renders nothing when the cache is empty (a fresh scaffold has no reviews.json)', async () => {
|
|
294
313
|
fixtures.cache = { version: 1, locations: {} };
|
|
295
314
|
const { default: Reviews } = await import('./Reviews.astro');
|
|
296
315
|
// The dev-only note is allowed (vitest runs with DEV on); no card or wrapper is.
|
|
297
|
-
const html = await render(Reviews, { _type: '
|
|
316
|
+
const html = await render(Reviews, { _type: 'vitops_reviews' });
|
|
298
317
|
expect(html).not.toContain('<article');
|
|
299
318
|
expect(html).not.toMatch(/class="reviews[" ]/);
|
|
300
319
|
});
|
|
@@ -371,7 +390,7 @@ describe('vitops.reviews', () => {
|
|
|
371
390
|
});
|
|
372
391
|
});
|
|
373
392
|
|
|
374
|
-
describe('
|
|
393
|
+
describe('vitops_nav', () => {
|
|
375
394
|
const menu = {
|
|
376
395
|
label: 'Footer site map',
|
|
377
396
|
items: [
|
|
@@ -441,7 +460,7 @@ describe('vitops.nav', () => {
|
|
|
441
460
|
// block the test nests, so it resolves as in production. (Not the whole `blockComponents` map:
|
|
442
461
|
// that imports Section.astro, which imports `emdash/ui`, which awaits this very factory.)
|
|
443
462
|
vi.mock('virtual:emdash/block-components', async () => ({
|
|
444
|
-
pluginBlockComponents: {
|
|
463
|
+
pluginBlockComponents: { vitops_logos: (await import('./Logos.astro')).default },
|
|
445
464
|
}));
|
|
446
465
|
|
|
447
466
|
vi.mock('virtual:emdash/config', () => ({ default: {} }));
|
|
@@ -466,8 +485,8 @@ describe('vitops_section', () => {
|
|
|
466
485
|
style: 'normal',
|
|
467
486
|
children: [{ _type: 'span', _key: 'b', text: 'Body copy' }],
|
|
468
487
|
},
|
|
469
|
-
// The point of the block: another
|
|
470
|
-
{ _type: '
|
|
488
|
+
// The point of the block: another vitops_* block resolves INSIDE the body.
|
|
489
|
+
{ _type: 'vitops_logos', _key: 'c', logos: [{ image: '/x.svg', alt: 'Acme' }] },
|
|
471
490
|
],
|
|
472
491
|
},
|
|
473
492
|
},
|
|
@@ -481,7 +500,7 @@ describe('vitops_section', () => {
|
|
|
481
500
|
}, 60_000);
|
|
482
501
|
});
|
|
483
502
|
|
|
484
|
-
describe('
|
|
503
|
+
describe('vitops_form', () => {
|
|
485
504
|
async function renderForm(node: object, path = '/contact') {
|
|
486
505
|
const container = await AstroContainer.create();
|
|
487
506
|
return container.renderToString(Form as unknown as Component, {
|
|
@@ -506,6 +525,19 @@ describe('vitops.form', () => {
|
|
|
506
525
|
expect(html).toContain('Send message');
|
|
507
526
|
});
|
|
508
527
|
|
|
528
|
+
it('renders "tick any number" as a fieldset of checkboxes sharing one name', async () => {
|
|
529
|
+
const html = await renderForm({
|
|
530
|
+
...SAMPLE_FORM_BLOCK,
|
|
531
|
+
fields: [
|
|
532
|
+
{ label: 'Interests', name: 'interests', type: 'checkboxes', options: 'IT\nMarketing' },
|
|
533
|
+
],
|
|
534
|
+
});
|
|
535
|
+
expect(html).toContain('<fieldset');
|
|
536
|
+
expect(html).toContain('<legend class="form-group__label">Interests</legend>');
|
|
537
|
+
expect(html.match(/name="interests"/g)).toHaveLength(2);
|
|
538
|
+
expect(html).toContain('value="Marketing"');
|
|
539
|
+
});
|
|
540
|
+
|
|
509
541
|
it('never submits a phone field as `phone`: the route drops that name', async () => {
|
|
510
542
|
const html = await renderForm(SAMPLE_FORM_BLOCK);
|
|
511
543
|
expect(html).toContain('name="tel"');
|
|
@@ -560,7 +592,7 @@ describe('vitops.form', () => {
|
|
|
560
592
|
const html = await renderForm({ ...SAMPLE_FORM_BLOCK, action: 'https://evil.example/steal' });
|
|
561
593
|
expect(html).toContain('action="/api/track"');
|
|
562
594
|
expect(html).not.toContain('evil.example');
|
|
563
|
-
expect(await renderForm({ _type: '
|
|
595
|
+
expect(await renderForm({ _type: 'vitops_form', fields: [] })).not.toContain('<form');
|
|
564
596
|
});
|
|
565
597
|
|
|
566
598
|
it('escapes editor text', async () => {
|
|
@@ -608,3 +640,43 @@ describe('escape hatches (anchor, className, style)', () => {
|
|
|
608
640
|
},
|
|
609
641
|
);
|
|
610
642
|
});
|
|
643
|
+
|
|
644
|
+
describe('per-item colour and hero card extras', () => {
|
|
645
|
+
const rows = [
|
|
646
|
+
{ title: 'Pine', body: 'a', tone: 'success' },
|
|
647
|
+
{ title: 'Amber', body: 'b', color: 'amber' },
|
|
648
|
+
{ title: 'Plain', body: 'c' },
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
it('a row sets its own --tone-* properties; a row with none inherits', async () => {
|
|
652
|
+
const html = await render(Cards, { variant: 'panel', tone: 'info', cards: rows });
|
|
653
|
+
const items = html.match(/<li[^>]*class="card[^"]*"[^>]*>/g)!;
|
|
654
|
+
expect(items).toHaveLength(3);
|
|
655
|
+
expect(items[0]).toContain('--color-bg-success-muted');
|
|
656
|
+
expect(items[1]).toContain('--color-amber-100');
|
|
657
|
+
expect(items[2]).toContain('--color-bg-info-muted'); // the block's own
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('applies to stats and steps rows', async () => {
|
|
661
|
+
const stats = await render(Stats, {
|
|
662
|
+
stats: [{ value: '1', tone: 'danger' }, { value: '2' }],
|
|
663
|
+
});
|
|
664
|
+
expect(stats).toContain('stats__item" style="--tone-bg');
|
|
665
|
+
const steps = await render(Steps, {
|
|
666
|
+
steps: [{ title: 'A', color: 'amber' }, { title: 'B' }],
|
|
667
|
+
});
|
|
668
|
+
expect(steps).toContain('steps__item" style="--tone-bg');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('hero cards take an eyebrow, a button and a tone', async () => {
|
|
672
|
+
const html = await render(Hero, {
|
|
673
|
+
heading: 'H',
|
|
674
|
+
level: '1',
|
|
675
|
+
cards: [{ title: 'Door', eyebrow: 'For IT', ctaLabel: 'Go', ctaHref: '/go', color: 'amber' }],
|
|
676
|
+
});
|
|
677
|
+
expect(html).toContain('class="font-eyebrow">For IT');
|
|
678
|
+
expect(html).toContain('<a class="cta" href="/go">');
|
|
679
|
+
expect(html).toContain('card subgrid-card tone');
|
|
680
|
+
expect(html).not.toContain('stretched-link');
|
|
681
|
+
});
|
|
682
|
+
});
|
|
@@ -51,3 +51,13 @@ describe('joinStyle', () => {
|
|
|
51
51
|
expect(joinStyle('--a: 1', '', '--b: 2')).toBe('--a: 1; --b: 2');
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe('solid fill from a hue and step', () => {
|
|
56
|
+
it("takes its text from the site's -on token for that step", () => {
|
|
57
|
+
expect(resolveTone('', 'amber-500').style).toContain(
|
|
58
|
+
'--tone-on-solid: var(--color-amber-500-on)',
|
|
59
|
+
);
|
|
60
|
+
expect(resolveTone('', 'navy').style).toContain('--tone-on-solid: var(--color-navy-600-on)');
|
|
61
|
+
expect(resolveTone('', 'navy').className).toBe('tone');
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/astro/blocks/tone.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* fallbacks, then `neutral`.
|
|
8
8
|
* - **`color`** — the escape hatch: a palette hue (`amber`) or hue and step (`amber-300`).
|
|
9
9
|
* Wins over `tone` when set. Contrast is NOT checked for it — the generator's contrast
|
|
10
|
-
* contract covers roles, not arbitrary hue/step pairs — so it is for decoration.
|
|
10
|
+
* contract covers roles, not arbitrary hue/step pairs — so it is for decoration. A solid fill
|
|
11
|
+
* reads from the site's `--color-<hue>-<step>-on` and the tint flips with the colour scheme
|
|
12
|
+
* (`light-dark()`); mid steps (300–500) cannot reach APCA Lc 75 with either text colour, so use
|
|
13
|
+
* a role where contrast matters.
|
|
11
14
|
*
|
|
12
15
|
* **Why CSS custom properties, not classes.** A class name built from a field value
|
|
13
16
|
* (`bg-${tone}`) is invisible to Tailwind's scanner, so it generates nothing in that
|
|
@@ -82,14 +85,21 @@ export function resolveTone(tone?: unknown, color?: unknown): ResolvedTone {
|
|
|
82
85
|
if (hue) {
|
|
83
86
|
const [, name, step] = hue;
|
|
84
87
|
const at = (s: string) => `var(--color-${name}-${s})`;
|
|
88
|
+
// The foreground is the site's own: the generator emits `--color-<hue>-<step>-on` for every
|
|
89
|
+
// step, picked by the same APCA rule as `text-on-<role>`, so a fill by hue and step reads
|
|
90
|
+
// without a fixed white and without asking the browser to guess.
|
|
91
|
+
const fill = step ?? '600';
|
|
92
|
+
// Raw hue steps never re-point in dark, so the tint flips here: `light-dark()` follows the
|
|
93
|
+
// `color-scheme` the generator sets under the dark selector.
|
|
94
|
+
const flip = (light: string, dark: string) => `light-dark(${at(light)}, ${at(dark)})`;
|
|
85
95
|
return {
|
|
86
96
|
className: 'tone',
|
|
87
97
|
style: [
|
|
88
|
-
`--tone-bg: ${
|
|
89
|
-
`--tone-fg: ${
|
|
90
|
-
`--tone-border: ${
|
|
91
|
-
`--tone-solid: ${at(
|
|
92
|
-
`--tone-on-solid:
|
|
98
|
+
`--tone-bg: ${flip('100', '900')}`,
|
|
99
|
+
`--tone-fg: ${flip('900', '100')}`,
|
|
100
|
+
`--tone-border: ${flip('300', '700')}`,
|
|
101
|
+
`--tone-solid: ${at(fill)}`,
|
|
102
|
+
`--tone-on-solid: ${at(`${fill}-on`)}`,
|
|
93
103
|
].join('; '),
|
|
94
104
|
};
|
|
95
105
|
}
|
|
@@ -120,3 +130,35 @@ export function joinStyle(...parts: (string | undefined | false)[]): string | un
|
|
|
120
130
|
const out = parts.filter(Boolean).join('; ');
|
|
121
131
|
return out || undefined;
|
|
122
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A repeater row's own colour: the `style` that overrides the block's `--tone-*` properties for
|
|
136
|
+
* that item, or `undefined` when the row sets none (it then inherits the block's). Only the custom
|
|
137
|
+
* properties are written, so the row picks up the block's own `.tone` surface and just recolours it.
|
|
138
|
+
*/
|
|
139
|
+
export function rowTone(row: Record<string, unknown>): string | undefined {
|
|
140
|
+
return resolveTone(row.tone, row.color).style || undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The `theme` every block can carry: a backdrop for the block's own root element, chosen from the
|
|
145
|
+
* site's colour roles. `<role>` is the tinted surface (`bg-<role>-muted`, with the role's text);
|
|
146
|
+
* `<role>:solid` is the filled backdrop with the contrast-computed `text-on-<role>`; `none` or
|
|
147
|
+
* nothing is no theme. Contrast is therefore the generator's guarantee, not the editor's judgement,
|
|
148
|
+
* and a site that styles its roles as raised, glass or flat gets that look, because the tokens are
|
|
149
|
+
* the site's own.
|
|
150
|
+
*
|
|
151
|
+
* Returns the classes and `--tone-*` style to put on the root element, or `undefined` for no theme.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveTheme(theme: unknown): { className: string; style: string } | undefined {
|
|
154
|
+
if (typeof theme !== 'string') return undefined;
|
|
155
|
+
const [role, fill] = theme
|
|
156
|
+
.trim()
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.replace(/^none$/, '')
|
|
159
|
+
.split(':');
|
|
160
|
+
if (!role || !ROLE.test(role)) return undefined;
|
|
161
|
+
const tone = resolveTone(role);
|
|
162
|
+
if (!tone.style) return undefined;
|
|
163
|
+
return { className: fill === 'solid' ? 'tone tone--solid' : 'tone', style: tone.style };
|
|
164
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `vitops_*` blocks in a Portable Text body have no renderer in the merged plugin map.
|
|
3
|
+
*
|
|
4
|
+
* `<PortableText>` logs `[components.type] is missing "vitops.…"` and drops the block, and the
|
|
5
|
+
* build still succeeds. Renderers that nest a body (`vitops_section`, widget areas) emit a
|
|
6
|
+
* `data-vitops-unrendered` marker for each such type so `vitops emdash check-prerender` can fail
|
|
7
|
+
* the build instead of publishing a page with holes in it.
|
|
8
|
+
*
|
|
9
|
+
* The virtual module is imported lazily, for the same reason `emdash/ui` is: it imports this
|
|
10
|
+
* package's `./astro` entry, so a static import closes the cycle.
|
|
11
|
+
*/
|
|
12
|
+
export async function unrenderedTypes(body: unknown): Promise<string[]> {
|
|
13
|
+
if (!Array.isArray(body)) return [];
|
|
14
|
+
const wanted = new Set<string>();
|
|
15
|
+
for (const node of body) {
|
|
16
|
+
const type = node && typeof node === 'object' ? (node as { _type?: unknown })._type : undefined;
|
|
17
|
+
if (typeof type === 'string' && type.startsWith('vitops_')) wanted.add(type);
|
|
18
|
+
}
|
|
19
|
+
if (wanted.size === 0) return [];
|
|
20
|
+
const { pluginBlockComponents } = await import('virtual:emdash/block-components');
|
|
21
|
+
const map = (pluginBlockComponents ?? {}) as Record<string, unknown>;
|
|
22
|
+
return [...wanted].filter((type) => !map[type]).sort();
|
|
23
|
+
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vite-plus/test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
accentRuns,
|
|
4
|
+
headingTag,
|
|
5
|
+
outlineTag,
|
|
6
|
+
lines,
|
|
7
|
+
linkRuns,
|
|
8
|
+
pairs,
|
|
9
|
+
paragraphs,
|
|
10
|
+
rows,
|
|
11
|
+
text,
|
|
12
|
+
} from './value.ts';
|
|
3
13
|
|
|
4
14
|
describe('editor-text helpers', () => {
|
|
5
15
|
it('headingTag clamps to h2-h6 and falls back', () => {
|
|
@@ -54,3 +64,19 @@ describe('repeater helpers', () => {
|
|
|
54
64
|
expect(paragraphs('')).toEqual([]);
|
|
55
65
|
});
|
|
56
66
|
});
|
|
67
|
+
|
|
68
|
+
describe('outlineTag', () => {
|
|
69
|
+
it('allows h1 through h6', () => {
|
|
70
|
+
expect(outlineTag('1')).toBe('h1');
|
|
71
|
+
expect(outlineTag(6)).toBe('h6');
|
|
72
|
+
});
|
|
73
|
+
it('returns p for a heading that is not in the outline', () => {
|
|
74
|
+
expect(outlineTag('p')).toBe('p');
|
|
75
|
+
expect(outlineTag(' P ')).toBe('p');
|
|
76
|
+
});
|
|
77
|
+
it('falls back for anything else', () => {
|
|
78
|
+
expect(outlineTag(undefined, 1)).toBe('h1');
|
|
79
|
+
expect(outlineTag(9)).toBe('h2');
|
|
80
|
+
expect(outlineTag('x', 3)).toBe('h3');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -59,6 +59,22 @@ export function headingTag(
|
|
|
59
59
|
return `h${clamped}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The element for a block that may own the page's title: `h1`–`h6`, or `p` when the heading is
|
|
64
|
+
* visual only and the outline should not carry it (the role class still styles it). Hero and
|
|
65
|
+
* section use this; every other block keeps `headingTag`'s `h2`–`h6`, so a page's `h1` only
|
|
66
|
+
* comes from a block an editor chose it for.
|
|
67
|
+
*/
|
|
68
|
+
export function outlineTag(
|
|
69
|
+
level: unknown,
|
|
70
|
+
fallback: 1 | 2 | 3 | 4 | 5 | 6 = 2,
|
|
71
|
+
): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' {
|
|
72
|
+
if (String(level).trim().toLowerCase() === 'p') return 'p';
|
|
73
|
+
const n = Math.round(Number(level));
|
|
74
|
+
const clamped = Number.isFinite(n) && n >= 1 && n <= 6 ? n : fallback;
|
|
75
|
+
return `h${clamped}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
/** One run of inline text, optionally a link. */
|
|
63
79
|
export interface Run {
|
|
64
80
|
text: string;
|
package/src/astro/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `defineBlockComponents` / `<Blocks components={…}>`. EmDash does not merge it.
|
|
9
9
|
*/
|
|
10
10
|
import Badges from './blocks/Badges.astro';
|
|
11
|
+
import { resolveTheme } from './blocks/tone.ts';
|
|
11
12
|
import WidgetArea from './blocks/WidgetArea.astro';
|
|
12
13
|
import Accordion from './blocks/Accordion.astro';
|
|
13
14
|
import ActionLink from './blocks/ActionLink.astro';
|
|
@@ -33,29 +34,29 @@ import Stats from './blocks/Stats.astro';
|
|
|
33
34
|
import Steps from './blocks/Steps.astro';
|
|
34
35
|
|
|
35
36
|
export const blockComponents = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
37
|
+
vitops_actionLink: ActionLink,
|
|
38
|
+
vitops_imageCompare: ImageCompare,
|
|
39
|
+
vitops_copyButton: CopyButton,
|
|
40
|
+
vitops_banner: Banner,
|
|
41
|
+
vitops_details: Details,
|
|
42
|
+
vitops_carousel: Carousel,
|
|
43
|
+
vitops_gallery: Gallery,
|
|
44
|
+
vitops_cards: Cards,
|
|
45
|
+
vitops_accordion: Accordion,
|
|
46
|
+
vitops_hero: Hero,
|
|
47
|
+
vitops_marquee: Marquee,
|
|
48
|
+
vitops_logos: Logos,
|
|
49
|
+
vitops_steps: Steps,
|
|
50
|
+
vitops_stats: Stats,
|
|
51
|
+
vitops_callout: Callout,
|
|
52
|
+
vitops_entries: Entries,
|
|
53
|
+
vitops_pricing: Pricing,
|
|
54
|
+
vitops_embed: Embed,
|
|
55
|
+
vitops_form: Form,
|
|
56
|
+
vitops_reviews: Reviews,
|
|
57
|
+
vitops_nav: Nav,
|
|
58
|
+
vitops_badges: Badges,
|
|
59
|
+
vitops_widgetArea: WidgetArea,
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
/** Keyed by block-type slug (see `SECTION_SLUG`), not by Portable Text `_type`. */
|
|
@@ -63,5 +64,14 @@ export const blockTypeComponents = {
|
|
|
63
64
|
vitops_section: Section,
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
/** The framework-styled widget area, for a site layout (the block form is `
|
|
67
|
+
/** The framework-styled widget area, for a site layout (the block form is `vitops_widgetArea`). */
|
|
67
68
|
export { default as VitopsWidgetArea } from './WidgetArea.astro';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `themeAttrs('surface')` → `{ class, style }` for the layout's `<body>` (or any element a site owns):
|
|
72
|
+
* the same backdrop-and-readable-text every block's `theme` field gives, from the site's own roles.
|
|
73
|
+
*/
|
|
74
|
+
export function themeAttrs(theme: string | undefined): { class?: string; style?: string } {
|
|
75
|
+
const t = resolveTheme(theme);
|
|
76
|
+
return t ? { class: t.className, style: t.style } : {};
|
|
77
|
+
}
|