@jtakeit/kit 0.0.0-stage → 0.1.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/README.md +174 -2
- package/bin/jtkit.mjs +62 -0
- package/booking.css +36 -0
- package/elements/alive.js +52 -0
- package/elements/booking.js +696 -0
- package/elements/lead.js +134 -0
- package/index.mjs +21 -0
- package/lead.css +23 -0
- package/lib/booking/render.mjs +210 -0
- package/lib/booking/words.mjs +88 -0
- package/lib/catalogue.mjs +1819 -0
- package/lib/codes.mjs +171 -0
- package/lib/kits.mjs +208 -0
- package/lib/lead/render.mjs +166 -0
- package/lib/meta.mjs +150 -0
- package/lib/money.mjs +30 -0
- package/package.json +51 -4
- package/types/alive.d.ts +2 -0
- package/types/booking.d.ts +28 -0
- package/types/elements-booking.d.ts +9 -0
- package/types/elements-lead.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/kits.d.ts +24 -0
- package/types/lead.d.ts +49 -0
- package/types/meta.d.ts +26 -0
- package/types/money.d.ts +2 -0
- package/types/words.d.ts +3 -0
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'].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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|