@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.
- package/CHANGELOG.md +279 -3
- package/README.md +66 -21
- package/dist/index.d.mts +82 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1626 -152
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -13
- package/src/admin/DesignSystemEditorPage.tsx +15 -42
- package/src/astro/blocks/Accordion.astro +39 -0
- package/src/astro/blocks/ActionLink.astro +4 -2
- package/src/astro/blocks/Callout.astro +73 -0
- package/src/astro/blocks/Cards.astro +96 -0
- package/src/astro/blocks/Carousel.astro +25 -8
- package/src/astro/blocks/Embed.astro +67 -0
- package/src/astro/blocks/Entries.astro +30 -0
- package/src/astro/blocks/Form.astro +194 -0
- package/src/astro/blocks/Gallery.astro +40 -0
- package/src/astro/blocks/Hero.astro +115 -0
- package/src/astro/blocks/ImageCompare.astro +5 -2
- package/src/astro/blocks/Logos.astro +48 -0
- package/src/astro/blocks/Marquee.astro +38 -0
- package/src/astro/blocks/Nav.astro +85 -0
- package/src/astro/blocks/NavTree.astro +48 -0
- package/src/astro/blocks/Pricing.astro +66 -0
- package/src/astro/blocks/Reviews.astro +122 -0
- package/src/astro/blocks/RichText.astro +14 -0
- package/src/astro/blocks/Section.astro +84 -0
- package/src/astro/blocks/Stats.astro +59 -0
- package/src/astro/blocks/Steps.astro +48 -0
- package/src/astro/blocks/embed.test.ts +43 -0
- package/src/astro/blocks/embed.ts +84 -0
- package/src/astro/blocks/form.sample.ts +33 -0
- package/src/astro/blocks/form.test.ts +55 -0
- package/src/astro/blocks/form.ts +158 -0
- package/src/astro/blocks/head.stub.ts +2 -0
- package/src/astro/blocks/nav.test.ts +51 -0
- package/src/astro/blocks/nav.ts +65 -0
- package/src/astro/blocks/render.container.test.ts +570 -0
- package/src/astro/blocks/tone.test.ts +53 -0
- package/src/astro/blocks/tone.ts +122 -0
- package/src/astro/blocks/value.test.ts +56 -0
- package/src/astro/blocks/value.ts +103 -1
- package/src/astro/index.ts +40 -0
- package/src/admin/core-editor.d.ts +0 -7
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an editor-pasted address into the iframe `src` for a provider, and say which
|
|
3
|
+
* consent category loading it needs.
|
|
4
|
+
*
|
|
5
|
+
* Pure so the consequential cases — what is allowed to become a frame, and which
|
|
6
|
+
* category it waits on — are asserted without rendering. The rule is **closed by
|
|
7
|
+
* default**: a `custom` embed must still be `https:`, and each named provider only
|
|
8
|
+
* accepts its own hosts, so a pasted `javascript:` or an unrelated site never reaches an
|
|
9
|
+
* iframe `src`.
|
|
10
|
+
*/
|
|
11
|
+
export type EmbedProvider = 'youtube' | 'vimeo' | 'map' | 'custom';
|
|
12
|
+
|
|
13
|
+
export interface ResolvedEmbed {
|
|
14
|
+
/** Address the consent runtime assigns to the iframe after the visitor allows it. */
|
|
15
|
+
src: string;
|
|
16
|
+
/** What the placeholder calls the third party ("YouTube"). */
|
|
17
|
+
provider: string;
|
|
18
|
+
/** Where the visitor can open the same content without embedding it. */
|
|
19
|
+
openUrl: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NAMES: Record<EmbedProvider, string> = {
|
|
23
|
+
youtube: 'YouTube',
|
|
24
|
+
vimeo: 'Vimeo',
|
|
25
|
+
map: 'Google Maps',
|
|
26
|
+
custom: 'a third party',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function parse(input: unknown): URL | null {
|
|
30
|
+
if (typeof input !== 'string') return null;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(input.trim());
|
|
33
|
+
return url.protocol === 'https:' ? url : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const host = (url: URL, ...names: string[]) =>
|
|
40
|
+
names.some((n) => url.hostname === n || url.hostname.endsWith(`.${n}`));
|
|
41
|
+
|
|
42
|
+
/** The id of a YouTube watch / short / embed address, or null. */
|
|
43
|
+
function youtubeId(url: URL): string | null {
|
|
44
|
+
if (url.hostname === 'youtu.be') return url.pathname.slice(1) || null;
|
|
45
|
+
if (!host(url, 'youtube.com', 'youtube-nocookie.com')) return null;
|
|
46
|
+
if (url.pathname === '/watch') return url.searchParams.get('v');
|
|
47
|
+
const m = /^\/(?:embed|shorts|live)\/([\w-]+)/.exec(url.pathname);
|
|
48
|
+
return m ? m[1]! : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveEmbed(provider: unknown, input: unknown): ResolvedEmbed | null {
|
|
52
|
+
const url = parse(input);
|
|
53
|
+
if (!url) return null;
|
|
54
|
+
const kind = (['youtube', 'vimeo', 'map', 'custom'] as const).includes(provider as EmbedProvider)
|
|
55
|
+
? (provider as EmbedProvider)
|
|
56
|
+
: 'custom';
|
|
57
|
+
const provider_ = NAMES[kind];
|
|
58
|
+
const done = (src: string): ResolvedEmbed => ({ src, provider: provider_, openUrl: url.href });
|
|
59
|
+
|
|
60
|
+
switch (kind) {
|
|
61
|
+
case 'youtube': {
|
|
62
|
+
const id = youtubeId(url);
|
|
63
|
+
// The no-cookie host: the same player, and it sets nothing until the visitor plays.
|
|
64
|
+
return id && /^[\w-]+$/.test(id)
|
|
65
|
+
? done(`https://www.youtube-nocookie.com/embed/${id}`)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
case 'vimeo': {
|
|
69
|
+
const m = /^\/(?:video\/)?(\d+)/.exec(url.pathname);
|
|
70
|
+
return host(url, 'vimeo.com') && m
|
|
71
|
+
? done(`https://player.vimeo.com/video/${m[1]}?dnt=1`)
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
case 'map': {
|
|
75
|
+
// Only the embed endpoints: a plain maps.google.com share link refuses framing.
|
|
76
|
+
const google = host(url, 'google.com') && url.pathname.startsWith('/maps/embed');
|
|
77
|
+
const osm =
|
|
78
|
+
url.hostname === 'www.openstreetmap.org' && url.pathname.startsWith('/export/embed');
|
|
79
|
+
return google || osm ? done(url.href) : null;
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
return done(url.href);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A realistic saved `vitops.form` block, for tests and the e2e fixtures: the fields a small
|
|
3
|
+
* service business asks for, including the two names the conversion route treats specially.
|
|
4
|
+
*/
|
|
5
|
+
export const SAMPLE_FORM_BLOCK = {
|
|
6
|
+
_type: 'vitops.form',
|
|
7
|
+
_key: 'contact',
|
|
8
|
+
heading: 'Get in touch',
|
|
9
|
+
level: '2',
|
|
10
|
+
intro: 'Tell us about your project and we will reply within one business day.',
|
|
11
|
+
fields: [
|
|
12
|
+
{ label: 'Your name', name: 'name', type: 'text', required: true },
|
|
13
|
+
{
|
|
14
|
+
label: 'Email',
|
|
15
|
+
name: 'email',
|
|
16
|
+
type: 'email',
|
|
17
|
+
required: true,
|
|
18
|
+
help: 'We only use it to reply.',
|
|
19
|
+
},
|
|
20
|
+
{ label: 'Phone', name: 'phone', type: 'tel', required: false },
|
|
21
|
+
{
|
|
22
|
+
label: 'Service',
|
|
23
|
+
name: 'service',
|
|
24
|
+
type: 'select',
|
|
25
|
+
required: false,
|
|
26
|
+
options: 'Maintenance\nDesign\nInstallation',
|
|
27
|
+
},
|
|
28
|
+
{ label: 'Message', name: 'message', type: 'textarea', required: true },
|
|
29
|
+
{ label: 'Add me to your newsletter', name: 'newsletter', type: 'checkbox' },
|
|
30
|
+
],
|
|
31
|
+
submitLabel: 'Send message',
|
|
32
|
+
successMessage: 'Thanks, we will be in touch.',
|
|
33
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test';
|
|
2
|
+
import { fieldName, resolveFields, safeAction, safeNext, slugName } from './form.ts';
|
|
3
|
+
|
|
4
|
+
describe('field names', () => {
|
|
5
|
+
it('derives a safe name from the label', () => {
|
|
6
|
+
expect(slugName('Your full name!')).toBe('your_full_name');
|
|
7
|
+
expect(slugName('Café ☕')).toBe('cafe');
|
|
8
|
+
});
|
|
9
|
+
it('keeps clear of every name the conversion route treats specially', () => {
|
|
10
|
+
const taken = new Set<string>();
|
|
11
|
+
expect(fieldName('phone', 'Phone', 'tel', taken)).toBe('tel');
|
|
12
|
+
expect(fieldName('event', 'Event', 'text', taken)).toBe('event_field');
|
|
13
|
+
expect(fieldName('website', 'Website', 'text', taken)).toBe('website_field');
|
|
14
|
+
expect(fieldName('cf-turnstile-response', 'x', 'text', taken)).toBe('cf_turnstile_response');
|
|
15
|
+
});
|
|
16
|
+
it('de-duplicates and never starts with a digit', () => {
|
|
17
|
+
const taken = new Set<string>();
|
|
18
|
+
expect(fieldName('', 'Name', 'text', taken)).toBe('name');
|
|
19
|
+
expect(fieldName('', 'Name', 'text', taken)).toBe('name_2');
|
|
20
|
+
expect(fieldName('', '3 guests', 'text', new Set())).toBe('f_3_guests');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('resolveFields', () => {
|
|
25
|
+
it('drops unlabeled rows, coerces unknown kinds, and turns an empty select into text', () => {
|
|
26
|
+
const f = resolveFields([
|
|
27
|
+
{ label: '' },
|
|
28
|
+
{ label: 'A', type: 'bogus' },
|
|
29
|
+
{ label: 'B', type: 'select', options: '' },
|
|
30
|
+
{ label: 'C', type: 'select', options: 'x\n y \n' },
|
|
31
|
+
]);
|
|
32
|
+
expect(f.map((x) => [x.name, x.type])).toEqual([
|
|
33
|
+
['a', 'text'],
|
|
34
|
+
['b', 'text'],
|
|
35
|
+
['c', 'select'],
|
|
36
|
+
]);
|
|
37
|
+
expect(f[2]!.options).toEqual(['x', 'y']);
|
|
38
|
+
});
|
|
39
|
+
it('caps the number of fields', () => {
|
|
40
|
+
const many = Array.from({ length: 40 }, (_, i) => ({ label: `F${i}` }));
|
|
41
|
+
expect(resolveFields(many)).toHaveLength(20);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('same-site paths only', () => {
|
|
46
|
+
it.each(['//evil.example', 'https://evil.example', '/a\\b', '/a?x=1', '/a#f', 'contact', ''])(
|
|
47
|
+
'refuses %j',
|
|
48
|
+
(v) => expect(safeNext(v)).toBeNull(),
|
|
49
|
+
);
|
|
50
|
+
it('accepts a plain path and defaults the action', () => {
|
|
51
|
+
expect(safeNext('/contact')).toBe('/contact');
|
|
52
|
+
expect(safeAction(undefined)).toBe('/api/track');
|
|
53
|
+
expect(safeAction('/api/enquiry')).toBe('/api/enquiry');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decisions for the `vitops.form` block: which of an editor's field rows become real
|
|
3
|
+
* controls, and under what submitted names.
|
|
4
|
+
*
|
|
5
|
+
* Names matter more than they look. `createConversionRoute` (`@getvitops/astro/routes`) is
|
|
6
|
+
* what receives the post, and it reserves a few: `phone` and `event` are the tel:/mailto:
|
|
7
|
+
* click-beacon payload and are dropped from a form submission, `cf-turnstile-response` is
|
|
8
|
+
* the Turnstile token, and `website`/`honeypot`/`_honeypot` are the honeypots (a filled one
|
|
9
|
+
* makes the route answer success and deliver nothing). A visitor-facing field called
|
|
10
|
+
* "Phone" must therefore not submit as `phone`, or the number silently vanishes from the
|
|
11
|
+
* notification and the CRM record. `identifySubmitter` reads `tel` for a phone number, so
|
|
12
|
+
* that is where it goes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const FIELD_TYPES = ['text', 'email', 'tel', 'textarea', 'select', 'checkbox'] as const;
|
|
16
|
+
export type FieldType = (typeof FIELD_TYPES)[number];
|
|
17
|
+
|
|
18
|
+
/** Names the route treats specially. A field must never submit under one of these. */
|
|
19
|
+
export const RESERVED_NAMES = new Set([
|
|
20
|
+
'event',
|
|
21
|
+
'phone',
|
|
22
|
+
'cf-turnstile-response',
|
|
23
|
+
'website',
|
|
24
|
+
'honeypot',
|
|
25
|
+
'_honeypot',
|
|
26
|
+
'_next',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** The honeypot's name: the first of the route's own defaults, so no option needs passing. */
|
|
30
|
+
export const HONEYPOT_NAME = 'website';
|
|
31
|
+
|
|
32
|
+
export const MAX_FIELDS = 20;
|
|
33
|
+
|
|
34
|
+
export interface FormField {
|
|
35
|
+
/** Submitted name. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** DOM id suffix; unique within the form. */
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
type: FieldType;
|
|
41
|
+
required: boolean;
|
|
42
|
+
placeholder: string;
|
|
43
|
+
help: string;
|
|
44
|
+
options: string[];
|
|
45
|
+
autocomplete: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
|
49
|
+
|
|
50
|
+
/** `Your full name` -> `your_full_name`. Empty when the text holds nothing usable. */
|
|
51
|
+
export function slugName(input: string): string {
|
|
52
|
+
return input
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.normalize('NFKD')
|
|
55
|
+
.replace(/[̀-ͯ]/g, '')
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
57
|
+
.replace(/^_+|_+$/g, '')
|
|
58
|
+
.slice(0, 40);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A submitted name for a row: the editor's `name` if usable, else one derived from the label. */
|
|
62
|
+
export function fieldName(
|
|
63
|
+
raw: unknown,
|
|
64
|
+
label: string,
|
|
65
|
+
type: FieldType,
|
|
66
|
+
taken: Set<string>,
|
|
67
|
+
): string {
|
|
68
|
+
let base = slugName(str(raw)) || slugName(label) || type;
|
|
69
|
+
if (!/^[a-z]/.test(base)) base = `f_${base}`;
|
|
70
|
+
if (base === 'phone') base = 'tel';
|
|
71
|
+
if (RESERVED_NAMES.has(base)) base = `${base}_field`;
|
|
72
|
+
let name = base;
|
|
73
|
+
for (let n = 2; taken.has(name); n++) name = `${base}_${n}`;
|
|
74
|
+
taken.add(name);
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The HTML `autocomplete` token for a field, or undefined when there is no honest one. */
|
|
79
|
+
export function autocompleteFor(name: string, type: FieldType): string | undefined {
|
|
80
|
+
if (type === 'email') return 'email';
|
|
81
|
+
if (type === 'tel') return 'tel';
|
|
82
|
+
if (type !== 'text') return undefined;
|
|
83
|
+
const map: Record<string, string> = {
|
|
84
|
+
name: 'name',
|
|
85
|
+
full_name: 'name',
|
|
86
|
+
your_name: 'name',
|
|
87
|
+
first_name: 'given-name',
|
|
88
|
+
given_name: 'given-name',
|
|
89
|
+
last_name: 'family-name',
|
|
90
|
+
family_name: 'family-name',
|
|
91
|
+
company: 'organization',
|
|
92
|
+
organization: 'organization',
|
|
93
|
+
organisation: 'organization',
|
|
94
|
+
postal_code: 'postal-code',
|
|
95
|
+
postcode: 'postal-code',
|
|
96
|
+
zip: 'postal-code',
|
|
97
|
+
city: 'address-level2',
|
|
98
|
+
address: 'street-address',
|
|
99
|
+
};
|
|
100
|
+
return map[name];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Editor rows to renderable fields. Rows with no label are dropped; at most {@link MAX_FIELDS}. */
|
|
104
|
+
export function resolveFields(rows: Record<string, unknown>[]): FormField[] {
|
|
105
|
+
const taken = new Set<string>();
|
|
106
|
+
const out: FormField[] = [];
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
const label = str(row['label']);
|
|
109
|
+
if (!label) continue;
|
|
110
|
+
if (out.length >= MAX_FIELDS) break;
|
|
111
|
+
const rawType = str(row['type']);
|
|
112
|
+
const type = (FIELD_TYPES as readonly string[]).includes(rawType)
|
|
113
|
+
? (rawType as FieldType)
|
|
114
|
+
: 'text';
|
|
115
|
+
const name = fieldName(row['name'], label, type, taken);
|
|
116
|
+
const options =
|
|
117
|
+
type === 'select'
|
|
118
|
+
? str(row['options'])
|
|
119
|
+
.split('\n')
|
|
120
|
+
.map((o) => o.trim())
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
: [];
|
|
123
|
+
// A select with nothing to choose is unusable; render it as free text instead.
|
|
124
|
+
const effective: FieldType = type === 'select' && options.length === 0 ? 'text' : type;
|
|
125
|
+
out.push({
|
|
126
|
+
name,
|
|
127
|
+
id: `form-${out.length + 1}-${name}`,
|
|
128
|
+
label,
|
|
129
|
+
type: effective,
|
|
130
|
+
required: row['required'] === true,
|
|
131
|
+
placeholder:
|
|
132
|
+
effective === 'checkbox' || effective === 'select' ? '' : str(row['placeholder']),
|
|
133
|
+
help: str(row['help']),
|
|
134
|
+
options,
|
|
135
|
+
autocomplete: autocompleteFor(name, effective),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where the endpoint may send a visitor back to: a same-site path, nothing else. The form
|
|
143
|
+
* posts its own page path as `_next`; the route that reads it must apply this too, or the
|
|
144
|
+
* field becomes an open redirect.
|
|
145
|
+
*/
|
|
146
|
+
export function safeNext(input: unknown): string | null {
|
|
147
|
+
const v = typeof input === 'string' ? input : '';
|
|
148
|
+
if (!v.startsWith('/') || v.startsWith('//') || v.includes('\\') || v.length > 200) return null;
|
|
149
|
+
// Control characters (and a query/fragment) are exactly what a redirect target must not carry.
|
|
150
|
+
// eslint-disable-next-line no-control-regex
|
|
151
|
+
if (/[\u0000-\u001f\u007f#?]/.test(v)) return null;
|
|
152
|
+
return v;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The endpoint a form posts to: a same-site path, defaulting to the conversion route. */
|
|
156
|
+
export function safeAction(input: unknown): string {
|
|
157
|
+
return safeNext(input) ?? '/api/track';
|
|
158
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test';
|
|
2
|
+
import { menuName, navDepth, navId, navLabel, navVariant } from './nav.ts';
|
|
3
|
+
|
|
4
|
+
describe('menuName', () => {
|
|
5
|
+
it('accepts an EmDash menu slug', () => {
|
|
6
|
+
expect(menuName('footer-sitemap')).toBe('footer-sitemap');
|
|
7
|
+
expect(menuName(' primary ')).toBe('primary');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('refuses anything that is not a slug, so it never reaches getMenu()', () => {
|
|
11
|
+
for (const bad of ['', ' ', '../x', 'a b', '<b>', 'a/b', '-lead', 'x'.repeat(65), 7, null])
|
|
12
|
+
expect(menuName(bad), String(bad)).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('navVariant / navDepth', () => {
|
|
17
|
+
it('falls back to a plain list and one level', () => {
|
|
18
|
+
expect(navVariant('nope')).toBe('list');
|
|
19
|
+
expect(navVariant(undefined)).toBe('list');
|
|
20
|
+
expect(navDepth(undefined)).toBe(1);
|
|
21
|
+
expect(navDepth('abc')).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('keeps the four variants and clamps depth to 1-3', () => {
|
|
25
|
+
for (const v of ['list', 'inline', 'columns', 'sitenav']) expect(navVariant(v)).toBe(v);
|
|
26
|
+
expect([navDepth('2'), navDepth(3), navDepth('9'), navDepth(0)]).toEqual([2, 3, 3, 1]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('navLabel: a unique landmark name', () => {
|
|
31
|
+
it('prefers the label, then the heading, then the menu label, then the name', () => {
|
|
32
|
+
expect(navLabel('Services', 'Heading', 'Menu label', 'm')).toBe('Services');
|
|
33
|
+
expect(navLabel('', 'Heading', 'Menu label', 'm')).toBe('Heading');
|
|
34
|
+
expect(navLabel('', '', 'Menu label', 'm')).toBe('Menu label');
|
|
35
|
+
expect(navLabel('', '', '', 'm')).toBe('m');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never repeats a name the header or footer landmark already carries', () => {
|
|
39
|
+
// <SiteNav> is "Primary"; <SiteFooter> is "Site map" and "Legal".
|
|
40
|
+
for (const taken of ['Primary', 'primary', 'Site map', 'Legal'])
|
|
41
|
+
expect(navLabel(taken, '', '', 'x')).toBe(`${taken} (this page)`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('navId', () => {
|
|
46
|
+
it('is DOM-id safe and prefers the block key so two blocks of one menu stay apart', () => {
|
|
47
|
+
expect(navId('abc_123', 'primary')).toBe('navblock-abc-123');
|
|
48
|
+
expect(navId(undefined, 'Footer Sitemap')).toBe('navblock-footer-sitemap');
|
|
49
|
+
expect(navId('', '')).toBe('navblock-x');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decisions behind the `vitops.nav` block, kept out of `Nav.astro` so they are asserted in
|
|
3
|
+
* `nav.test.ts` without rendering (the split `sitenav.ts` makes for `<SiteNav>`). The menu-tree
|
|
4
|
+
* helpers themselves — depth trimming, `aria-current` — are `@getvitops/astro`'s, not copied.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const NAV_VARIANTS = ['list', 'inline', 'columns', 'sitenav'] as const;
|
|
8
|
+
export type NavVariant = (typeof NAV_VARIANTS)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An EmDash menu name is a slug. Anything else is refused before it reaches `getMenu()`, so a
|
|
12
|
+
* saved value can never be a path, a query or markup — and a block pointing at "nothing" is
|
|
13
|
+
* the same quiet no-op as one pointing at a menu that was deleted.
|
|
14
|
+
*/
|
|
15
|
+
export function menuName(input: unknown): string | null {
|
|
16
|
+
const name = typeof input === 'string' ? input.trim() : '';
|
|
17
|
+
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name) ? name : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function navVariant(input: unknown): NavVariant {
|
|
21
|
+
return (NAV_VARIANTS as readonly string[]).includes(String(input))
|
|
22
|
+
? (input as NavVariant)
|
|
23
|
+
: 'list';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 1–3 levels. A page-body nav that nests deeper is a site map, and wants its own page. */
|
|
27
|
+
export function navDepth(input: unknown): 1 | 2 | 3 {
|
|
28
|
+
const n = Math.round(Number(input));
|
|
29
|
+
return n >= 3 ? 3 : n === 2 ? 2 : 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Names the header and footer landmarks already use. `<SiteNav>` is "Primary" and
|
|
34
|
+
* `<SiteFooter>` is "Site map" / "Legal"; a second `<nav aria-label="Primary">` in the page
|
|
35
|
+
* body would leave a screen-reader user a landmark list of identical entries.
|
|
36
|
+
*/
|
|
37
|
+
const TAKEN = new Set(['primary', 'site map', 'legal', 'main', 'navigation', 'menu']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The accessible name for the block's `<nav>`: the editor's `label`, else the visible
|
|
41
|
+
* `heading`, else the menu's own label, else its name. If that lands on one of the names the
|
|
42
|
+
* layout's header/footer already use, a distinguishing suffix is added rather than shipping
|
|
43
|
+
* two landmarks that read the same.
|
|
44
|
+
*/
|
|
45
|
+
export function navLabel(
|
|
46
|
+
label: unknown,
|
|
47
|
+
heading: unknown,
|
|
48
|
+
menuLabel: unknown,
|
|
49
|
+
name: string,
|
|
50
|
+
): string {
|
|
51
|
+
const pick =
|
|
52
|
+
[label, heading, menuLabel, name]
|
|
53
|
+
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|
54
|
+
.find(Boolean) ?? 'Links';
|
|
55
|
+
return TAKEN.has(pick.toLowerCase()) ? `${pick} (this page)` : pick;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A DOM-id-safe slug, used to keep two `sitenav`-variant blocks' drawer ids apart. */
|
|
59
|
+
export function navId(key: unknown, name: string): string {
|
|
60
|
+
const slug = `${typeof key === 'string' && key ? key : name}`
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-|-$/g, '');
|
|
64
|
+
return `navblock-${slug || 'x'}`;
|
|
65
|
+
}
|