@jtakeit/kit 0.0.0-stage → 0.2.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/lib/codes.mjs ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The names the platform gives to the things a catalogue can get wrong.
3
+ *
4
+ * ── this file is a copy, and that is not an accident ────────────────────────
5
+ *
6
+ * The set lives in the admin, in `backend/api/registry/errcode.go`, and that
7
+ * table is the source of truth: it is what `POST /v1/registry/validate` answers
8
+ * with, what the generated reference (`docs/reference/errors.md`) prints, and
9
+ * what the troubleshooting page is tested against. Nothing here is authoritative
10
+ * about anything.
11
+ *
12
+ * It is copied because `fl-catalogue` has to work before there is anything to
13
+ * ask — on a laptop, before a deploy, before a token, on a plane. A local
14
+ * checker that needs the network to name a fault is a local checker nobody runs.
15
+ *
16
+ * The alternative was generating this file from the Go table at build time, and
17
+ * it was rejected for what it would cost rather than what it would buy: a
18
+ * generator, a make target, a CI diff guard and a second repository in the
19
+ * checkout, all so that a list of twenty-one strings can be regenerated on the
20
+ * few days a year it changes.
21
+ *
22
+ * **What keeps it honest is `fl-catalogue --judge`, not anybody remembering.**
23
+ * That flag posts the emitted catalogue to the real validator and reports every
24
+ * place the two answers differ — a code the server raised and we did not, or one
25
+ * we raised and it did not. Every client the studio runs through it is one
26
+ * sitting of that exam (wiki/35 §5, §7), so a code that has been renamed or a
27
+ * check that has drifted is found by the next real project rather than by a
28
+ * reviewer noticing. A copy nobody checks rots in a month; a copy that is
29
+ * examined weekly is a cache.
30
+ *
31
+ * Only the catalogue's codes are here. The build's five — `JTK_E_UNANNOTATED_
32
+ * FIELD` and its siblings — belong to the annotation lint, which runs in the
33
+ * platform's build container and is judged there.
34
+ */
35
+
36
+ /**
37
+ * The codes, under the names the Go table gives them, so a reader with both
38
+ * files open can put them side by side.
39
+ */
40
+ export const E = Object.freeze({
41
+ // The file itself, before anything in it is read.
42
+ UNREADABLE: 'JTK_E_CATALOGUE_UNREADABLE',
43
+ CONTRACT: 'JTK_E_CONTRACT_UNKNOWN',
44
+ EMPTY: 'JTK_E_CATALOGUE_EMPTY',
45
+ GENERATOR: 'JTK_E_GENERATOR_INVALID',
46
+
47
+ // Names: what a thing is called, and whether it is called that twice.
48
+ NAME: 'JTK_E_NAME_INVALID',
49
+ DUPLICATE: 'JTK_E_NAME_DUPLICATE',
50
+
51
+ // Words: the label and the hint an owner reads in the admin.
52
+ LABEL_MISSING: 'JTK_E_LABEL_MISSING',
53
+ TEXT: 'JTK_E_TEXT_INVALID',
54
+
55
+ // Blocks.
56
+ BLOCK_VERSION: 'JTK_E_BLOCK_VERSION',
57
+ BLOCK_EMPTY: 'JTK_E_BLOCK_EMPTY',
58
+ COLLECTS: 'JTK_E_COLLECTS_UNKNOWN',
59
+
60
+ // Fields.
61
+ KIND: 'JTK_E_KIND_UNKNOWN',
62
+ BOUNDS: 'JTK_E_BOUNDS_INVALID',
63
+ OPTIONS: 'JTK_E_SELECT_NO_OPTIONS',
64
+ LIST: 'JTK_E_LIST_INVALID',
65
+ MEDIA: 'JTK_E_MEDIA_INVALID',
66
+
67
+ // Arrangements.
68
+ VIEW: 'JTK_E_VIEW_INVALID',
69
+
70
+ // Languages.
71
+ LOCALE: 'JTK_E_LOCALE_INVALID',
72
+
73
+ // Collections.
74
+ COLLECTION: 'JTK_E_COLLECTION_INVALID',
75
+ ENTRY_TYPE: 'JTK_E_ENTRY_TYPE_INVALID',
76
+ ORDER: 'JTK_E_ORDER_INVALID',
77
+ });
78
+
79
+ /**
80
+ * The judge's second voice: advice, with `JTK_W_` codes. A finding here
81
+ * refuses nothing — the catalogue is valid — and comes back from
82
+ * `POST /v1/registry/validate` under `advice`, apart from the refusals. This
83
+ * checker raises none of them (the rules are the server's, about the bookings
84
+ * module it runs), so `--judge` prints them and leaves them out of the exam.
85
+ */
86
+ export const W = Object.freeze({
87
+ MODULE_TWIN: 'JTK_W_MODULE_TWIN',
88
+ MODULE_POST: 'JTK_W_MODULE_POST',
89
+ });
90
+
91
+ export const ADVICE = Object.freeze({
92
+ [W.MODULE_TWIN]:
93
+ 'A page block types again a fact the bookings module holds — the owner\'s hours, the services\' prices — so the site has two copies and one goes stale.',
94
+ [W.MODULE_POST]:
95
+ 'An entry type the bookings module reads is shaped as a post — a body, or excerpt, cover and body — or its services get a page each; a service is filled in, not written.',
96
+ });
97
+
98
+ /** Advice from the judge, never a refusal. */
99
+ export function isAdvice(code) {
100
+ return typeof code === 'string' && code.startsWith('JTK_W_');
101
+ }
102
+
103
+ /**
104
+ * What each one means, in the platform's own words — the `Means` line of its row
105
+ * in `AllCodes`.
106
+ *
107
+ * Copied verbatim rather than paraphrased. A second wording of one rule is how
108
+ * two documents come to describe two rules, and the developer reading this one
109
+ * is going to be handed the other by the server a moment later.
110
+ */
111
+ export const MEANS = Object.freeze({
112
+ [E.UNREADABLE]: 'The catalogue is not JSON, or carries a key this contract has no place for.',
113
+ [E.CONTRACT]: 'The catalogue names a contract number this service does not speak.',
114
+ [E.EMPTY]: 'The catalogue declares no blocks, so it describes no site.',
115
+ [E.GENERATOR]: 'The `generator` string does not name a tool.',
116
+
117
+ [E.NAME]: 'A block type, field key, view key or collection name is not spelled the way an identifier must be.',
118
+ [E.DUPLICATE]: 'Two things in the same place are called the same thing.',
119
+
120
+ [E.LABEL_MISSING]: 'A field, view or collection has no label.',
121
+ [E.TEXT]:
122
+ 'A label or hint is written both once and per language, names a language that is not one, is empty, or runs too long.',
123
+
124
+ [E.BLOCK_VERSION]: 'A block type has no version, or a version below one.',
125
+ [E.BLOCK_EMPTY]: 'A block type declares no fields.',
126
+ [E.COLLECTS]: 'A block says it collects something nothing knows about.',
127
+
128
+ [E.KIND]: "A field's kind is not one of the thirteen.",
129
+ [E.BOUNDS]: 'A `min` or `max` is negative, or the minimum is above the maximum.',
130
+ [E.OPTIONS]: 'A `select` field offers nothing to select.',
131
+ [E.LIST]: 'A list nests another list, declares no item fields, or `of` appears on a field that is not a list.',
132
+ [E.MEDIA]:
133
+ "A media field's `accept`, `ratio` or `multiple` do not go together — or one of the three appears on a field that is not media.",
134
+
135
+ [E.VIEW]:
136
+ "A block declares arrangements but has no gallery to arrange, sets its bounds in two places, or a view's own bounds do not make sense.",
137
+
138
+ [E.LOCALE]: "A language is not a language, is declared twice, or the site's own language appears again among the others.",
139
+
140
+ [E.COLLECTION]: "A collection's prefix, pagination, entry type or body list is wrong.",
141
+ [E.ENTRY_TYPE]: 'The block type a collection names cannot be used for entries.',
142
+ [E.ORDER]: 'A collection is ordered by a field that will not order it.',
143
+ });
144
+
145
+ /**
146
+ * The mark on a finding that is this skill's own and will never come back from
147
+ * the server.
148
+ *
149
+ * ── why these may not have a JTK_E_ code ────────────────────────────────────
150
+ *
151
+ * A code is a promise the platform's documentation keeps: every one of them has
152
+ * a row in the generated reference and an anchor in the troubleshooting page,
153
+ * enforced by a test over there. Inventing a `JTK_E_…` for a rule the platform
154
+ * has never heard of would put a name in a developer's terminal that leads
155
+ * nowhere when they go and look it up.
156
+ *
157
+ * And these are genuinely not the catalogue's business. Whether every address in
158
+ * `src/` survives being served under `/p/<slug>/` is about the studio's preview
159
+ * — our deployment, our arrangement, nothing to do with the format a stranger's
160
+ * repository declares. The judge is right not to have an opinion on it.
161
+ *
162
+ * So they are marked, printed as plainly as anything else, and left out of the
163
+ * comparison in `--judge`: their absence from the server's answer is the correct
164
+ * answer rather than a gap.
165
+ */
166
+ export const LOCAL = 'FL_LOCAL';
167
+
168
+ /** Whether a finding is one the server can be asked about. */
169
+ export function isPlatformCode(code) {
170
+ return Object.hasOwn(MEANS, code);
171
+ }
package/lib/kits.mjs ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * A booking kit, laid out — the files the platform's `kits.Files` describes,
3
+ * in the same shapes, into any repository.
4
+ *
5
+ * The kit is the platform's (`GET /v1/kits/<kind>`; a page of it at
6
+ * `jtakeit:///kits/<kind>.md`) and it is judged there. What this does is
7
+ * write what a kit says into the `jtk/` files: its settings into
8
+ * `jtk/bookings.json` with `page` at the services' listing and `kit` /
9
+ * `kit_version` saying which kit and which version they came from, its rates
10
+ * as entries in the site's language, and — for a site whose catalogue is a
11
+ * file rather than a declaration — its block types, collections and the
12
+ * module into `jtk/catalogue.json`. `@jtakeit/astro` writes the same into
13
+ * `src/content/blocks.ts` instead, and takes the rest from here.
14
+ *
15
+ * Nothing written is a fact about the business: the rates carry no price and
16
+ * the settings no zone and no hours. What the kit says to take from the brief
17
+ * is in `kit.ask`, for whoever runs this to do next.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+
22
+ /** Where the platform is, for a kit asked by its kind. */
23
+ export const API = 'https://api.jtakeit.com';
24
+
25
+ /** A kit, read from a file and checked. @param {string} file */
26
+ export function readKit(file) {
27
+ let kit;
28
+ try {
29
+ kit = JSON.parse(readFileSync(resolve(file), 'utf8'));
30
+ } catch (why) {
31
+ throw new Error(`--kit ${file} is not a kit: ${why.message}`);
32
+ }
33
+ return checkKit(kit, file);
34
+ }
35
+
36
+ /**
37
+ * A kit asked of the platform by its kind (wiki/64 · §4.1): the kit whole,
38
+ * every part written out, and its version — which the site's settings record
39
+ * as `kit_version`, so the platform's judge can say when a fix to the kit has
40
+ * not reached the site (JTK_W_KIT_BEHIND).
41
+ *
42
+ * @param {string} kind
43
+ * @param {string} [api]
44
+ */
45
+ export async function fetchKit(kind, api = process.env.JTK_API_URL || API) {
46
+ const base = api.replace(/\/+$/, '');
47
+ const url = `${base}/v1/kits/${encodeURIComponent(kind)}`;
48
+ let answer;
49
+ try {
50
+ answer = await fetch(url, { headers: { accept: 'application/json' } });
51
+ } catch (why) {
52
+ throw new Error(`--kit ${kind}: the platform did not answer at ${url} (${why.message}) — pass --api, or a file: --kit <file>`);
53
+ }
54
+ if (answer.status === 404) {
55
+ let kinds = '';
56
+ try {
57
+ const list = await (await fetch(`${base}/v1/kits`)).json();
58
+ kinds = ` — the kits are ${list.kits.map((one) => one.kind).join(', ')}`;
59
+ } catch {
60
+ // The list is a courtesy; the refusal stands without it.
61
+ }
62
+ throw new Error(`--kit ${kind}: the platform has no kit of that kind${kinds}`);
63
+ }
64
+ if (!answer.ok) throw new Error(`--kit ${kind}: the platform answered ${answer.status} at ${url}`);
65
+ const body = await answer.json();
66
+ const kit = checkKit(body.kit, kind);
67
+ kit.version = body.version;
68
+ return kit;
69
+ }
70
+
71
+ /**
72
+ * A kit by its kind or by a file, whichever the argument is.
73
+ * @param {string} asked
74
+ * @param {string} [api]
75
+ */
76
+ export async function loadKit(asked, api) {
77
+ if (existsSync(resolve(asked)) || asked.endsWith('.json')) return readKit(asked);
78
+ if (/^[a-z][a-z_]*$/.test(asked)) return fetchKit(asked, api);
79
+ throw new Error(`--kit ${asked} is neither a kit's kind nor a file`);
80
+ }
81
+
82
+ /**
83
+ * A kit checked for the shape this lays out, wherever it came from: a wrong
84
+ * file stops here with its name rather than as a broken catalogue.
85
+ * @param {any} kit
86
+ * @param {string} from
87
+ */
88
+ export function checkKit(kit, from) {
89
+ const bookings = kit?.catalogue?.modules?.bookings;
90
+ if (typeof kit?.kind !== 'string' || !/^[a-z][a-z_]*$/.test(kit.kind)
91
+ || !Array.isArray(kit.catalogue?.blocks) || !Array.isArray(kit.catalogue?.collections)
92
+ || typeof bookings?.services !== 'string' || typeof bookings?.resources !== 'string'
93
+ || typeof kit.settings !== 'object' || !Array.isArray(kit.entries)) {
94
+ throw new Error(`--kit ${from} is not a kit — save the JSON under «The kit, whole» on the kit's page, jtakeit:///kits/<kind>.md`);
95
+ }
96
+ const services = kit.catalogue.collections.find((one) => one.name === bookings.services);
97
+ if (!services || services.pages !== false) {
98
+ throw new Error(`--kit ${from}: its services are not a collection without pages, and the booking page is their listing`);
99
+ }
100
+ for (const entry of kit.entries) {
101
+ if (!kit.catalogue.collections.some((one) => one.name === entry.collection)) {
102
+ throw new Error(`--kit ${from}: the entry "${entry.slug}" is in "${entry.collection}", which the kit does not declare`);
103
+ }
104
+ }
105
+ return kit;
106
+ }
107
+
108
+ /** The language a kit's words are taken in: the site's, or English where the kit has not got it. */
109
+ export function kitLanguage(locale) {
110
+ const lang = String(locale ?? '').slice(0, 2);
111
+ return ['uk', 'en', 'de', 'ru', 'es', 'it', 'pt', 'fr'].includes(lang) ? lang : 'en';
112
+ }
113
+
114
+ /**
115
+ * The diary's settings for a kit, as `jtk/bookings.json` holds them: one
116
+ * block of type `bookings_config`, `page` at the services' listing, and
117
+ * which kit and which version they came from.
118
+ * @param {any} kit
119
+ */
120
+ export function kitSettings(kit) {
121
+ const services = kit.catalogue.collections.find((one) => one.name === kit.catalogue.modules.bookings.services);
122
+ return {
123
+ type: 'bookings_config', v: 1, ...kit.settings, page: services.prefix,
124
+ kit: kit.kind, ...(kit.version ? { kit_version: kit.version } : {}),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The kit's entries as content documents, one per file, in the site's
130
+ * language: `[path, document]` pairs, the path relative to `jtk/content`.
131
+ * @param {any} kit
132
+ * @param {string} locale
133
+ */
134
+ export function kitEntries(kit, locale) {
135
+ const lang = kitLanguage(locale);
136
+ return kit.entries.map((entry, i) => {
137
+ const collection = kit.catalogue.collections.find((one) => one.name === entry.collection);
138
+ const block = { _key: `kit${String(i + 1).padStart(5, '0')}`, type: collection.type, v: 1 };
139
+ for (const [key, value] of Object.entries(entry.fields)) {
140
+ block[key] = value !== null && typeof value === 'object' ? (value[lang] ?? '') : value;
141
+ }
142
+ const path = `${collection.prefix}/${entry.slug}`;
143
+ const doc = { path, collection: collection.name, visible: true, seo: { title: '', description: '' }, blocks: [block] };
144
+ return [path, doc];
145
+ });
146
+ }
147
+
148
+ /**
149
+ * A kit's block types, collections and module put into a catalogue object —
150
+ * for a site whose catalogue is `jtk/catalogue.json` rather than a
151
+ * declaration. A type or a collection the catalogue already has by that name
152
+ * is left as it is: the site's own wins.
153
+ * @param {any} catalogue
154
+ * @param {any} kit
155
+ */
156
+ export function mergeKitIntoCatalogue(catalogue, kit) {
157
+ const out = { contract: 2, ...catalogue };
158
+ out.blocks = [...(out.blocks ?? [])];
159
+ for (const block of kit.catalogue.blocks) {
160
+ if (!out.blocks.some((one) => one.type === block.type)) out.blocks.push(block);
161
+ }
162
+ out.collections = [...(out.collections ?? [])];
163
+ for (const collection of kit.catalogue.collections) {
164
+ if (!out.collections.some((one) => one.name === collection.name)) out.collections.push(collection);
165
+ }
166
+ out.modules = { ...(out.modules ?? {}), ...kit.catalogue.modules };
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * Lays the kit's `jtk/` files out under a repository: the settings and the
172
+ * entries always; the catalogue too when `catalogue` is true (a site that
173
+ * keeps `jtk/catalogue.json` by hand) — `@jtakeit/astro` writes the
174
+ * declaration itself and passes false.
175
+ *
176
+ * @param {string} target the repository's root
177
+ * @param {any} kit
178
+ * @param {object} [options]
179
+ * @param {string} [options.locale]
180
+ * @param {boolean} [options.catalogue]
181
+ * @returns {string[]} the files written
182
+ */
183
+ export function layKitFiles(target, kit, { locale = 'en', catalogue = false } = {}) {
184
+ const literal = (value) => JSON.stringify(value, null, 2);
185
+ const written = [];
186
+ const settingsFile = join(target, 'jtk', 'bookings.json');
187
+ mkdirSync(dirname(settingsFile), { recursive: true });
188
+ writeFileSync(settingsFile, `${literal({ blocks: [kitSettings(kit)] })}\n`, 'utf8');
189
+ written.push(settingsFile);
190
+
191
+ for (const [path, doc] of kitEntries(kit, locale)) {
192
+ const file = join(target, 'jtk', 'content', ...path.split('/').filter(Boolean)) + '.json';
193
+ mkdirSync(dirname(file), { recursive: true });
194
+ writeFileSync(file, `${literal(doc)}\n`, 'utf8');
195
+ written.push(file);
196
+ }
197
+
198
+ if (catalogue) {
199
+ const file = join(target, 'jtk', 'catalogue.json');
200
+ let had = {};
201
+ if (existsSync(file)) {
202
+ try { had = JSON.parse(readFileSync(file, 'utf8')); } catch (why) { throw new Error(`${file} is not JSON: ${why.message}`); }
203
+ }
204
+ writeFileSync(file, `${literal(mergeKitIntoCatalogue(had, kit))}\n`, 'utf8');
205
+ written.push(file);
206
+ }
207
+ return written;
208
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The enquiry form, as markup — made at build time by whatever builds the
3
+ * site, and spared the navigation by `@jtakeit/kit/elements/lead`.
4
+ *
5
+ * It is a real `<form method="post">` first and an in-place submission
6
+ * second: with JavaScript off the browser posts to the same endpoint and the
7
+ * platform answers with a plain page. Every rule enforced on the page is
8
+ * enforced again by the platform behind `/api/lead` — the endpoint is public
9
+ * and the browser's checks are a convenience, not a gate.
10
+ *
11
+ * What the platform holds the form to (the build's lint, JTK_E_FORM_*): a
12
+ * `name` control, one of `contact`, `phone` or `email`, the honeypot
13
+ * `website`, and nothing else that the collecting block does not declare in
14
+ * `asks`. The words on it are the site's: the block's own fields (title,
15
+ * lead, the labels), annotated with their paths so the owner edits them by
16
+ * tapping, and the `asks` from the catalogue, fixed.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} Ask
21
+ * @property {string} key the control's name — and the key the inbox labels by
22
+ * @property {string} label in the site's language, already resolved
23
+ * @property {string} kind text, textarea, number, tel, email, url, date, time_of_day, select, bool
24
+ * @property {boolean} [required]
25
+ * @property {number} [max]
26
+ * @property {number} [min]
27
+ * @property {{ value: string; label: string }[]} [options] for a select
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} LeadCopy
32
+ * @property {string} [title]
33
+ * @property {string} [lead]
34
+ * @property {string} [name] the name field's label
35
+ * @property {string} [contact] the contact field's label
36
+ * @property {string} [message] the message field's label
37
+ * @property {string} [requiredNote]
38
+ * @property {string} [submit]
39
+ * @property {string} [sending]
40
+ * @property {string} [success]
41
+ * @property {string} [error]
42
+ * @property {string} [invalidName]
43
+ * @property {string} [invalidContact]
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} LeadPaths
48
+ * The `data-jtk-path` of each of the block's fields the form renders, or
49
+ * nothing for a build without annotations. Keys: title, lead, name_label,
50
+ * contact_label, message_label, required_note, cta_label.
51
+ * @property {string} [title]
52
+ * @property {string} [lead]
53
+ * @property {string} [name_label]
54
+ * @property {string} [contact_label]
55
+ * @property {string} [message_label]
56
+ * @property {string} [required_note]
57
+ * @property {string} [cta_label]
58
+ */
59
+
60
+ /**
61
+ * @typedef {object} LeadFormProps
62
+ * @property {string} action where `/api/lead` is from this page — under the preview the site is served at `/p/<slug>/`
63
+ * @property {LeadCopy} copy
64
+ * @property {Ask[]} [asks]
65
+ * @property {LeadPaths} [paths]
66
+ * @property {string} [tag] the element's name around the form, `jtk-lead`; `''` for none
67
+ */
68
+
69
+ const ESCAPE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
70
+ /** @param {unknown} value */
71
+ function esc(value) {
72
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE[c]);
73
+ }
74
+ /** @param {string} name @param {unknown} value */
75
+ function attr(name, value) {
76
+ return value === undefined || value === null || value === '' || value === false ? '' : value === true ? ` ${name}` : ` ${name}="${esc(value)}"`;
77
+ }
78
+ /** A `data-jtk-path`, or nothing. @param {string | undefined} path */
79
+ const at = (path) => attr('data-jtk-path', path);
80
+
81
+ /** The `<input type>` a visitor types this kind into. @param {string} kind */
82
+ export function inputType(kind) {
83
+ switch (kind) {
84
+ case 'number': return 'number';
85
+ case 'tel': return 'tel';
86
+ case 'email': return 'email';
87
+ case 'url': return 'url';
88
+ case 'date': return 'date';
89
+ case 'time_of_day': return 'time';
90
+ default: return 'text';
91
+ }
92
+ }
93
+
94
+ /** @param {Ask} ask */
95
+ function askControl(ask) {
96
+ const id = `lead-${ask.key}`;
97
+ if (ask.kind === 'textarea') {
98
+ return `<textarea class="field__input field__input--area" id="${esc(id)}" name="${esc(ask.key)}" rows="3"${attr('required', ask.required)}${attr('maxlength', ask.max)}></textarea>`;
99
+ }
100
+ if (ask.kind === 'select') {
101
+ return `<select class="field__input" id="${esc(id)}" name="${esc(ask.key)}"${attr('required', ask.required)}>
102
+ <option value=""></option>
103
+ ${(ask.options ?? []).map((o) => `<option value="${esc(o.value)}" data-jtk-fixed>${esc(o.label || o.value)}</option>`).join('\n')}
104
+ </select>`;
105
+ }
106
+ if (ask.kind === 'bool') {
107
+ return `<input class="field__check" id="${esc(id)}" name="${esc(ask.key)}" type="checkbox" value="yes">`;
108
+ }
109
+ const number = ask.kind === 'number';
110
+ return `<input class="field__input" id="${esc(id)}" name="${esc(ask.key)}" type="${inputType(ask.kind)}"${attr('required', ask.required)}${attr('maxlength', number ? undefined : ask.max)}${attr('min', number ? ask.min : undefined)}${attr('max', number ? ask.max : undefined)}>`;
111
+ }
112
+
113
+ /**
114
+ * The enquiry form as HTML.
115
+ *
116
+ * @param {LeadFormProps} props
117
+ * @returns {string}
118
+ */
119
+ export function renderLeadForm(props) {
120
+ const { action, copy, asks = [], paths = {}, tag = 'jtk-lead' } = props;
121
+ if (typeof action !== 'string' || action === '') throw new TypeError('renderLeadForm: action is where /api/lead is from this page');
122
+ if (!copy || typeof copy !== 'object') throw new TypeError('renderLeadForm: copy is the form\'s words');
123
+
124
+ const form = `<form class="lead" method="post" action="${esc(action)}" novalidate data-lead-form${attr('data-success', copy.success)}${attr('data-error', copy.error)}${attr('data-sending', copy.sending)}>
125
+ <div class="lead__head">
126
+ ${copy.title ? `<h2 class="lead__title"${at(paths.title)}>${esc(copy.title)}</h2>` : ''}
127
+ ${copy.lead ? `<p class="lead__lead"${at(paths.lead)}>${esc(copy.lead)}</p>` : ''}
128
+ </div>
129
+
130
+ <div class="field">
131
+ <label class="field__label" for="lead-name">${copy.name ? `<span${at(paths.name_label)}>${esc(copy.name)}</span>` : ''} <span aria-hidden="true">*</span></label>
132
+ <input class="field__input" id="lead-name" name="name" type="text" autocomplete="name" required maxlength="120"${attr('data-message', copy.invalidName)}>
133
+ <p class="field__error" data-error-for="name" hidden></p>
134
+ </div>
135
+
136
+ <div class="field">
137
+ <label class="field__label" for="lead-contact">${copy.contact ? `<span${at(paths.contact_label)}>${esc(copy.contact)}</span>` : ''} <span aria-hidden="true">*</span></label>
138
+ <input class="field__input" id="lead-contact" name="contact" type="text" inputmode="tel" autocomplete="tel" required maxlength="120"${attr('data-message', copy.invalidContact)}>
139
+ <p class="field__error" data-error-for="contact" hidden></p>
140
+ </div>
141
+
142
+ <div class="field">
143
+ <label class="field__label" for="lead-message">${copy.message ? `<span${at(paths.message_label)}>${esc(copy.message)}</span>` : ''}</label>
144
+ <textarea class="field__input field__input--area" id="lead-message" name="message" rows="4" maxlength="2000"></textarea>
145
+ </div>
146
+
147
+ ${asks.map((ask) => `<div class="field">
148
+ <label class="field__label" for="lead-${esc(ask.key)}" data-jtk-fixed>${esc(ask.label)}${ask.required ? ' <span aria-hidden="true">*</span>' : ''}</label>
149
+ ${askControl(ask)}
150
+ </div>`).join('\n ')}
151
+
152
+ <div class="trap" aria-hidden="true" data-jtk-fixed>
153
+ <label for="lead-website">Website</label>
154
+ <input id="lead-website" name="website" type="text" tabindex="-1" autocomplete="off">
155
+ </div>
156
+
157
+ ${copy.requiredNote ? `<p class="lead__legal"${at(paths.required_note)}>${esc(copy.requiredNote)}</p>` : ''}
158
+
159
+ <div class="lead__foot">
160
+ <button class="btn" type="submit" data-submit>${copy.submit ? `<span data-submit-label${at(paths.cta_label)}>${esc(copy.submit)}</span>` : ''}</button>
161
+ <p class="lead__status" role="status" data-status></p>
162
+ </div>
163
+ </form>`;
164
+
165
+ return tag ? `<${tag}>${form}</${tag}>` : form;
166
+ }