@jtakeit/kit 0.5.2 → 0.7.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 +20 -8
- package/booking.css +23 -10
- package/elements/booking-ui.js +91 -21
- package/elements/booking.js +170 -29
- package/elements/lead.js +26 -4
- package/index.mjs +2 -1
- package/lib/booking/hooks.mjs +91 -0
- package/lib/booking/render.mjs +110 -42
- package/lib/booking/words.mjs +8 -0
- package/lib/catalogue.mjs +96 -1
- package/lib/content.mjs +7 -2
- package/lib/kits.mjs +5 -1
- package/lib/lint.mjs +39 -0
- package/lib/meta.mjs +11 -3
- package/lib/schedule.mjs +46 -0
- package/package.json +5 -1
- package/types/booking.d.ts +22 -0
- package/types/content.d.ts +1 -1
- package/types/elements-booking.d.ts +1 -0
- package/types/hooks.d.ts +12 -0
- package/types/index.d.ts +4 -2
- package/types/meta.d.ts +1 -1
package/lib/booking/render.mjs
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* what they answer. Nothing about availability is computed on the page, and
|
|
9
9
|
* nothing about the visitor's choice is trusted by the server.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Two ways to the same form. `renderBookingForm` writes it as a string, for a
|
|
12
|
+
* site on any generator: Eleventy in a shortcode, Next in
|
|
13
|
+
* `dangerouslySetInnerHTML`, a Node script in a template literal.
|
|
14
|
+
* `bookingForm` gives what that string is made of — the words, the services
|
|
15
|
+
* in their runs, the form's attributes, the days — for a page that writes the
|
|
16
|
+
* markup itself, the way `@jtakeit/astro`'s `BookingForm.astro` does, so the
|
|
17
|
+
* markup is the site's and only the facts are the kit's. Either way, keep the
|
|
18
|
+
* hooks (`./hooks.mjs`): the element reads those and nothing else, and the
|
|
16
19
|
* platform reads what the element sends.
|
|
17
20
|
*
|
|
18
21
|
* ── what the page passes in ─────────────────────────────────────────────────
|
|
@@ -48,7 +51,7 @@ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class',
|
|
|
48
51
|
* @property {number} [costs] minor units, for the same
|
|
49
52
|
* @property {{ day: string, from: number, until: number, costs: number }[]} [rates] a price by the hour of the week instead of `costs`: the list says «from …», and the price is the slot's, printed beside the time
|
|
50
53
|
* @property {string} [per] `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies
|
|
51
|
-
* @property {string} [group] a heading the services are listed under
|
|
54
|
+
* @property {string} [group] a heading the services are listed under — the words (an option's label, a category's title), not the stored value; services with none are listed after every heading
|
|
52
55
|
* @property {string[]} [does] for a resource: the services they perform, by slug — the element greys the others out once they are chosen; empty or absent is everything
|
|
53
56
|
*/
|
|
54
57
|
|
|
@@ -87,31 +90,45 @@ function esc(value) {
|
|
|
87
90
|
/**
|
|
88
91
|
* A service's price as the list says it: the price, or «from …» for one
|
|
89
92
|
* priced by the hour of the week — whose price is the slot's and is printed
|
|
90
|
-
* beside the time once one is chosen (wiki/68).
|
|
93
|
+
* beside the time once one is chosen (wiki/68). Plain text, not escaped.
|
|
91
94
|
* @param {Named} one
|
|
92
95
|
* @param {string} currency
|
|
93
96
|
* @param {string} lang
|
|
97
|
+
* @param {Record<string, string>} words
|
|
94
98
|
*/
|
|
95
|
-
function
|
|
96
|
-
if (one.costs) return
|
|
99
|
+
function priceText(one, currency, lang, words) {
|
|
100
|
+
if (one.costs) return money(one.costs, currency, lang);
|
|
97
101
|
const least = priceFrom(one);
|
|
98
|
-
return least > 0 ?
|
|
102
|
+
return least > 0 ? `${words.from} ${money(least, currency, lang)}` : '';
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
102
|
-
*
|
|
106
|
+
* @typedef {object} BookingForm
|
|
107
|
+
* @property {Record<string, string>} words the form's words, in the page's language; the platform's answer replaces the kind's
|
|
108
|
+
* @property {string} lang the language tag prices and days are written in
|
|
109
|
+
* @property {boolean} combine a checklist, or one `<select>`
|
|
110
|
+
* @property {{ group: string; items: (Named & { meta: string })[] }[]} runs the checklist's runs, headings in order, the ones with none after them, the add-ons last under their own; `meta` is «45 min · CHF 95»
|
|
111
|
+
* @property {Named[]} services for one `<select>`, as they came
|
|
112
|
+
* @property {(Named & { does: string })[]} resources the choice of whom — empty for none; `does` is the slugs, joined, for `data-does`
|
|
113
|
+
* @property {string} today the day input's `min`
|
|
114
|
+
* @property {string} last its `max`
|
|
115
|
+
* @property {Record<string, string>} attrs every attribute of the `<form>`, `data-booking` and `novalidate` among them — spread them on it
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the booking form is made of, for a page that writes the markup.
|
|
103
120
|
*
|
|
104
121
|
* @param {BookingFormProps} props
|
|
105
|
-
* @returns {
|
|
122
|
+
* @returns {BookingForm}
|
|
106
123
|
*/
|
|
107
|
-
export function
|
|
124
|
+
export function bookingForm(props) {
|
|
108
125
|
const {
|
|
109
126
|
services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
|
|
110
|
-
api = '/api/',
|
|
127
|
+
api = '/api/', now = new Date(), open = [], ui = true,
|
|
111
128
|
} = props;
|
|
112
|
-
if (!Array.isArray(services)) throw new TypeError('
|
|
129
|
+
if (!Array.isArray(services)) throw new TypeError('bookingForm: services is a list of { slug, title }');
|
|
113
130
|
const unsaid = kind !== '' && kind !== 'salon';
|
|
114
|
-
const
|
|
131
|
+
const words = wordsFor(locale, scale);
|
|
115
132
|
const lang = { de: 'de-CH', uk: 'uk-UA', ru: 'ru-RU', es: 'es-ES', it: 'it-IT', pt: 'pt-PT', fr: 'fr-FR' }[locale] ?? 'en';
|
|
116
133
|
|
|
117
134
|
// The checklist's runs, in the order the services came, each heading once.
|
|
@@ -123,6 +140,14 @@ export function renderBookingForm(props) {
|
|
|
123
140
|
if (run) run.items.push(one);
|
|
124
141
|
else runs.push({ group, items: [one] });
|
|
125
142
|
}
|
|
143
|
+
// The services with no heading go after every run that has one, wherever
|
|
144
|
+
// the first of them came: drawn first, they read as the run above the first
|
|
145
|
+
// heading, and drawn between two they read as the one before. A group is
|
|
146
|
+
// the heading's words — an option's label, a category's title — never the
|
|
147
|
+
// stored value; the page that reads the catalogue resolves it (12 October
|
|
148
|
+
// 2026: the scaffold passed `entry.data.group` itself).
|
|
149
|
+
const loose = runs.findIndex((r) => r.group === '');
|
|
150
|
+
if (loose >= 0 && runs.length > 1) runs.push(...runs.splice(loose, 1));
|
|
126
151
|
|
|
127
152
|
const today = now.toISOString().slice(0, 10);
|
|
128
153
|
const last = new Date(now.getTime() + daysAhead * 86_400_000).toISOString().slice(0, 10);
|
|
@@ -131,15 +156,62 @@ export function renderBookingForm(props) {
|
|
|
131
156
|
// its own heading, after the services, and rides on the visit (wiki/70).
|
|
132
157
|
const addOns = runs.flatMap((run) => run.items.filter((one) => !one.takes && (one.costs || priceFrom(one))));
|
|
133
158
|
const timedRuns = runs.map((run) => ({ ...run, items: run.items.filter((one) => !addOns.includes(one)) })).filter((run) => run.items.length > 0);
|
|
159
|
+
/** @param {Named} one */
|
|
160
|
+
const meta = (one) => [one.takes ? `${one.takes} min` : '', priceText(one, currency, lang, words), one.per === 'person' ? words.perPerson : ''].filter(Boolean).join(' · ');
|
|
161
|
+
|
|
162
|
+
// Fixed, the whole form, as the lead form's labels are: every word in it is
|
|
163
|
+
// the platform's — spoken in the business's kind from the availability
|
|
164
|
+
// answer, «Забронювати» over the build's «Записатися» — or an entry's own
|
|
165
|
+
// title, which the owner edits on the entry. None is the owner's to edit
|
|
166
|
+
// here, and `jtk catalogue` asked whose all seventeen were on a fresh
|
|
167
|
+
// restaurant (30 September 2026).
|
|
168
|
+
/** @type {Record<string, string>} */
|
|
169
|
+
const attrs = {
|
|
170
|
+
'data-booking': '',
|
|
171
|
+
'data-locale': locale,
|
|
172
|
+
'data-currency': currency,
|
|
173
|
+
'data-words': JSON.stringify(words),
|
|
174
|
+
'data-scale': scale,
|
|
175
|
+
'data-api': api,
|
|
176
|
+
'data-sends': SENDS.join(','),
|
|
177
|
+
'data-open': open.join(','),
|
|
178
|
+
...(ui ? { 'data-ui': ui === true ? 'steps week parts select' : String(ui) } : {}),
|
|
179
|
+
...(unsaid ? { 'data-unsaid': '' } : {}),
|
|
180
|
+
// Its own name as its value: Astro drops a boolean attribute spread as
|
|
181
|
+
// '', and the browser's own check stopped every booking without a name
|
|
182
|
+
// before the form could say so (12 October 2026).
|
|
183
|
+
novalidate: 'novalidate',
|
|
184
|
+
'data-jtk-fixed': '',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
words, lang, combine, today, last, attrs, services,
|
|
189
|
+
runs: [...timedRuns, ...(addOns.length ? [{ group: words.addOns, items: addOns }] : [])].map((run) => ({ ...run, items: run.items.map((one) => ({ ...one, meta: meta(one) })) })),
|
|
190
|
+
resources: resources.length > 1 ? resources.map((one) => ({ ...one, does: Array.isArray(one.does) ? one.does.join(',') : '' })) : [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The booking form as HTML — `bookingForm`'s facts in the kit's own markup.
|
|
196
|
+
*
|
|
197
|
+
* @param {BookingFormProps} props
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
export function renderBookingForm(props) {
|
|
201
|
+
const { tag = 'jtk-booking', kind = '' } = props;
|
|
202
|
+
if (!Array.isArray(props.services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
|
|
203
|
+
const { words: WORDS, combine, runs, services, resources, today, last, attrs } = bookingForm(props);
|
|
204
|
+
const unsaid = kind !== '' && kind !== 'salon';
|
|
205
|
+
|
|
134
206
|
const servicesField = combine
|
|
135
207
|
? `<fieldset class="booking__services" data-services>
|
|
136
208
|
<legend class="field__label" data-say="services">${esc(WORDS.services)}</legend>
|
|
137
|
-
${
|
|
138
|
-
${run.group !== '' ? `<p class="booking__run-title">${esc(run.group)}</p>` : ''}
|
|
139
|
-
${run.items.map((one) => `<label class="booking__service">
|
|
209
|
+
${runs.map((run, i) => `<div class="booking__run"${run.group !== '' ? ` role="group" aria-labelledby="booking-run-${i}"` : ''}>
|
|
210
|
+
${run.group !== '' ? `<p class="booking__run-title" id="booking-run-${i}">${esc(run.group)}</p>` : ''}
|
|
211
|
+
${run.items.map((one) => `<label class="booking__service" data-service-row>
|
|
140
212
|
<input type="checkbox" name="service" value="${esc(one.slug)}" data-takes="${esc(one.takes ?? 0)}" data-costs="${esc(one.costs ?? 0)}"${one.per === 'person' ? ' data-per="person"' : ''}>
|
|
141
|
-
<span class="booking__service-name">${esc(one.title)}</span>
|
|
142
|
-
<span class="booking__service-meta">${
|
|
213
|
+
<span class="booking__service-name" data-service-name>${esc(one.title)}</span>
|
|
214
|
+
<span class="booking__service-meta">${esc(one.meta)}</span>
|
|
143
215
|
</label>`).join('\n')}
|
|
144
216
|
</div>`).join('\n')}
|
|
145
217
|
<p class="booking__total" data-total hidden></p>
|
|
@@ -149,53 +221,48 @@ export function renderBookingForm(props) {
|
|
|
149
221
|
${services.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
|
|
150
222
|
</select>`;
|
|
151
223
|
|
|
152
|
-
const resourcesField = resources.length >
|
|
153
|
-
? `<div class="field">
|
|
224
|
+
const resourcesField = resources.length > 0
|
|
225
|
+
? `<div class="field" data-field>
|
|
154
226
|
<label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
|
|
155
227
|
<select class="field__input" id="booking-resource" name="resource" data-say-wait>
|
|
156
228
|
<option value="" data-say="any">${esc(WORDS.any)}</option>
|
|
157
|
-
${resources.map((one) => `<option value="${esc(one.slug)}"${
|
|
229
|
+
${resources.map((one) => `<option value="${esc(one.slug)}"${one.does ? ` data-does="${esc(one.does)}"` : ''}>${esc(one.title)}</option>`).join('\n')}
|
|
158
230
|
</select>
|
|
159
231
|
</div>`
|
|
160
232
|
: '';
|
|
161
233
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// answer, «Забронювати» over the build's «Записатися» — or an entry's own
|
|
165
|
-
// title, which the owner edits on the entry. None is the owner's to edit
|
|
166
|
-
// here, and `jtk catalogue` asked whose all seventeen were on a fresh
|
|
167
|
-
// restaurant (30 September 2026).
|
|
168
|
-
const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" data-open="${esc(open.join(','))}"${ui ? ` data-ui="${ui === true ? 'steps week parts select' : esc(ui)}"` : ''} novalidate data-jtk-fixed>
|
|
234
|
+
const said = Object.entries(attrs).map(([key, value]) => (value === '' || value === key ? ` ${key}` : ` ${key}="${esc(value)}"`)).join('');
|
|
235
|
+
const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}"${said}>
|
|
169
236
|
<noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
|
|
170
237
|
|
|
171
|
-
<div class="field">
|
|
238
|
+
<div class="field" data-field>
|
|
172
239
|
${servicesField}
|
|
173
240
|
</div>
|
|
174
241
|
|
|
175
242
|
${resourcesField}
|
|
176
243
|
|
|
177
|
-
<div class="field">
|
|
244
|
+
<div class="field" data-field>
|
|
178
245
|
<label class="field__label" for="booking-day">${esc(WORDS.day)}</label>
|
|
179
246
|
<input class="field__input" id="booking-day" name="day" type="date" min="${today}" max="${last}" required>
|
|
180
247
|
</div>
|
|
181
248
|
|
|
182
|
-
<div class="field" data-back-field hidden>
|
|
249
|
+
<div class="field" data-field data-back-field hidden>
|
|
183
250
|
<label class="field__label" for="booking-back">${esc(WORDS.back)}</label>
|
|
184
251
|
<input class="field__input" id="booking-back" name="back" type="date" min="${today}">
|
|
185
252
|
<p class="booking__hint" data-length hidden></p>
|
|
186
253
|
</div>
|
|
187
254
|
|
|
188
|
-
<div class="field" data-hours-field hidden>
|
|
255
|
+
<div class="field" data-field data-hours-field hidden>
|
|
189
256
|
<label class="field__label" for="booking-hours">${esc(WORDS.hours)}</label>
|
|
190
257
|
<select class="field__input" id="booking-hours" name="hours"></select>
|
|
191
258
|
</div>
|
|
192
259
|
|
|
193
|
-
<div class="field" data-party-field hidden>
|
|
260
|
+
<div class="field" data-field data-party-field hidden>
|
|
194
261
|
<label class="field__label" for="booking-party">${esc(WORDS.party)}</label>
|
|
195
262
|
<select class="field__input" id="booking-party" name="party"></select>
|
|
196
263
|
</div>
|
|
197
264
|
|
|
198
|
-
<div class="field" data-quantity-field hidden>
|
|
265
|
+
<div class="field" data-field data-quantity-field hidden>
|
|
199
266
|
<label class="field__label" for="booking-quantity">${esc(WORDS.quantity)}</label>
|
|
200
267
|
<select class="field__input" id="booking-quantity" name="quantity"></select>
|
|
201
268
|
</div>
|
|
@@ -208,26 +275,26 @@ export function renderBookingForm(props) {
|
|
|
208
275
|
<p class="booking__total" data-stay-total hidden></p>
|
|
209
276
|
</fieldset>
|
|
210
277
|
|
|
211
|
-
<div class="field">
|
|
278
|
+
<div class="field" data-field>
|
|
212
279
|
<label class="field__label" for="booking-name">${esc(WORDS.name)} <span aria-hidden="true">*</span></label>
|
|
213
280
|
<input class="field__input" id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120">
|
|
214
281
|
</div>
|
|
215
|
-
<div class="field">
|
|
282
|
+
<div class="field" data-field>
|
|
216
283
|
<label class="field__label" for="booking-phone">${esc(WORDS.phone)}</label>
|
|
217
284
|
<input class="field__input" id="booking-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="40">
|
|
218
285
|
</div>
|
|
219
|
-
<div class="field">
|
|
286
|
+
<div class="field" data-field>
|
|
220
287
|
<label class="field__label" for="booking-email">${esc(WORDS.email)}</label>
|
|
221
288
|
<input class="field__input" id="booking-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="120">
|
|
222
289
|
</div>
|
|
223
|
-
<div class="field">
|
|
290
|
+
<div class="field" data-field>
|
|
224
291
|
<label class="field__label" for="booking-note">${esc(WORDS.note)}</label>
|
|
225
292
|
<textarea class="field__input field__input--area" id="booking-note" name="note" rows="3" maxlength="2000"></textarea>
|
|
226
293
|
</div>
|
|
227
294
|
|
|
228
295
|
<div class="booking__challenge" data-challenge hidden></div>
|
|
229
296
|
|
|
230
|
-
<p class="field__error" data-booking-error role="alert" hidden></p>
|
|
297
|
+
<p class="field__error" id="booking-error" data-booking-error role="alert" hidden></p>
|
|
231
298
|
|
|
232
299
|
<button class="booking__submit" type="submit" data-submit data-say="book">${esc(WORDS.book)}</button>
|
|
233
300
|
|
|
@@ -242,6 +309,7 @@ export function renderBookingForm(props) {
|
|
|
242
309
|
<button type="button" class="booking__keep-copy" data-keep-copy></button>
|
|
243
310
|
<a class="booking__keep-open" data-keep-open></a>
|
|
244
311
|
</div>
|
|
312
|
+
<span class="booking__sr" role="status" data-keep-said></span>
|
|
245
313
|
</div>
|
|
246
314
|
<p class="booking__note" data-pay-note hidden></p>
|
|
247
315
|
<p class="booking__links">
|
package/lib/booking/words.mjs
CHANGED
|
@@ -49,6 +49,7 @@ const BASE = {
|
|
|
49
49
|
back: 'До', length: 'Від {min} до {max} діб.', anyOf: 'Будь-яке вільне — {class}', noneOf: '{class} — немає вільних', notFor: '{who} — не для обраного', notForChosen: '{who} — не для обраного. Оберіть іншого або «{any}».', took: 'Ви берете: {what}',
|
|
50
50
|
party: 'Скільки вас', perPerson: 'за особу', addOns: 'Додатково', quantity: 'Скільки — доріжок, столів, кімнат', together: '{n} разом', hours: 'Скільки часу', hourShort: 'год', minuteShort: 'хв', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
|
|
51
51
|
from: 'від',
|
|
52
|
+
needName: "Вкажіть імʼя, щоб ми знали, кого чекати.",
|
|
52
53
|
needContact: 'Вкажіть телефон або пошту, щоб ми могли з вами звʼязатися.', noScript: 'Онлайн-форма працює з увімкненим JavaScript — увімкніть його або зателефонуйте нам.',
|
|
53
54
|
},
|
|
54
55
|
de: {
|
|
@@ -87,6 +88,7 @@ const BASE = {
|
|
|
87
88
|
back: 'Bis', length: 'Von {min} bis {max} Nächte.', anyOf: 'Beliebig frei — {class}', noneOf: '{class} — nichts verfügbar', notFor: '{who} — nicht für diese Auswahl', notForChosen: '{who} — nicht für diese Auswahl. Bitte jemand anderen oder «{any}» wählen.', took: 'Sie nehmen: {what}',
|
|
88
89
|
party: 'Wie viele Personen', perPerson: 'pro Person', addOns: 'Zusätzlich', quantity: 'Wie viele — Bahnen, Tische, Zimmer', together: '{n} zusammen', hours: 'Wie lange', hourShort: 'Std.', minuteShort: 'Min.', challenge: 'Bitte bestätigen Sie, dass Sie kein Roboter sind, und versuchen Sie es erneut.', tooMany: 'Sie haben bereits mehrere offene Termine. Sagen Sie einen ab oder rufen Sie uns an.',
|
|
89
90
|
from: 'ab',
|
|
91
|
+
needName: "Bitte geben Sie Ihren Namen an.",
|
|
90
92
|
needContact: 'Bitte Telefon oder E-Mail angeben, damit wir Sie erreichen können.', noScript: 'Für die Online-Buchung ist JavaScript nötig — oder rufen Sie uns an.',
|
|
91
93
|
},
|
|
92
94
|
en: {
|
|
@@ -125,6 +127,7 @@ const BASE = {
|
|
|
125
127
|
back: 'Until', length: 'From {min} to {max} nights.', anyOf: 'Any free — {class}', noneOf: '{class} — none available', notFor: '{who} — not for this choice', notForChosen: '{who} — not for this choice. Pick another, or «{any}».', took: 'You are taking: {what}',
|
|
126
128
|
party: 'How many of you', perPerson: 'per person', addOns: 'Add-ons', quantity: 'How many — lanes, tables, rooms', together: '{n} together', hours: 'How long', hourShort: 'h', minuteShort: 'min', challenge: 'Please confirm you are not a robot and try again.', tooMany: 'You already have several open bookings. Cancel one, or ring us.',
|
|
127
129
|
from: 'from',
|
|
130
|
+
needName: "Please give your name.",
|
|
128
131
|
needContact: 'Leave a phone number or an email so we can reach you.', noScript: 'Booking online needs JavaScript — or ring us.',
|
|
129
132
|
},
|
|
130
133
|
ru: {
|
|
@@ -192,6 +195,7 @@ const BASE = {
|
|
|
192
195
|
challenge: "Подтвердите, что вы не робот, и попробуйте еще раз.",
|
|
193
196
|
tooMany: "У вас уже есть несколько открытых записей. Отмените одну из них или позвоните нам.",
|
|
194
197
|
from: 'от',
|
|
198
|
+
needName: "Укажите имя, чтобы мы знали, кого ждать.",
|
|
195
199
|
needContact: "Укажите телефон или почту, чтобы мы могли с вами связаться.",
|
|
196
200
|
noScript: "Онлайн-форма работает с включенным JavaScript — включите его или позвоните нам.",
|
|
197
201
|
},
|
|
@@ -260,6 +264,7 @@ const BASE = {
|
|
|
260
264
|
challenge: "Confirme que no es un robot e inténtelo de nuevo.",
|
|
261
265
|
tooMany: "Ya tiene varias reservas abiertas. Cancele una o llámenos.",
|
|
262
266
|
from: 'desde',
|
|
267
|
+
needName: "Indique su nombre, por favor.",
|
|
263
268
|
needContact: "Deje un teléfono o un correo electrónico para que podamos contactar con usted.",
|
|
264
269
|
noScript: "Para reservar en línea hace falta JavaScript — o llámenos.",
|
|
265
270
|
},
|
|
@@ -328,6 +333,7 @@ const BASE = {
|
|
|
328
333
|
challenge: "Confermi di non essere un robot e riprovi.",
|
|
329
334
|
tooMany: "Ha già diverse prenotazioni aperte. Ne annulli una, o ci chiami.",
|
|
330
335
|
from: 'da',
|
|
336
|
+
needName: "Indichi il Suo nome, per favore.",
|
|
331
337
|
needContact: "Lasci un numero di telefono o un'email per poterLa contattare.",
|
|
332
338
|
noScript: "Per prenotare online serve JavaScript: oppure ci chiami.",
|
|
333
339
|
},
|
|
@@ -396,6 +402,7 @@ const BASE = {
|
|
|
396
402
|
challenge: "Confirme que não é um robô e tente de novo.",
|
|
397
403
|
tooMany: "Já tem várias reservas em aberto. Cancele uma ou ligue-nos.",
|
|
398
404
|
from: 'desde',
|
|
405
|
+
needName: "Indique o seu nome, por favor.",
|
|
399
406
|
needContact: "Deixe um telefone ou um e-mail para podermos contactá-lo.",
|
|
400
407
|
noScript: "Reservar online precisa de JavaScript — ou ligue-nos.",
|
|
401
408
|
},
|
|
@@ -464,6 +471,7 @@ const BASE = {
|
|
|
464
471
|
challenge: "Veuillez confirmer que vous n’êtes pas un robot et réessayer.",
|
|
465
472
|
tooMany: "Vous avez déjà plusieurs réservations en cours. Annulez-en une, ou appelez-nous.",
|
|
466
473
|
from: 'à partir de',
|
|
474
|
+
needName: "Indiquez votre nom, s'il vous plaît.",
|
|
467
475
|
needContact: "Laissez un numéro de téléphone ou un e-mail pour que nous puissions vous joindre.",
|
|
468
476
|
noScript: "Réserver en ligne nécessite JavaScript — ou appelez-nous.",
|
|
469
477
|
},
|
package/lib/catalogue.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
51
51
|
import { pathToFileURL } from 'node:url';
|
|
52
52
|
|
|
53
53
|
import { E, isAdvice, isPlatformCode, LOCAL } from './codes.mjs';
|
|
54
|
+
import { shownAt } from './schedule.mjs';
|
|
54
55
|
|
|
55
56
|
// What this tool calls itself in a catalogue it writes. Bumped when the shape
|
|
56
57
|
// it emits changes; the `contract` beside it moves only when the format does.
|
|
@@ -587,11 +588,49 @@ for (const collection of collections) {
|
|
|
587
588
|
}
|
|
588
589
|
}
|
|
589
590
|
|
|
591
|
+
/*
|
|
592
|
+
* What the entries are grouped by: a select, or a ref to another
|
|
593
|
+
* collection, and nothing else — the platform's validateEntryGroup, mirrored
|
|
594
|
+
* (12 October 2026, wiki/78). Free text would be a list of one-entry groups
|
|
595
|
+
* with three spellings of "manicure". A ref into the collection itself is a
|
|
596
|
+
* tree, which is a `parent` field and takes no part in grouping: an entry
|
|
597
|
+
* heading its own siblings is a listing nobody can order.
|
|
598
|
+
*/
|
|
599
|
+
if (collection.group !== undefined && collection.group !== '') {
|
|
600
|
+
const groupWhere = `${where}.group`;
|
|
601
|
+
const by = field(collection.group);
|
|
602
|
+
if (by === undefined) {
|
|
603
|
+
fault(E.COLLECTION, groupWhere, `grouped by "${collection.group}", which ${collection.type} does not declare`);
|
|
604
|
+
} else if (by.kind === 'ref' && by.ref === collection.name) {
|
|
605
|
+
fault(E.COLLECTION, groupWhere, `grouped by ${collection.type}.${collection.group}, which points into "${collection.name}" itself — group by another collection, and make a tree with a parent field`);
|
|
606
|
+
} else if (by.kind !== 'select' && by.kind !== 'ref') {
|
|
607
|
+
fault(E.COLLECTION, groupWhere, `grouped by ${collection.type}.${collection.group}, which is "${by.kind}" — group by a select, or by a ref to another collection`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
590
611
|
const perPage = collection.per_page ?? 0;
|
|
591
612
|
if (!Number.isInteger(perPage) || perPage < 0 || perPage > 200) {
|
|
592
613
|
fault(E.COLLECTION, where, 'per_page must be between 1 and 200, or absent for one page');
|
|
593
614
|
}
|
|
594
615
|
|
|
616
|
+
/*
|
|
617
|
+
* A listing at the prefix, asked for (the platform's CollectionDef.Listing,
|
|
618
|
+
* 12 October 2026): the scaffold draws one only where a collection says
|
|
619
|
+
* `listing: true`, because a page nobody asked for is an address on a
|
|
620
|
+
* client's site nobody designed. Only for a collection whose entries have
|
|
621
|
+
* pages — one without is listed on its prefix's page already, which the
|
|
622
|
+
* repository writes.
|
|
623
|
+
*/
|
|
624
|
+
if (collection.listing !== undefined && typeof collection.listing !== 'boolean') {
|
|
625
|
+
fault(E.COLLECTION, `${where}.listing`, `listing is ${JSON.stringify(collection.listing)} — true, or absent for no listing at the prefix`);
|
|
626
|
+
} else if (collection.listing === true && collection.pages === false) {
|
|
627
|
+
fault(
|
|
628
|
+
E.COLLECTION,
|
|
629
|
+
`${where}.listing`,
|
|
630
|
+
`collection "${collection.name}" has no pages of its own, so its prefix's page is the listing already — the repository writes it; \`listing\` is for a collection whose entries have pages`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
595
634
|
/*
|
|
596
635
|
* What a post may hold, and the one type it must.
|
|
597
636
|
*
|
|
@@ -619,6 +658,30 @@ for (const collection of collections) {
|
|
|
619
658
|
}
|
|
620
659
|
}
|
|
621
660
|
|
|
661
|
+
/*
|
|
662
|
+
* Every `ref`, wherever it is — a block's field, a row of a list, the page's
|
|
663
|
+
* SEO, the business facts — names a collection this catalogue declares. The
|
|
664
|
+
* platform's validateRefs, mirrored on 12 October 2026, when a ref became
|
|
665
|
+
* the way a site has categories and tags (wiki/78): `tags: list of { tag:
|
|
666
|
+
* ref tags }` with no `tags` collection is a field whose every value points
|
|
667
|
+
* nowhere, and the judge refused it while this said nothing.
|
|
668
|
+
*/
|
|
669
|
+
{
|
|
670
|
+
const names = new Set(collections.map((one) => one.name));
|
|
671
|
+
const walk = (where, fields) => {
|
|
672
|
+
for (const one of fields ?? []) {
|
|
673
|
+
const at = `${where}.${one.key}`;
|
|
674
|
+
if (one.kind === 'ref' && !names.has(one.ref)) {
|
|
675
|
+
fault(E.COLLECTION, `${at}.ref`, `${at} refers to collection ${JSON.stringify(one.ref ?? '')}, which this catalogue does not declare`);
|
|
676
|
+
}
|
|
677
|
+
walk(at, one.of);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
catalogue.blocks.forEach((block, at) => walk(`blocks[${at}]`, block.fields));
|
|
681
|
+
walk('page_seo', catalogue.page_seo);
|
|
682
|
+
walk('business_facts', catalogue.business_facts);
|
|
683
|
+
}
|
|
684
|
+
|
|
622
685
|
/*
|
|
623
686
|
* ── can the platform serve this at all ─────────────────────────────────────
|
|
624
687
|
*
|
|
@@ -1346,6 +1409,21 @@ const sharedBlocks = existsSync(sharedFile)
|
|
|
1346
1409
|
const byType = new Map(catalogue.blocks.map((block) => [block.type, block]));
|
|
1347
1410
|
const disagreements = [];
|
|
1348
1411
|
|
|
1412
|
+
/*
|
|
1413
|
+
* Which fields a page must carry a tap target for: the platform's own rule,
|
|
1414
|
+
* `tappable()` in its sitebuild/annotation-lint.mjs, copied (12 October 2026).
|
|
1415
|
+
*
|
|
1416
|
+
* This used to ask it of every field but a bool or a `no_tap_target`, which
|
|
1417
|
+
* is stricter than the judge in exactly the shapes wiki/78 made the way a
|
|
1418
|
+
* site has categories and tags: a service's `category: ref categories`, a
|
|
1419
|
+
* dish's `course` select, a work's `tags: [{ tag }]` were each reported as a
|
|
1420
|
+
* control that edits nothing — they are edited in the panel beside the page,
|
|
1421
|
+
* and the platform's build asks no element of them. A field outside the rule
|
|
1422
|
+
* may still be annotated; it is accepted, never required.
|
|
1423
|
+
*/
|
|
1424
|
+
const TAPPABLE = new Set(['text', 'textarea', 'richtext_lite', 'markdown', 'media', 'money', 'duration']);
|
|
1425
|
+
const tappable = (field) => TAPPABLE.has(field.kind) && field.client_editable !== false && field.no_tap_target !== true;
|
|
1426
|
+
|
|
1349
1427
|
/** `/de/services/x` without its language: `/services/x`. The site's own language has no prefix. */
|
|
1350
1428
|
function basePathOf(path, locales) {
|
|
1351
1429
|
for (const locale of locales) {
|
|
@@ -1370,6 +1448,13 @@ for (const page of pages) {
|
|
|
1370
1448
|
(c) => c.pages === false && (page.collection === c.name || bare.startsWith(`${c.prefix}/`)),
|
|
1371
1449
|
);
|
|
1372
1450
|
if (listedOnly) {
|
|
1451
|
+
/*
|
|
1452
|
+
* A hidden one is on no listing — `visible: false`, or a schedule not
|
|
1453
|
+
* come or gone (schedule.mjs), which the platform writes `visible:
|
|
1454
|
+
* false` before its build — and is owed nowhere, as the platform's
|
|
1455
|
+
* lint says (`listable`). This asked it of a hidden one too.
|
|
1456
|
+
*/
|
|
1457
|
+
if (!shownAt(page)) continue;
|
|
1373
1458
|
/*
|
|
1374
1459
|
* No page of its own, so its fields are annotated on the listing as
|
|
1375
1460
|
* `page:<address>:<field>` — and the platform's lint fails a build
|
|
@@ -1389,7 +1474,7 @@ for (const page of pages) {
|
|
|
1389
1474
|
const value = block[field.key];
|
|
1390
1475
|
const empty = value === undefined || value === null || value === '' ||
|
|
1391
1476
|
(Array.isArray(value) && value.length === 0);
|
|
1392
|
-
if (empty || field
|
|
1477
|
+
if (empty || !tappable(field) || (field.kind === 'media' && field.multiple)) continue;
|
|
1393
1478
|
if (!everywhere.has(`page:${page.path}:blocks[${at}].${field.key}`)) {
|
|
1394
1479
|
disagreements.push(
|
|
1395
1480
|
`${page.path}: blocks[${at}].${field.key} is on no page — an entry without a page of its own is annotated on its listing as page:${page.path}:blocks[${at}].${field.key}`,
|
|
@@ -1419,6 +1504,12 @@ for (const page of pages) {
|
|
|
1419
1504
|
if (empty || field.no_tap_target || field.kind === 'bool') continue;
|
|
1420
1505
|
|
|
1421
1506
|
const path = `blocks[${at}].${field.key}`;
|
|
1507
|
+
// Outside the platform's rule and not a list (whose rows are asked one
|
|
1508
|
+
// by one below): accepted where it is annotated, asked for nowhere.
|
|
1509
|
+
if (field.kind !== 'list' && !tappable(field)) {
|
|
1510
|
+
onThePage.delete(path);
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1422
1513
|
|
|
1423
1514
|
/*
|
|
1424
1515
|
* A gallery: one field, many pictures, and each picture is its own tap
|
|
@@ -1455,6 +1546,10 @@ for (const page of pages) {
|
|
|
1455
1546
|
for (const sub of field.of ?? []) {
|
|
1456
1547
|
if (sub.no_tap_target || !row[sub.key]) continue;
|
|
1457
1548
|
const rowPath = `blocks[${at}].${field.key}[${i}].${sub.key}`;
|
|
1549
|
+
if (!tappable(sub)) {
|
|
1550
|
+
onThePage.delete(rowPath);
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1458
1553
|
if (!onThePage.has(rowPath)) {
|
|
1459
1554
|
disagreements.push(`${rowPath} is declared and the page does not render it`);
|
|
1460
1555
|
}
|
package/lib/content.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { readFile, readdir } from 'node:fs/promises';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { shownAt } from './schedule.mjs';
|
|
13
14
|
|
|
14
15
|
/** @param {string} path */
|
|
15
16
|
async function readJSON(path) {
|
|
@@ -59,9 +60,10 @@ export function sharedLocaleOf(name) {
|
|
|
59
60
|
* anyway with the site's facts left empty — the annotations do not depend on
|
|
60
61
|
* them.
|
|
61
62
|
* @param {string} root the site's root, holding `jtk/`
|
|
62
|
-
* @param {{ dir?: string }} [options] `dir`: where `jtk/` is, relative to the root
|
|
63
|
+
* @param {{ dir?: string; now?: number }} [options] `dir`: where `jtk/` is, relative to the root; `now`: the clock a `show_at`/`hide_at` is read against
|
|
63
64
|
*/
|
|
64
65
|
export async function collectContent(root, options = {}) {
|
|
66
|
+
const now = options.now ?? Date.now();
|
|
65
67
|
const dir = join(root, options.dir ?? 'jtk');
|
|
66
68
|
const site = (await readJSON(join(dir, 'site.json'))) ?? {};
|
|
67
69
|
const pages = [];
|
|
@@ -72,7 +74,10 @@ export async function collectContent(root, options = {}) {
|
|
|
72
74
|
const collected = { path: page.path, seo: page.seo ?? {}, blocks: page.blocks ?? [] };
|
|
73
75
|
if (typeof page.collection === 'string' && page.collection !== '') {
|
|
74
76
|
collected.collection = page.collection;
|
|
75
|
-
|
|
77
|
+
// Hidden by `visible: false`, or by a schedule not come or gone
|
|
78
|
+
// (schedule.mjs) — read by the clock of this check, as the platform's
|
|
79
|
+
// build reads it by its own.
|
|
80
|
+
collected.visible = shownAt(page, now);
|
|
76
81
|
}
|
|
77
82
|
pages.push(collected);
|
|
78
83
|
}
|
package/lib/kits.mjs
CHANGED
|
@@ -137,7 +137,11 @@ export function kitEntries(kit, locale) {
|
|
|
137
137
|
const collection = kit.catalogue.collections.find((one) => one.name === entry.collection);
|
|
138
138
|
const block = { _key: `kit${String(i + 1).padStart(5, '0')}`, type: collection.type, v: 1 };
|
|
139
139
|
for (const [key, value] of Object.entries(entry.fields)) {
|
|
140
|
-
|
|
140
|
+
// An object is the words per language; a list — a master's `does`, a
|
|
141
|
+
// post's `tags: [{ tag }]` — is the value itself. It was read as a map
|
|
142
|
+
// of languages and laid out as '' (12 October 2026), which broke the
|
|
143
|
+
// listing that maps a resource's `does`.
|
|
144
|
+
block[key] = Array.isArray(value) ? value : value !== null && typeof value === 'object' ? (value[lang] ?? '') : value;
|
|
141
145
|
}
|
|
142
146
|
const path = `${collection.prefix}/${entry.slug}`;
|
|
143
147
|
const doc = { path, collection: collection.name, visible: true, seo: { title: '', description: '' }, blocks: [block] };
|
package/lib/lint.mjs
CHANGED
|
@@ -301,6 +301,8 @@ export async function lint(options = {}) {
|
|
|
301
301
|
// Entries of a collection without pages: on their listing, annotated there.
|
|
302
302
|
const pageless = new Set((registry?.collections ?? []).filter((one) => one.pages === false).map((one) => one.name));
|
|
303
303
|
const everywhere = [...htmlByPath.values()].join('\n');
|
|
304
|
+
// `visible` as collectContent read it: false for `visible: false` and for a
|
|
305
|
+
// schedule not come or gone (schedule.mjs) — a listing leaves both out.
|
|
304
306
|
const listable = (page) => page.visible !== false;
|
|
305
307
|
for (const one of registry?.collections ?? []) {
|
|
306
308
|
if (one.pages !== false) continue;
|
|
@@ -361,8 +363,45 @@ export async function lint(options = {}) {
|
|
|
361
363
|
* The command: print the findings the way the build does, and exit 1 on any.
|
|
362
364
|
* @param {{ root?: string, dist?: string, siteURL?: string }} options
|
|
363
365
|
*/
|
|
366
|
+
/**
|
|
367
|
+
* The files under a built directory the platform never publishes: a path with
|
|
368
|
+
* a segment that starts with a dot, `.well-known/` at the root aside — the
|
|
369
|
+
* rule the platform's upload and edge keep (its wiki/81). A directory is said
|
|
370
|
+
* once, with a slash.
|
|
371
|
+
* @param {string} dir
|
|
372
|
+
* @returns {Promise<string[]>}
|
|
373
|
+
*/
|
|
374
|
+
export async function withheldIn(dir) {
|
|
375
|
+
/** @type {string[]} */
|
|
376
|
+
const out = [];
|
|
377
|
+
/** @param {string} at @param {string} rel */
|
|
378
|
+
async function walk(at, rel) {
|
|
379
|
+
let entries;
|
|
380
|
+
try {
|
|
381
|
+
entries = await readdir(at, { withFileTypes: true });
|
|
382
|
+
} catch {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
for (const entry of entries) {
|
|
386
|
+
const path = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
387
|
+
const dotted = entry.name.startsWith('.') && !(rel === '' && entry.name === '.well-known');
|
|
388
|
+
if (dotted) out.push(entry.isDirectory() ? `${path}/` : path);
|
|
389
|
+
else if (entry.isDirectory()) await walk(join(at, entry.name), path);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
await walk(dir, '');
|
|
393
|
+
return out.sort();
|
|
394
|
+
}
|
|
395
|
+
|
|
364
396
|
export async function lintCommand(options) {
|
|
365
397
|
const out = await lint(options);
|
|
398
|
+
// Said, and not a failure: the platform leaves these out of the upload and
|
|
399
|
+
// its preflight fails the site until a build without them is served. Here
|
|
400
|
+
// they are caught before the push, which is when they are cheapest.
|
|
401
|
+
const withheld = await withheldIn(options.dist ?? join(options.root ?? process.cwd(), 'dist'));
|
|
402
|
+
if (withheld.length > 0) {
|
|
403
|
+
console.error(` not published: ${withheld.join(', ')} — a path starting with a dot is never served (.well-known/ aside). It comes from the repository (usually public/): remove it there, and change any secret it held — it is in the history.`);
|
|
404
|
+
}
|
|
366
405
|
for (const one of out.findings) console.error(' ' + said(one.code, one.says));
|
|
367
406
|
if (out.line !== '') (out.ok ? console.log : console.error)(' ' + out.line);
|
|
368
407
|
return out.ok ? 0 : 1;
|
package/lib/meta.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
36
36
|
import { join, relative, sep } from 'node:path';
|
|
37
|
+
import { shownAt } from './schedule.mjs';
|
|
37
38
|
|
|
38
39
|
/** The id of one specimen, which is also its address under the collection. */
|
|
39
40
|
export function specimenId(type, view) {
|
|
@@ -76,12 +77,16 @@ export function specimenAddresses(collections, blocks, locales = []) {
|
|
|
76
77
|
*
|
|
77
78
|
* A document, not frontmatter: an entry is a page of blocks in a `.json`
|
|
78
79
|
* file. Only an entry carries `visible` at all; a page has none and is not a
|
|
79
|
-
* draft. `=== false` and not falsy: absent means listed
|
|
80
|
+
* draft. `=== false` and not falsy: absent means listed — and so does a
|
|
81
|
+
* schedule that has come and not gone (`shownAt`, schedule.mjs): a `show_at`
|
|
82
|
+
* still ahead or a `hide_at` behind is a draft at the moment of the build,
|
|
83
|
+
* as it is on the platform, which writes it `visible: false` before building.
|
|
80
84
|
*
|
|
81
85
|
* @param {string} [root] where the content is, `jtk/content` under the working directory
|
|
86
|
+
* @param {number} [now] the clock a schedule is read against, the build's by default
|
|
82
87
|
* @returns {Promise<string[]>}
|
|
83
88
|
*/
|
|
84
|
-
export async function hiddenEntries(root = join(process.cwd(), 'jtk', 'content')) {
|
|
89
|
+
export async function hiddenEntries(root = join(process.cwd(), 'jtk', 'content'), now = Date.now()) {
|
|
85
90
|
const out = [];
|
|
86
91
|
const walk = async (at) => {
|
|
87
92
|
let entries;
|
|
@@ -104,7 +109,10 @@ export async function hiddenEntries(root = join(process.cwd(), 'jtk', 'content')
|
|
|
104
109
|
} catch {
|
|
105
110
|
continue; // unreadable JSON is the build's error to report, not this one
|
|
106
111
|
}
|
|
107
|
-
|
|
112
|
+
// A schedule is an entry's (it names its collection); a page carries
|
|
113
|
+
// none, and `visible: false` is read wherever it is written, as before.
|
|
114
|
+
const isEntry = typeof document?.collection === 'string' && document.collection !== '';
|
|
115
|
+
if (document?.visible === false || (isEntry && !shownAt(document, now))) {
|
|
108
116
|
const name = relative(root, full).split(sep).join('/').replace(/\.json$/, '');
|
|
109
117
|
out.push(`/${name}/`);
|
|
110
118
|
}
|