@getvitops/emdash 1.0.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 +69 -0
- package/dist/index.d.mts +33 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1308 -184
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -7
- package/src/admin/DesignSystemEditorPage.tsx +15 -42
- package/src/astro/blocks/Callout.astro +73 -0
- package/src/astro/blocks/Cards.astro +53 -9
- 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/Hero.astro +77 -23
- package/src/astro/blocks/Logos.astro +48 -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 +36 -1
- package/src/astro/blocks/value.ts +78 -1
- package/src/astro/index.ts +30 -0
- package/src/admin/core-editor.d.ts +0 -7
|
@@ -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
|
+
}
|