@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.
@@ -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
- * This is the markup `@jtakeit/astro`'s `BookingForm.astro` used to carry
12
- * itself; it is a string now so a site on any generator can put the same form
13
- * on a page: Eleventy in a shortcode, Next in `dangerouslySetInnerHTML`, a
14
- * Node script in a template literal. Keep the `data-*` hooks and the input
15
- * `name`s whatever you do to its classes: the element reads those, and the
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 priceSaid(one, currency, lang) {
96
- if (one.costs) return ` · ${esc(money(one.costs, currency, lang))}`;
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 ? ` · ${esc(wordsFor(lang).from)} ${esc(money(least, currency, lang))}` : '';
102
+ return least > 0 ? `${words.from} ${money(least, currency, lang)}` : '';
99
103
  }
100
104
 
101
105
  /**
102
- * The booking form as HTML.
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 {string}
122
+ * @returns {BookingForm}
106
123
  */
107
- export function renderBookingForm(props) {
124
+ export function bookingForm(props) {
108
125
  const {
109
126
  services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
110
- api = '/api/', tag = 'jtk-booking', now = new Date(), open = [], ui = true,
127
+ api = '/api/', now = new Date(), open = [], ui = true,
111
128
  } = props;
112
- if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
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 WORDS = wordsFor(locale, scale);
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
- ${[...timedRuns, ...(addOns.length ? [{ group: WORDS.addOns, items: addOns }] : [])].map((run) => `<div class="booking__run">
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">${one.takes ? `${esc(one.takes)} min` : ''}${priceSaid(one, currency, lang)}${one.per === 'person' ? ` · ${esc(WORDS.perPerson)}` : ''}</span>
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 > 1
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)}"${Array.isArray(one.does) && one.does.length > 0 ? ` data-does="${esc(one.does.join(','))}"` : ''}>${esc(one.title)}</option>`).join('\n')}
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
- // 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
- 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">
@@ -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.no_tap_target || field.kind === 'bool' || (field.kind === 'media' && field.multiple)) continue;
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
- collected.visible = page.visible !== false;
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
- block[key] = value !== null && typeof value === 'object' ? (value[lang] ?? '') : value;
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
- if (document?.visible === false) {
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
  }