@jtakeit/kit 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/catalogue.mjs +8 -8
- package/lib/codes.mjs +3 -3
- package/lib/devdiary.mjs +199 -0
- package/lib/kits.mjs +5 -2
- package/package.json +2 -1
package/lib/catalogue.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Both directions matter and they fail differently. A field declared and never
|
|
19
19
|
* rendered is a control in the admin that edits nothing. A field rendered and
|
|
20
20
|
* never declared is text the owner can see and cannot touch — and it is the
|
|
21
|
-
* silent one, which is why `
|
|
21
|
+
* silent one, which is why `jtkit lint` exists at the other end of the
|
|
22
22
|
* pipeline to catch it at build time too.
|
|
23
23
|
*
|
|
24
24
|
* jtk catalogue # emit, then check against dist/
|
|
@@ -149,7 +149,7 @@ if (Object.keys(modules).length > 0) catalogue.modules = modules;
|
|
|
149
149
|
* A site scaffolded as `de-CH` therefore had its labels accepted and its own
|
|
150
150
|
* language refused, with the message "not a language — two letters, or two and
|
|
151
151
|
* a region" printed against a tag that is exactly that. Every regional locale
|
|
152
|
-
*
|
|
152
|
+
* jtk create can produce hit it.
|
|
153
153
|
*
|
|
154
154
|
* The region is *read* in either case and *written* in one. There are sites in
|
|
155
155
|
* repositories spelt `de-ch` — the platform asked for that once — and a checker
|
|
@@ -1279,7 +1279,7 @@ const contentProblems = [];
|
|
|
1279
1279
|
/*
|
|
1280
1280
|
* A body with pictures in it needs the two class names that draw them.
|
|
1281
1281
|
*
|
|
1282
|
-
* `figures.mjs` turns a paragraph of images into `.
|
|
1282
|
+
* `figures.mjs` turns a paragraph of images into `.jtk-figure` and `.jtk-row`,
|
|
1283
1283
|
* and the kit ships styles for both in `src/styles/global.css`. A site that
|
|
1284
1284
|
* deleted them gets pictures at their natural size in document order, which
|
|
1285
1285
|
* looks like a broken build and is really a missing stylesheet — so it is said
|
|
@@ -1287,7 +1287,7 @@ const contentProblems = [];
|
|
|
1287
1287
|
*/
|
|
1288
1288
|
if (pages.some(hasBodyPicture)) {
|
|
1289
1289
|
const styled = readStyles(join(root, 'src'));
|
|
1290
|
-
for (const name of ['
|
|
1290
|
+
for (const name of ['jtk-figure', 'jtk-row']) {
|
|
1291
1291
|
if (!styled.includes(name)) {
|
|
1292
1292
|
contentProblems.push(
|
|
1293
1293
|
`a post has pictures in its body and nothing styles .${name}. ` +
|
|
@@ -1409,7 +1409,7 @@ const disagreements = [];
|
|
|
1409
1409
|
|
|
1410
1410
|
/*
|
|
1411
1411
|
* Which fields a page must carry a tap target for: the platform's own rule,
|
|
1412
|
-
* `tappable()` in its sitebuild
|
|
1412
|
+
* `tappable()` in its sitebuild/`jtkit lint`, copied (12 October 2026).
|
|
1413
1413
|
*
|
|
1414
1414
|
* This used to ask it of every field but a bool or a `no_tap_target`, which
|
|
1415
1415
|
* is stricter than the judge in exactly the shapes that are the way a
|
|
@@ -1745,14 +1745,14 @@ if (!flag('dist') && !existsSync(join(root, 'astro.config.mjs'))) {
|
|
|
1745
1745
|
console.log('not an Astro project: the second build under a path is skipped — build with SITE_URL=https://x.invalid/p/check/ and run again with --dist <dir> to prove the addresses');
|
|
1746
1746
|
}
|
|
1747
1747
|
if (!flag('dist') && existsSync(join(root, 'astro.config.mjs'))) {
|
|
1748
|
-
const prefix = '/p/
|
|
1749
|
-
const out = join(root, '.
|
|
1748
|
+
const prefix = '/p/jtk-check/';
|
|
1749
|
+
const out = join(root, '.jtk-prefix-check');
|
|
1750
1750
|
|
|
1751
1751
|
try {
|
|
1752
1752
|
execFileSync('npx', ['astro', 'build', '--outDir', out], {
|
|
1753
1753
|
cwd: root,
|
|
1754
1754
|
stdio: 'pipe',
|
|
1755
|
-
env: { ...process.env, SITE_URL: `https://
|
|
1755
|
+
env: { ...process.env, SITE_URL: `https://jtk-check.invalid${prefix}`, PUBLIC_INDEXABLE: 'false' },
|
|
1756
1756
|
});
|
|
1757
1757
|
|
|
1758
1758
|
const leaks = [];
|
package/lib/codes.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* what the troubleshooting page is tested against. Nothing here is authoritative
|
|
10
10
|
* about anything.
|
|
11
11
|
*
|
|
12
|
-
* It is copied because `
|
|
12
|
+
* It is copied because `jtk catalogue` has to work before there is anything to
|
|
13
13
|
* ask — on a laptop, before a deploy, before a token, on a plane. A local
|
|
14
14
|
* checker that needs the network to name a fault is a local checker nobody runs.
|
|
15
15
|
*
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* checkout, all so that a list of twenty-one strings can be regenerated on the
|
|
20
20
|
* few days a year it changes.
|
|
21
21
|
*
|
|
22
|
-
* **What keeps it honest is `
|
|
22
|
+
* **What keeps it honest is `jtk catalogue --judge`, not anybody remembering.**
|
|
23
23
|
* That flag posts the emitted catalogue to the real validator and reports every
|
|
24
24
|
* place the two answers differ — a code the server raised and we did not, or one
|
|
25
25
|
* we raised and it did not. Every client the studio runs through it is one
|
|
@@ -163,7 +163,7 @@ export const MEANS = Object.freeze({
|
|
|
163
163
|
* comparison in `--judge`: their absence from the server's answer is the correct
|
|
164
164
|
* answer rather than a gap.
|
|
165
165
|
*/
|
|
166
|
-
export const LOCAL = '
|
|
166
|
+
export const LOCAL = 'JTK_LOCAL';
|
|
167
167
|
|
|
168
168
|
/** Whether a finding is one the server can be asked about. */
|
|
169
169
|
export function isPlatformCode(code) {
|
package/lib/devdiary.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diary on the dev server — so a booking form can be walked on localhost.
|
|
3
|
+
*
|
|
4
|
+
* On the platform `/api/availability`, `/api/book` and the rest are the edge's;
|
|
5
|
+
* on `astro dev` there was nothing there, and every booking form on localhost
|
|
6
|
+
* said nothing at all: no times, no button that did anything (Schorem, 15
|
|
7
|
+
* October 2026). A developer judging a page by walking it could not walk the
|
|
8
|
+
* one thing the page is for.
|
|
9
|
+
*
|
|
10
|
+
* This answers the same requests, in the same shape, from the site's own files:
|
|
11
|
+
* the hours, the zone and the slot length in `jtk/bookings.json`, the services
|
|
12
|
+
* and the people in `jtk/content` (the collections `jtk/catalogue.json` names
|
|
13
|
+
* for the bookings module). A booking is kept in memory until the server
|
|
14
|
+
* stops — so the time it took is taken — and nothing ever leaves the machine.
|
|
15
|
+
* It is not the platform's diary: no buffers between visits, no resource's own
|
|
16
|
+
* hours, no rates by the hour of the week. Close enough to walk; `preflight`
|
|
17
|
+
* with `try_booking` is still the test of the real one.
|
|
18
|
+
*
|
|
19
|
+
* import { devDiary } from '@jtakeit/kit/devdiary';
|
|
20
|
+
* server.middlewares.use('/api', devDiary(process.cwd()));
|
|
21
|
+
*/
|
|
22
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
26
|
+
|
|
27
|
+
async function json(path) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every content file under jtk/content, with its slug. */
|
|
36
|
+
async function entries(root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const walk = async (dir) => {
|
|
39
|
+
let names = [];
|
|
40
|
+
try {
|
|
41
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const one of names) {
|
|
46
|
+
const full = join(dir, one.name);
|
|
47
|
+
if (one.isDirectory()) await walk(full);
|
|
48
|
+
else if (one.name.endsWith('.json')) {
|
|
49
|
+
const doc = await json(full);
|
|
50
|
+
if (doc?.collection) out.push({ ...doc, slug: String(doc.path ?? one.name).split('/').filter(Boolean).pop()?.replace(/\.json$/, '') });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
await walk(join(root, 'jtk', 'content'));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minutes a zone is ahead of UTC on a given day, from the platform's own Intl. */
|
|
59
|
+
function offsetOf(zone, at) {
|
|
60
|
+
try {
|
|
61
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).formatToParts(at);
|
|
62
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
63
|
+
const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
|
|
64
|
+
return Math.round((local - at.getTime()) / 60000);
|
|
65
|
+
} catch {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the site's files say, read fresh on every request so an edit shows at once. */
|
|
71
|
+
async function read(root) {
|
|
72
|
+
const catalogue = (await json(join(root, 'jtk', 'catalogue.json'))) ?? {};
|
|
73
|
+
const roles = catalogue.modules?.bookings ?? {};
|
|
74
|
+
const settings = (await json(join(root, 'jtk', 'bookings.json')))?.blocks?.[0] ?? {};
|
|
75
|
+
const all = await entries(root);
|
|
76
|
+
const servicesOf = all.filter((e) => e.collection === (roles.services ?? 'services'));
|
|
77
|
+
const peopleOf = all.filter((e) => e.collection === (roles.resources ?? 'masters'));
|
|
78
|
+
const services = servicesOf.map((e) => {
|
|
79
|
+
const b = e.blocks?.[0] ?? {};
|
|
80
|
+
return { slug: e.slug, takes: Number(b.takes) || 0, costs: Number(b.costs) || 0, title: b.title ?? e.slug };
|
|
81
|
+
});
|
|
82
|
+
const people = peopleOf.map((e) => ({ slug: e.slug, does: e.blocks?.[0]?.does ?? null }));
|
|
83
|
+
return { settings, services, people };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const send = (res, status, body) => {
|
|
87
|
+
res.statusCode = status;
|
|
88
|
+
res.setHeader('Content-Type', 'application/json');
|
|
89
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
90
|
+
res.setHeader('X-Jtk-Dev-Diary', '1');
|
|
91
|
+
res.end(JSON.stringify(body));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const bodyOf = (req) =>
|
|
95
|
+
new Promise((done) => {
|
|
96
|
+
let raw = '';
|
|
97
|
+
req.on('data', (chunk) => (raw += chunk));
|
|
98
|
+
req.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
done(JSON.parse(raw || '{}'));
|
|
101
|
+
} catch {
|
|
102
|
+
done({});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** @param {string} root the site's directory */
|
|
108
|
+
export function devDiary(root) {
|
|
109
|
+
/** start ISO → slugs of the people taken then */
|
|
110
|
+
const taken = new Map();
|
|
111
|
+
let made = 0;
|
|
112
|
+
|
|
113
|
+
return async (req, res, next) => {
|
|
114
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
115
|
+
const path = url.pathname.replace(/^\/api/, '') || '/';
|
|
116
|
+
|
|
117
|
+
if (path === '/turnstile') return send(res, 200, { sitekey: '' });
|
|
118
|
+
|
|
119
|
+
if (path === '/lead' && req.method === 'POST') {
|
|
120
|
+
const body = await bodyOf(req);
|
|
121
|
+
console.log(`[dev diary] an enquiry, kept on this machine: ${JSON.stringify(body).slice(0, 200)}`);
|
|
122
|
+
return send(res, 200, { ok: true, dev: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (path === '/availability') {
|
|
126
|
+
const { settings, services, people } = await read(root);
|
|
127
|
+
if (!settings.zone || !Array.isArray(settings.hours) || settings.hours.length === 0) {
|
|
128
|
+
return send(res, 412, {
|
|
129
|
+
code: 'failed_precondition',
|
|
130
|
+
message: !settings.zone ? 'nobody has said which timezone this business keeps' : 'nobody has said when this business is open',
|
|
131
|
+
details: { code: 'JTK_E_DIARY_NOT_READY', means: 'jtk/bookings.json needs `zone` and `hours`.' },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const asked = (url.searchParams.get('service') ?? '').split(',').concat(url.searchParams.getAll('services')).filter(Boolean);
|
|
135
|
+
const chosen = services.filter((s) => asked.includes(s.slug));
|
|
136
|
+
const takes = chosen.reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
137
|
+
const costs = chosen.reduce((sum, s) => sum + s.costs, 0);
|
|
138
|
+
const every = Number(settings.slot_every) || 15;
|
|
139
|
+
const lead = Number(settings.lead) || 0;
|
|
140
|
+
const zone = settings.zone;
|
|
141
|
+
const from = new Date(url.searchParams.get('from') ?? Date.now());
|
|
142
|
+
const to = new Date(url.searchParams.get('to') ?? Date.now() + 86400000);
|
|
143
|
+
const resource = url.searchParams.get('resource') ?? '';
|
|
144
|
+
const able = people.filter((p) => (!resource || p.slug === resource) && (!p.does || chosen.every((s) => p.does.includes(s.slug))));
|
|
145
|
+
const earliest = Date.now() + lead * 60000;
|
|
146
|
+
const slots = [];
|
|
147
|
+
// Walk the days in the business's own zone, from its local midnight.
|
|
148
|
+
for (let day = new Date(from.getTime()); day < to; day = new Date(day.getTime() + 86400000)) {
|
|
149
|
+
const offset = offsetOf(zone, day);
|
|
150
|
+
const local = new Date(day.getTime() + offset * 60000);
|
|
151
|
+
const midnightUtc = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - offset * 60000;
|
|
152
|
+
const weekday = DAYS[new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate())).getUTCDay()];
|
|
153
|
+
for (const span of settings.hours.filter((h) => h.day === weekday)) {
|
|
154
|
+
for (let m = Number(span.opens); m + takes <= Number(span.closes); m += every) {
|
|
155
|
+
const start = midnightUtc + m * 60000;
|
|
156
|
+
if (start < earliest || start < from.getTime() || start >= to.getTime()) continue;
|
|
157
|
+
const iso = new Date(start).toISOString();
|
|
158
|
+
const busy = taken.get(iso) ?? [];
|
|
159
|
+
const free = (able.length ? able : [{ slug: '' }]).map((p) => p.slug).filter((s) => !busy.includes(s));
|
|
160
|
+
slots.push({ start: iso, ends: new Date(start + takes * 60000).toISOString(), free: free.filter(Boolean), costs_minor: costs });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return send(res, 200, {
|
|
165
|
+
zone,
|
|
166
|
+
slots: slots.filter((s) => s.free.length > 0 || people.length === 0),
|
|
167
|
+
party_max: 0,
|
|
168
|
+
quantity_max: 1,
|
|
169
|
+
scale: settings.scale ?? 'hourly',
|
|
170
|
+
currency: settings.currency ?? '',
|
|
171
|
+
services: services.map((s) => ({ slug: s.slug, scale: settings.scale ?? 'hourly', takes: s.takes, fewest: 1, most: 1 })),
|
|
172
|
+
dev: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (path === '/book' && req.method === 'POST') {
|
|
177
|
+
const body = await bodyOf(req);
|
|
178
|
+
const { people, services, settings } = await read(root);
|
|
179
|
+
const start = body.start ? new Date(body.start).toISOString() : '';
|
|
180
|
+
if (!start) return send(res, 400, { code: 'invalid_argument', message: 'a booking needs a start' });
|
|
181
|
+
const busy = taken.get(start) ?? [];
|
|
182
|
+
const who = body.resource || people.map((p) => p.slug).find((s) => !busy.includes(s)) || '';
|
|
183
|
+
taken.set(start, [...busy, who]);
|
|
184
|
+
made += 1;
|
|
185
|
+
console.log(`[dev diary] booked ${body.service ?? (body.services ?? []).join('+')} at ${start}${who ? ` with ${who}` : ''} for ${body.name ?? 'somebody'} — on this machine only, nothing reached the platform`);
|
|
186
|
+
// The answer the form draws its last screen from: when, until when, with
|
|
187
|
+
// whom — without `start` and `ends` it cannot say the time, and says
|
|
188
|
+
// the booking failed instead.
|
|
189
|
+
const names = [body.service, ...(Array.isArray(body.services) ? body.services : [])].filter(Boolean);
|
|
190
|
+
const takes = services.filter((s) => names.includes(s.slug)).reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
191
|
+
const ends = new Date(new Date(start).getTime() + takes * 60000).toISOString();
|
|
192
|
+
return send(res, 200, { id: `dev-${made}`, state: 'confirmed', start, ends, amount_minor: 0, currency: settings.currency ?? '', token: `dev-${made}`, manage_path: '', telegram_url: '', resource: who, dev: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (path === '/booking/cancel' && req.method === 'POST') return send(res, 200, { ok: true, dev: true });
|
|
196
|
+
|
|
197
|
+
return next();
|
|
198
|
+
};
|
|
199
|
+
}
|
package/lib/kits.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* the settings no zone and no hours. What the kit says to take from the brief
|
|
17
17
|
* is in `kit.ask`, for whoever runs this to do next.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
21
|
|
|
22
22
|
/** Where the platform is, for a kit asked by its kind. */
|
|
@@ -74,7 +74,10 @@ export async function fetchKit(kind, api = process.env.JTK_API_URL || API) {
|
|
|
74
74
|
* @param {string} [api]
|
|
75
75
|
*/
|
|
76
76
|
export async function loadKit(asked, api) {
|
|
77
|
-
|
|
77
|
+
// A file is a file: a folder that happens to share the kind's name — a
|
|
78
|
+
// project called `salon`, run beside `--kit salon` — is not a kit, and
|
|
79
|
+
// reading it failed with EISDIR.
|
|
80
|
+
if (asked.endsWith('.json') || (existsSync(resolve(asked)) && statSync(resolve(asked)).isFile())) return readKit(asked);
|
|
78
81
|
if (/^[a-z][a-z_]*$/.test(asked)) return fetchKit(asked, api);
|
|
79
82
|
throw new Error(`--kit ${asked} is neither a kit's kind nor a file`);
|
|
80
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtakeit/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"default": "./lib/kits.mjs"
|
|
46
46
|
},
|
|
47
47
|
"./catalogue": "./lib/catalogue.mjs",
|
|
48
|
+
"./devdiary": "./lib/devdiary.mjs",
|
|
48
49
|
"./codes": "./lib/codes.mjs",
|
|
49
50
|
"./elements/booking": {
|
|
50
51
|
"types": "./types/elements-booking.d.ts",
|