@jtakeit/kit 0.5.1 → 0.6.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 CHANGED
@@ -31,7 +31,7 @@ shape, so the form says what it sends and the element sends nothing else.
31
31
  import { renderBookingForm } from '@jtakeit/kit/booking';
32
32
 
33
33
  const html = renderBookingForm({
34
- services, // [{ slug, title, takes, costs, rates, group }], the entries with `takes`; `rates` instead of `costs` says «from …»
34
+ services, // [{ slug, title, takes, costs, rates, group }], the entries with `takes`; `rates` instead of `costs` says «from …»; `group` is a heading's words (a select's label, a category's title), and the services with none come after every heading
35
35
  resources, // [{ slug, title }] — leave empty for a solo business
36
36
  locale: 'uk', // uk, de or en: the words before the platform has spoken
37
37
  combine: settings.combine === true, // jtk/bookings.json
@@ -636,6 +636,12 @@ export function wireBooking(form) {
636
636
  return;
637
637
  }
638
638
  hint.textContent = '';
639
+ // A price beside each time only where the times differ in price — a
640
+ // rate by the hour (wiki/68). Where every time costs the same, the
641
+ // price is the services' and is said beside them: «09:00 · CHF 95» on
642
+ // thirty pills is one number read thirty times (11 October 2026).
643
+ const slotPrices = new Set(data.slots.map((one) => one.costs_minor ?? 0));
644
+ const pricedByTime = slotPrices.size > 1;
639
645
  for (const slot of data.slots) {
640
646
  const button = document.createElement('button');
641
647
  button.type = 'button';
@@ -646,8 +652,8 @@ export function wireBooking(form) {
646
652
  ? slot.ends ? `${dayAndHour(slot.start, false)} — ${dayAndHour(slot.ends, false)}` : dayAndHour(slot.start, false)
647
653
  : clock(slot.start);
648
654
  // What the booking starting here costs — the platform's number, every
649
- // hour by its own row (wiki/68) — beside the time, whenever one came.
650
- if (slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
655
+ // hour by its own row (wiki/68) — beside the time, where it varies.
656
+ if (pricedByTime && slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
651
657
  button.dataset.start = slot.start;
652
658
  button.addEventListener('click', () => {
653
659
  chosen = slot.start;
@@ -48,7 +48,7 @@ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class',
48
48
  * @property {number} [costs] minor units, for the same
49
49
  * @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
50
  * @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
51
+ * @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
52
  * @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
53
  */
54
54
 
@@ -123,6 +123,14 @@ export function renderBookingForm(props) {
123
123
  if (run) run.items.push(one);
124
124
  else runs.push({ group, items: [one] });
125
125
  }
126
+ // The services with no heading go after every run that has one, wherever
127
+ // the first of them came: drawn first, they read as the run above the first
128
+ // heading, and drawn between two they read as the one before. A group is
129
+ // the heading's words — an option's label, a category's title — never the
130
+ // stored value; the page that reads the catalogue resolves it (12 October
131
+ // 2026: the scaffold passed `entry.data.group` itself).
132
+ const loose = runs.findIndex((r) => r.group === '');
133
+ if (loose >= 0 && runs.length > 1) runs.push(...runs.splice(loose, 1));
126
134
 
127
135
  const today = now.toISOString().slice(0, 10);
128
136
  const last = new Date(now.getTime() + daysAhead * 86_400_000).toISOString().slice(0, 10);
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;
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
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether an entry is out yet, and still out — the platform's scheduled
3
+ * publishing, read the way the platform reads it.
4
+ *
5
+ * An entry's file may carry `"show_at"` (hidden until then) and `"hide_at"`
6
+ * (hidden from then on), both RFC 3339 in UTC. A build the platform makes
7
+ * rewrites such an entry to `visible: false` in its checkout before the
8
+ * generator runs, so on the platform this changes nothing. On a laptop the
9
+ * files are read as they are, and every reader here that asks `visible` — the
10
+ * drafts in `_meta.json` (meta.mjs), the content the check reads (content.mjs,
11
+ * and so lint.mjs) — asks this instead, so a post scheduled for next week is a
12
+ * draft and not a listing's missing row.
13
+ *
14
+ * `visible: false` hides an entry whatever its dates say. A value that is not
15
+ * a time is no schedule: the platform refuses one where it is written, and a
16
+ * file edited by hand that says `"next week"` is shown rather than hidden for
17
+ * a reason nobody can see.
18
+ *
19
+ * The scaffold's entry loader asks the same question in
20
+ * `template/src/lib/schedule.ts` — a copy, not an import: a scaffold is built
21
+ * against the published kit, which may not have this file yet. The two are
22
+ * held to one answer by `test/schedule.test.mjs` at the repository's root.
23
+ */
24
+
25
+ /**
26
+ * Whether the site lists the entry at `now` (milliseconds since the epoch).
27
+ * @param {{ visible?: boolean; show_at?: unknown; hide_at?: unknown } | null | undefined} document
28
+ * @param {number} [now]
29
+ * @returns {boolean}
30
+ */
31
+ export function shownAt(document, now = Date.now()) {
32
+ if (document == null) return true;
33
+ if (document.visible === false) return false;
34
+ const show = instant(document.show_at);
35
+ if (show !== undefined && now < show) return false;
36
+ const hide = instant(document.hide_at);
37
+ if (hide !== undefined && now >= hide) return false;
38
+ return true;
39
+ }
40
+
41
+ /** A time written down, or `undefined` for none and for one that is not a time. */
42
+ function instant(value) {
43
+ if (typeof value !== 'string' || value.trim() === '') return undefined;
44
+ const at = Date.parse(value);
45
+ return Number.isNaN(at) ? undefined : at;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtakeit/kit",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The framework-free half of a site on jtakeit: the booking form as an element and as markup, for Astro through @jtakeit/astro and for any other generator directly",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ export interface Named {
7
7
  rates?: { day: string; from: number; until: number; costs: number }[];
8
8
  /** `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies. */
9
9
  per?: 'booking' | 'person';
10
+ /** The heading a service is listed under: the words — an option's label, a category's title — not the stored value. Services with none come after every heading. */
10
11
  group?: string;
11
12
  /** For a resource: the services they perform, by slug. The element greys the others out once they are chosen. */
12
13
  does?: string[];
@@ -1,5 +1,5 @@
1
1
  /** The published content as one document, from the files in `jtk/` — what the build renders from. */
2
- export function collectContent(root: string, options?: { dir?: string }): Promise<{
2
+ export function collectContent(root: string, options?: { dir?: string; now?: number }): Promise<{
3
3
  schema_version: number;
4
4
  content_rev: number;
5
5
  site: { slug: string; locale: string; media_base: string; business: Record<string, unknown> };
package/types/meta.d.ts CHANGED
@@ -21,6 +21,6 @@ export function specimenAddresses(
21
21
  blocks: { type: string; views?: { key: string }[] }[],
22
22
  locales?: string[],
23
23
  ): string[];
24
- export function hiddenEntries(root?: string): Promise<string[]>;
24
+ export function hiddenEntries(root?: string, now?: number): Promise<string[]>;
25
25
  export function buildMeta(what?: MetaInput): Promise<Meta>;
26
26
  export function writeMeta(dir: string, what?: MetaInput): Promise<Meta>;