@tideworthy/tidysites 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # tidysites
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Tidysites website as files. Pull every record and page to a folder, edit
4
+ them (by hand, with an editor, or with an assistant), check them against the
5
+ website's content model, push the changes back in one write, and look at
6
+ the result. The website is the remote: pull, edit, push.
7
+
8
+ ```
9
+ npx -y @tideworthy/tidysites@latest init <site_id> --host https://app.tideworthy.com --key <key>
10
+ ```
11
+
12
+ The connector prints that command, with the key filled in, from its
13
+ `connect_tidysites_cli` tool. The key reaches the workspace's websites and
14
+ nothing else; it lives in `.tidysites/key`, which is kept out of git.
15
+
16
+ ## Commands
17
+
18
+ | | |
19
+ |---|---|
20
+ | `tidysites init <site_id> --host <url> --key <key>` | connect this folder to a website and pull it |
21
+ | `tidysites pull [--collections pages,services]` | the website's content to files; files for entries the website no longer has are removed |
22
+ | `tidysites status` | what changed here since the pull |
23
+ | `tidysites validate` | every file against the content model, with the file and the field in each message |
24
+ | `tidysites push [--prune] [--force] [--dry] [--only pages]` | validate, then write the created and updated entries back (with `--prune`, delete the entries whose files are gone), then pull |
25
+ | `tidysites shot <path or url> [--mobile] [--desktop] [--first-screen] [--live]` | a screenshot of a page of the preview site, the live site, or any address, saved under `.tidysites/shots/` |
26
+ | `tidysites pages` | every address the website renders |
27
+ | `tidysites page <path> [--html]` | the rendered page, section by section, with the key each section carries |
28
+ | `tidysites schema [type or collection]` | the section types and layouts, or one type's or collection's fields |
29
+ | `tidysites gallery [type]` | a screenshot of every section type and layout in this website's theme, or the first screen of one type |
30
+ | `tidysites fetch <url> [--out dir]` | a page of the site being ported: cleaned HTML, its outline (headings, navigation, images, phones, fonts, colours) and its stylesheets |
31
+ | `tidysites publish --yes` | the live site becomes a copy of the preview site |
32
+
33
+ A push that would overwrite an entry changed on the website since the pull
34
+ is refused with the list; pull again (your files are overwritten, so copy
35
+ what you need first) or push `--force`.
36
+
37
+ ## The folder
38
+
39
+ ```
40
+ <collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": { ... } }
41
+ <collection>/<slug>.css data.custom_css as a file (for rows: data.css)
42
+ rows/<slug>.html data.html as a file
43
+ menus/<name>.json a menu as a tree: { "label", "items": [{ "label", "url", "children": [...] }] }
44
+ .tidysites/ config.json, key, snapshot.json (the last pull), schema.json (the content model), shots/, source/
45
+ ```
46
+
47
+ A new entry is a new `<collection>/<slug>.json` with at least `"data"`; the
48
+ slug is the file name, the status is `published` unless the file says
49
+ `draft`. Delete a file and `push --prune` removes the entry. A field taken
50
+ out of a file is cleared on the website at the next push. `id` and
51
+ `updated_at` are the website's; leave them as pulled. A pull never overwrites
52
+ a file you changed and have not pushed; it keeps the file and the difference
53
+ shows in `status`.
54
+
55
+ Images: an image field, a hero's image or a Columns image slot takes
56
+ `{ "$media": { "url": "https://...", "alt": "what it shows" } }` and the
57
+ push fetches the file into the website's library. An image already in the
58
+ library keeps the value the pull gave it.
59
+
60
+ References: a record names another by its id (what the pull shows), or by
61
+ `"$ref:<slug>"` for one created in the same push.
62
+
63
+ Rich text (a section's `copy`, a post's `content`) is markdown.
64
+
65
+ Custom HTML (a row's `.html` file, a Columns HTML slot) reads the records:
66
+ `{{ business.phone }}`, `{{ page.title }}`, `{{#each services as s}}…{{ s.url }}…{{/each}}`,
67
+ `{{#if page.collection == "services"}}…{{/if}}`, and the drop-ins `{{> menu "primary"}}`,
68
+ `{{> logo}}`, `{{> phone}}`, `{{> address}}`, `{{> hours}}`, `{{> social}}`, `{{> year}}`,
69
+ `{{> button "Label" "/url" style=outline}}`, `{{> form "Name"}}`. A link to `#form:<Name>`
70
+ opens that form in a dialog. So a custom header is written once against `page.*` and
71
+ `business.*` and fits every page; `frame/frame.json` names it as `header_row`, or a page
72
+ names its own in `header_row`.
73
+
74
+ ## Building a site from these files
75
+
76
+ 1. `tidysites schema` for the section types, `tidysites gallery` to see them in this theme.
77
+ 2. Records first: `business/business.json` (the company's name, tagline, phone, hours, brand), `locations/`, `services/`, `people/`, `reviews/`, `faqs/`.
78
+ 3. `frame/frame.json` for the top bar, the call bar, the footer headings, the wording; `menus/primary.json` and `menus/footer.json` for the menus (an item with `children` is a dropdown).
79
+ 4. Pages as sections: `pages/<slug>.json`, each a list in `data.sections`. Columns is the section for a band with no record behind it.
80
+ 5. The look: brand colours and fonts on the Business record, `business/business.css` for what the tokens cannot say. The theme's own rules are written as `.ts.ts-accent` and `.ts.ts-ink .ts-prose a`, so a rule of yours needs the same weight (`.ts.ts-accent …`) to win.
81
+ 6. `tidysites validate`, `tidysites push`, `tidysites shot /` and `tidysites shot / --mobile`, and look.
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tidysites: a Tidysites website as files.
4
+ *
5
+ * init <site_id> --host <url> --key <key> connect this folder to a website, then pull
6
+ * pull [--collections a,b] the website's content to files
7
+ * status what changed here since the pull
8
+ * validate check the files against the content model
9
+ * push [--prune] [--force] [--dry] write the changes back, then pull
10
+ * shot <path|url> [--mobile|--desktop] [--first-screen] screenshot to .tidysites/shots
11
+ * pages every address the website renders
12
+ * page <path> [--html] the rendered page, section by section
13
+ * schema [name] section types and layouts, or one type's or collection's fields
14
+ * gallery [type] screenshot of every section type in this theme, or the first screen of one type
15
+ * fetch <url> [--out dir] a page of the site being ported: cleaned HTML and its outline
16
+ * publish --yes the live site becomes a copy of the preview site
17
+ *
18
+ * The site is the remote: pull, edit, push. A push that would overwrite an
19
+ * entry changed on the website since the pull is refused until you pull
20
+ * again (or say --force).
21
+ */
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join, relative, resolve } from 'node:path';
24
+ import { callTool, ApiError } from '../lib/api.mjs';
25
+ import { diffEntries, entryToFiles, filesToEntry, FILE_NAME, menuItems, menuTree, pushDocument, sameEntry, snapshotOf, SIDECARS } from '../lib/folder.mjs';
26
+ import { validateAll, indexSchema } from '../lib/validate.mjs';
27
+ import { cleanHtml, cssSummary, fetchPage, outline, outlineText } from '../lib/fetch.mjs';
28
+
29
+ const META = '.tidysites';
30
+ const argv = process.argv.slice(2);
31
+ const command = argv[0];
32
+ const flags = {};
33
+ const positional = [];
34
+ for (let i = 1; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a.startsWith('--')) {
37
+ const [k, inline] = a.slice(2).split('=');
38
+ if (inline !== undefined) flags[k] = inline;
39
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) flags[k] = argv[++i];
40
+ else flags[k] = true;
41
+ } else positional.push(a);
42
+ }
43
+
44
+ const say = (...a) => console.log(...a);
45
+ const fail = (msg, code = 1) => {
46
+ console.error(msg);
47
+ process.exit(code);
48
+ };
49
+
50
+ function root() {
51
+ let dir = resolve(flags.dir ?? process.cwd());
52
+ for (;;) {
53
+ if (existsSync(join(dir, META, 'config.json'))) return dir;
54
+ const up = dirname(dir);
55
+ if (up === dir) return resolve(flags.dir ?? process.cwd());
56
+ dir = up;
57
+ }
58
+ }
59
+
60
+ function config(required = true) {
61
+ const dir = root();
62
+ const path = join(dir, META, 'config.json');
63
+ if (!existsSync(path)) {
64
+ if (required) fail(`Not a Tidysites folder (no ${META}/config.json). Run \`tidysites init <site_id> --host <url> --key <key>\` first; the connector's connect_tidysites_cli tool prints that command.`);
65
+ return { dir, cfg: null };
66
+ }
67
+ const cfg = JSON.parse(readFileSync(path, 'utf8'));
68
+ const keyPath = join(dir, META, 'key');
69
+ cfg.key = process.env.TIDYSITES_KEY || (existsSync(keyPath) ? readFileSync(keyPath, 'utf8').trim() : '');
70
+ if (!cfg.key) fail(`No credential: put it in ${META}/key or TIDYSITES_KEY.`);
71
+ return { dir, cfg };
72
+ }
73
+
74
+ function writeFiles(dir, files) {
75
+ for (const [rel, content] of Object.entries(files)) {
76
+ const p = join(dir, rel);
77
+ mkdirSync(dirname(p), { recursive: true });
78
+ writeFileSync(p, content);
79
+ }
80
+ }
81
+
82
+ /** Every entry in the folder, by "collection/slug", reading sidecars beside each JSON. */
83
+ function readLocal(dir, only = null) {
84
+ const local = {};
85
+ const problems = [];
86
+ for (const name of readdirSync(dir)) {
87
+ if (name.startsWith('.') || name === 'node_modules') continue;
88
+ const colDir = join(dir, name);
89
+ if (!statSync(colDir).isDirectory()) continue;
90
+ if (!/^[a-z][a-z0-9_]*$/.test(name) || name === 'menus') continue;
91
+ if (only && !only.includes(name)) continue;
92
+ const sidecarExts = Object.keys(SIDECARS[name] ?? SIDECARS.default);
93
+ for (const file of readdirSync(colDir)) {
94
+ if (!file.endsWith('.json')) continue;
95
+ const slug = file.slice(0, -5);
96
+ if (!FILE_NAME.test(slug)) {
97
+ problems.push(`${name}/${file}: the file name is the slug; use letters, digits, - or _`);
98
+ continue;
99
+ }
100
+ const sidecars = {};
101
+ for (const ext of sidecarExts) {
102
+ const p = join(colDir, `${slug}.${ext}`);
103
+ if (existsSync(p)) sidecars[ext] = readFileSync(p, 'utf8');
104
+ }
105
+ try {
106
+ local[`${name}/${slug}`] = filesToEntry(name, slug, readFileSync(join(colDir, file), 'utf8'), sidecars);
107
+ } catch (e) {
108
+ problems.push(e.message);
109
+ }
110
+ }
111
+ }
112
+ return { local, problems };
113
+ }
114
+
115
+ function readJson(path, fallback = null) {
116
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback;
117
+ }
118
+
119
+ function describeError(e) {
120
+ let msg = e.message;
121
+ if (e.details?.conflicts) {
122
+ msg += '\n' + e.details.conflicts.map((c) => ` ${c.collection}/${c.slug}: ${c.kind === 'changed' ? `changed on the website (${c.site_updated_at})` : c.kind === 'gone' ? 'removed on the website' : 'already exists on the website'}`).join('\n');
123
+ msg += '\n\nRun `tidysites pull` to take the website\'s version (a file you changed here is kept and shows as changed against the new snapshot), or push --force to overwrite the website.';
124
+ } else if (e.details) msg += `\n${JSON.stringify(e.details, null, 2).slice(0, 4000)}`;
125
+ return msg;
126
+ }
127
+
128
+ async function tool(cfg, name, args) {
129
+ try {
130
+ return await callTool(cfg, name, args);
131
+ } catch (e) {
132
+ if (e instanceof ApiError) fail(describeError(e));
133
+ throw e;
134
+ }
135
+ }
136
+
137
+ /** The site's menus as trees, by name. */
138
+ async function readMenus(cfg) {
139
+ const { json: list } = await tool(cfg, 'call_website_site_tool', { site_id: cfg.site, tool: 'menu_list', arguments: {} });
140
+ const rows = list?.result ?? list?.menus ?? [];
141
+ const menus = {};
142
+ for (const m of Array.isArray(rows) ? rows : []) {
143
+ if (!m?.name) continue;
144
+ const { json: one } = await tool(cfg, 'call_website_site_tool', { site_id: cfg.site, tool: 'menu_get', arguments: { name: m.name } });
145
+ const menu = one?.result ?? one;
146
+ menus[m.name] = { name: m.name, label: menu?.label ?? m.label ?? m.name, items: menuTree(Array.isArray(menu?.items) ? menu.items : []) };
147
+ }
148
+ return menus;
149
+ }
150
+
151
+ function readLocalMenus(dir) {
152
+ const out = {};
153
+ const d = join(dir, 'menus');
154
+ if (!existsSync(d)) return out;
155
+ for (const f of readdirSync(d)) {
156
+ if (!f.endsWith('.json')) continue;
157
+ const name = f.slice(0, -5);
158
+ try {
159
+ const j = JSON.parse(readFileSync(join(d, f), 'utf8'));
160
+ out[name] = { name, label: j.label ?? name, items: Array.isArray(j.items) ? j.items : [] };
161
+ } catch (e) {
162
+ out[name] = { name, error: `menus/${f}: ${e.message}` };
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ async function pull({ dir, cfg }, collections = null) {
169
+ const want = collections ?? (flags.collections ? String(flags.collections).split(',').map((s) => s.trim()).filter(Boolean) : null);
170
+ const [{ json: doc }, { json: schema }] = await Promise.all([
171
+ tool(cfg, 'pull_website', { site_id: cfg.site, ...(want ? { collections: want.join(',') } : {}) }),
172
+ tool(cfg, 'get_website_schema', { site_id: cfg.site })
173
+ ]);
174
+ if (!doc?.content) fail('The pull came back without content.');
175
+ const pulled = new Set(doc.collections.map((c) => c.slug));
176
+ const { local } = readLocal(dir, [...pulled]);
177
+ const prev = readJson(join(dir, META, 'snapshot.json'));
178
+ // A file changed here since the last pull and not pushed yet is kept; the snapshot moves to the
179
+ // website's version underneath it, so status shows the difference and a push carries the new stamp.
180
+ const files = {};
181
+ const kept = [];
182
+ for (const [collection, entries] of Object.entries(doc.content)) {
183
+ for (const e of entries) {
184
+ const key = `${collection}/${e.slug ?? e.id}`;
185
+ const mine = local[key];
186
+ const was = prev?.entries?.[key];
187
+ if (mine && was && !sameEntry(mine, was) && !sameEntry(mine, { status: e.status, data: e.data })) {
188
+ kept.push(key);
189
+ continue;
190
+ }
191
+ Object.assign(files, entryToFiles(collection, e));
192
+ }
193
+ }
194
+ let removed = 0;
195
+ for (const key of Object.keys(local)) {
196
+ const [collection, slug] = key.split('/');
197
+ // Only a file the last pull wrote goes; a file you added here and never pushed stays.
198
+ if (prev?.entries?.[key] && !doc.content[collection]?.some((e) => (e.slug ?? e.id) === slug)) {
199
+ for (const ext of ['json', ...Object.keys(SIDECARS[collection] ?? SIDECARS.default)]) {
200
+ const p = join(dir, collection, `${slug}.${ext}`);
201
+ if (existsSync(p)) rmSync(p);
202
+ }
203
+ removed++;
204
+ }
205
+ }
206
+ writeFiles(dir, files);
207
+ const snap = snapshotOf(doc);
208
+ if (!want) {
209
+ const menus = await readMenus(cfg);
210
+ snap.menus = menus;
211
+ const localMenus = readLocalMenus(dir);
212
+ for (const [name, menu] of Object.entries(menus)) {
213
+ const mine = localMenus[name];
214
+ const wasMenu = prev?.menus?.[name];
215
+ if (mine && !mine.error && wasMenu && JSON.stringify(mine.items) !== JSON.stringify(wasMenu.items) && JSON.stringify(mine.items) !== JSON.stringify(menu.items)) {
216
+ kept.push(`menus/${name}`);
217
+ continue;
218
+ }
219
+ writeFiles(dir, { [`menus/${name}.json`]: `${JSON.stringify({ label: menu.label, items: menu.items }, null, '\t')}\n` });
220
+ }
221
+ } else if (prev?.menus) snap.menus = prev.menus;
222
+ if (want && prev) {
223
+ // A partial pull refreshes only its collections in the snapshot.
224
+ for (const [k, v] of Object.entries(prev.entries)) if (!pulled.has(k.split('/')[0])) snap.entries[k] = v;
225
+ snap.settings = prev.settings;
226
+ snap.collections = [...new Set([...(prev.collections ?? []), ...snap.collections])];
227
+ }
228
+ writeFileSync(join(dir, META, 'snapshot.json'), JSON.stringify(snap, null, '\t'));
229
+ writeFileSync(join(dir, META, 'schema.json'), JSON.stringify(schema, null, '\t'));
230
+ const n = doc.collections.reduce((a, c) => a + c.count, 0);
231
+ say(`Pulled ${n} entr${n === 1 ? 'y' : 'ies'} in ${doc.collections.length} collections from ${doc.site.preview_url ?? cfg.site}${removed ? `; ${removed} file${removed === 1 ? '' : 's'} removed (gone on the website)` : ''}.`);
232
+ for (const c of doc.collections.filter((c) => c.count)) say(` ${c.slug.padEnd(20)} ${c.count}`);
233
+ if (snap.menus) say(` ${'menus'.padEnd(20)} ${Object.keys(snap.menus).length}`);
234
+ for (const k of kept) say(` kept your unpushed changes in ${k}`);
235
+ }
236
+
237
+ function status({ dir }) {
238
+ const snap = readJson(join(dir, META, 'snapshot.json'));
239
+ if (!snap) fail('Nothing pulled yet. Run `tidysites pull`.');
240
+ const { local, problems } = readLocal(dir);
241
+ const d = diffEntries(local, snap);
242
+ const menus = readLocalMenus(dir);
243
+ const menusChanged = [];
244
+ for (const [name, m] of Object.entries(menus)) {
245
+ if (m.error) problems.push(m.error);
246
+ else if (JSON.stringify(m.items) !== JSON.stringify(snap.menus?.[name]?.items ?? null) || m.label !== (snap.menus?.[name]?.label ?? m.label)) menusChanged.push(name);
247
+ }
248
+ return { snap, local, problems, diff: d, menus, menusChanged };
249
+ }
250
+
251
+ function printStatus(s) {
252
+ for (const p of s.problems) say(`! ${p}`);
253
+ for (const k of s.diff.created) say(`+ ${k}`);
254
+ for (const k of s.diff.updated) say(`~ ${k}`);
255
+ for (const k of s.diff.removed) say(`- ${k} (file deleted; push --prune removes it from the website)`);
256
+ for (const m of s.menusChanged) say(`~ menus/${m}`);
257
+ const n = s.diff.created.length + s.diff.updated.length + s.menusChanged.length;
258
+ if (!n && !s.diff.removed.length && !s.problems.length) say(`Nothing changed since the pull (${s.diff.unchanged.length} entries).`);
259
+ return n;
260
+ }
261
+
262
+ function validate({ dir }, local = null) {
263
+ const schema = readJson(join(dir, META, 'schema.json'));
264
+ if (!schema) fail('No content model here yet. Run `tidysites pull`.');
265
+ const read = local ? { local, problems: [] } : readLocal(dir);
266
+ const errors = [...read.problems, ...validateAll(read.local, schema)];
267
+ return errors;
268
+ }
269
+
270
+ async function push(ctx) {
271
+ const s = status(ctx);
272
+ const errors = validate(ctx, s.local);
273
+ if (errors.length) {
274
+ for (const e of errors) say(`✗ ${e}`);
275
+ fail(`\n${errors.length} problem${errors.length === 1 ? '' : 's'}; nothing pushed.`);
276
+ }
277
+ const only = flags.only ? String(flags.only).split(',') : null;
278
+ const keys = [...s.diff.created, ...s.diff.updated].filter((k) => !only || only.includes(k.split('/')[0]));
279
+ const removeKeys = flags.prune ? s.diff.removed.filter((k) => !only || only.includes(k.split('/')[0])) : [];
280
+ const menus = s.menusChanged.filter((m) => !only || only.includes('menus'));
281
+ if (!keys.length && !removeKeys.length && !menus.length) {
282
+ say('Nothing to push.');
283
+ return;
284
+ }
285
+ const doc = pushDocument(s.local, s.snap, { keys, removeKeys });
286
+ if (flags.force) doc.force = true;
287
+ if (flags.dry) {
288
+ say(`Would push ${keys.length} entr${keys.length === 1 ? 'y' : 'ies'}${removeKeys.length ? `, remove ${removeKeys.length}` : ''}${menus.length ? `, rewrite menu${menus.length === 1 ? '' : 's'} ${menus.join(', ')}` : ''}:`);
289
+ printStatus(s);
290
+ if (flags.json) say(JSON.stringify(doc, null, 2));
291
+ return;
292
+ }
293
+ printStatus(s);
294
+ const touched = [...new Set([...keys, ...removeKeys].map((k) => k.split('/')[0]))];
295
+ if (keys.length || removeKeys.length) {
296
+ let r;
297
+ try {
298
+ ({ json: r } = await callTool(ctx.cfg, 'push_website', { site_id: ctx.cfg.site, ...doc }));
299
+ } catch (e) {
300
+ if (!(e instanceof ApiError)) throw e;
301
+ say(describeError(e));
302
+ if (e.status !== 409 && e.status !== 422) {
303
+ // The website writes entry by entry, so a failure part way leaves some of them written.
304
+ say('\nThe website may have taken part of the push. Pulling what it has now; a file of yours that still differs is kept and stays listed in status.');
305
+ await pull(ctx, touched);
306
+ }
307
+ process.exit(1);
308
+ }
309
+ say(`Pushed: ${r.content.created} created, ${r.content.updated} updated${r.images ? `, ${r.images} image${r.images === 1 ? '' : 's'} added` : ''}${r.removed ? `, ${r.removed} removed` : ''}.`);
310
+ if (r.missing?.length) say(`Not found to remove: ${r.missing.map((m) => `${m.collection}/${m.slug}`).join(', ')}`);
311
+ }
312
+ for (const name of menus) {
313
+ const m = s.menus[name];
314
+ let items;
315
+ try {
316
+ items = menuItems(m.items);
317
+ } catch (e) {
318
+ fail(`menus/${name}.json: ${e.message}`);
319
+ }
320
+ await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_set_items', arguments: { name, items } });
321
+ say(`Menu ${name}: ${items.length} item${items.length === 1 ? '' : 's'} written.`);
322
+ }
323
+ // Refresh only what went: a partial push leaves the other collections' files exactly as they are.
324
+ await pull(ctx, menus.length && !touched.length ? null : touched.length ? touched : null);
325
+ }
326
+
327
+ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
328
+ const isUrl = /^https?:\/\//.test(target);
329
+ const view = flags.mobile ? 'mobile' : flags.desktop ? 'desktop' : (opts.view ?? 'both');
330
+ const args = { site_id: ctx.cfg.site, view, full_page: !(flags['first-screen'] || opts.firstScreen), ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }) };
331
+ const { json, images } = await tool(ctx.cfg, 'screenshot_website', args);
332
+ const outDir = join(ctx.dir, META, 'shots');
333
+ mkdirSync(outDir, { recursive: true });
334
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(4, 18);
335
+ const base = (opts.name ?? (isUrl ? new URL(target).hostname + new URL(target).pathname : target)).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home';
336
+ const named = Array.isArray(json?.captures) ? json.captures : [];
337
+ images.forEach((img, i) => {
338
+ const v = named[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view);
339
+ const p = join(outDir, `${base}-${v}-${stamp}.${img.mimeType.includes('png') ? 'png' : 'jpg'}`);
340
+ writeFileSync(p, Buffer.from(img.base64, 'base64'));
341
+ say(relative(process.cwd(), p));
342
+ });
343
+ if (!images.length) say(json ? JSON.stringify(json).slice(0, 500) : 'No image came back.');
344
+ }
345
+
346
+ async function pages(ctx) {
347
+ const { json } = await tool(ctx.cfg, 'list_website_pages', { site_id: ctx.cfg.site });
348
+ const list = json?.pages ?? json?.items ?? [];
349
+ for (const p of list) say(`${(p.path ?? '').padEnd(40)} ${p.kind ?? ''}${p.collection ? ` ${p.collection}/${p.slug ?? ''}` : ''} ${p.title ?? ''}`);
350
+ if (!list.length) say(JSON.stringify(json).slice(0, 800));
351
+ }
352
+
353
+ async function page(ctx) {
354
+ const path = positional[0] ?? '/';
355
+ const { json } = await tool(ctx.cfg, 'get_website_page', { site_id: ctx.cfg.site, path });
356
+ if (!json) fail('No page came back.');
357
+ say(`${json.title ?? ''}${json.h1 ? ` (H1: ${json.h1})` : ''}`);
358
+ const sections = json.sections ?? json.blocks ?? [];
359
+ for (const s of sections) say(` ${String(s.key ?? s._key ?? '').padEnd(14)} ${String(s.type ?? s._type ?? '').padEnd(12)} ${(s.html ?? '').length} chars${s.heading ? ` ${s.heading}` : ''}`);
360
+ if (flags.html) {
361
+ const outDir = join(ctx.dir, META, 'pages');
362
+ mkdirSync(outDir, { recursive: true });
363
+ const p = join(outDir, `${path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}.html`);
364
+ writeFileSync(p, [json.header ?? '', ...sections.map((s) => `<!-- ${s.key ?? ''} ${s.type ?? ''} -->\n${s.html ?? ''}`), json.footer ?? ''].join('\n\n'));
365
+ say(relative(process.cwd(), p));
366
+ }
367
+ }
368
+
369
+ function schema(ctx) {
370
+ const s = readJson(join(ctx.dir, META, 'schema.json'));
371
+ if (!s) fail('No content model here yet. Run `tidysites pull`.');
372
+ const model = indexSchema(s);
373
+ const name = positional[0];
374
+ const fieldLine = (f) => {
375
+ const opts = f.validation?.options ?? f.options;
376
+ const sub = f.validation?.subFields ?? f.subFields;
377
+ let line = ` ${f.slug.padEnd(22)} ${f.type}${f.required ? ' (required)' : ''}${f.default !== undefined ? ` default: ${JSON.stringify(f.default)}` : ''}`;
378
+ if (f.label && f.label !== f.slug) line += ` — ${f.label}`;
379
+ if (opts) line += `\n${' '.repeat(25)}one of: ${opts.join(' | ')}`;
380
+ if (sub) line += `\n${' '.repeat(25)}items: ${sub.map((x) => `${x.slug}:${x.type}${x.options ? `(${x.options.join('|')})` : ''}`).join(', ')}`;
381
+ if (f.validation?.allowedTypes) line += `\n${' '.repeat(25)}section types: ${f.validation.allowedTypes.join(', ')}`;
382
+ return line;
383
+ };
384
+ if (!name) {
385
+ say('Section types (a page\'s sections list holds these; `tidysites schema <type>` for the fields):');
386
+ for (const t of model.sectionTypes.values()) say(` ${t.slug.padEnd(16)} ${t.description ?? t.label ?? ''}`);
387
+ say('\nCollections (`tidysites schema <collection>` for the fields):');
388
+ for (const c of model.collections.values()) say(` ${c.slug.padEnd(20)} ${c.label ?? ''}${c.description ? ` — ${c.description}` : ''}`);
389
+ return;
390
+ }
391
+ const t = model.sectionTypes.get(name);
392
+ const c = model.collections.get(name);
393
+ if (!t && !c) fail(`No section type or collection named "${name}".`);
394
+ if (t) {
395
+ say(`Section type ${t.slug}: ${t.description ?? ''}`);
396
+ for (const f of t.fields ?? []) say(fieldLine(f));
397
+ }
398
+ if (c) {
399
+ say(`Collection ${c.slug}: ${c.description ?? c.label ?? ''}`);
400
+ for (const f of c.fields ?? []) say(fieldLine(f));
401
+ }
402
+ }
403
+
404
+ async function fetchCmd(ctx) {
405
+ const url = positional[0];
406
+ if (!url) fail('Usage: tidysites fetch <url> [--out dir]');
407
+ const outDir = resolve(flags.out ?? join(ctx?.dir ?? process.cwd(), META, 'source'));
408
+ mkdirSync(outDir, { recursive: true });
409
+ const { url: final, html } = await fetchPage(url);
410
+ const o = outline(html, final);
411
+ const base = (new URL(final).pathname.replace(/\/$/, '').split('/').pop() || 'home').replace(/[^a-z0-9]+/gi, '-');
412
+ const cssTexts = [];
413
+ for (const href of o.stylesheets.slice(0, 8)) {
414
+ try {
415
+ const r = await fetch(href, { headers: { 'user-agent': 'Mozilla/5.0' } });
416
+ if (r.ok) {
417
+ const css = await r.text();
418
+ cssTexts.push(css);
419
+ writeFileSync(join(outDir, `${base}-${cssTexts.length}.css`), css);
420
+ }
421
+ } catch {
422
+ /* a stylesheet that will not download is not the port's problem */
423
+ }
424
+ }
425
+ if (cssTexts.length) o.css = cssSummary(cssTexts.join('\n'));
426
+ writeFileSync(join(outDir, `${base}.raw.html`), html);
427
+ writeFileSync(join(outDir, `${base}.html`), cleanHtml(html));
428
+ writeFileSync(join(outDir, `${base}.outline.json`), JSON.stringify(o, null, '\t'));
429
+ const text = outlineText(o);
430
+ writeFileSync(join(outDir, `${base}.outline.txt`), text);
431
+ say(text);
432
+ say(`Saved to ${relative(process.cwd(), outDir)}/: ${base}.html (cleaned), ${base}.raw.html, ${base}.outline.json${cssTexts.length ? `, ${cssTexts.length} stylesheet${cssTexts.length === 1 ? '' : 's'}` : ''}`);
433
+ }
434
+
435
+ async function publish(ctx) {
436
+ if (!flags.yes) fail('Publishing makes the live site a copy of the preview site. Look at it first (`tidysites shot /`), then run `tidysites publish --yes`.');
437
+ const { json, text } = await tool(ctx.cfg, 'publish_website', { site_id: ctx.cfg.site });
438
+ say(json ? JSON.stringify(json, null, 2) : text);
439
+ }
440
+
441
+ function help() {
442
+ say(readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(2, 20).map((l) => l.replace(/^ \* ?/, '')).join('\n'));
443
+ say(`\nFiles:\n <collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": {...} }; a new entry is a new file with "data"\n <collection>/<slug>.css data.custom_css as a file (rows: data.css); rows/<slug>.html is data.html\n menus/<name>.json a menu as a tree: { label, items: [{ label, url, children: [...] }] }\n ${META}/ config, key, the last pull, the content model, screenshots\n\nA field taken out of a file is cleared on the website at the next push; a file you changed here is never overwritten by a pull.\nImages: an image field or a Columns image slot takes { "$media": { "url": "https://...", "alt": "..." } }; the push fetches it into the library.\nReferences: a record names another by its id, or "$ref:<slug-or-id>" for one in the same push.`);
444
+ }
445
+
446
+ async function main() {
447
+ switch (command) {
448
+ case 'init': {
449
+ const site = positional[0];
450
+ const host = flags.host;
451
+ const key = flags.key || process.env.TIDYSITES_KEY;
452
+ if (!site || !host || !key) fail('Usage: tidysites init <site_id> --host <url> --key <key> [--dir folder]');
453
+ const dir = resolve(flags.dir ?? process.cwd());
454
+ mkdirSync(join(dir, META), { recursive: true });
455
+ writeFileSync(join(dir, META, 'config.json'), `${JSON.stringify({ host: String(host).replace(/\/+$/, ''), site }, null, '\t')}\n`);
456
+ writeFileSync(join(dir, META, 'key'), `${key}\n`, { mode: 0o600 });
457
+ writeFileSync(join(dir, META, '.gitignore'), 'key\nshots/\nsource/\npages/\n');
458
+ say(`Connected ${relative(process.cwd(), dir) || '.'} to ${site} on ${host}.`);
459
+ await pull(config());
460
+ return;
461
+ }
462
+ case 'pull':
463
+ return pull(config());
464
+ case 'status': {
465
+ printStatus(status(config()));
466
+ return;
467
+ }
468
+ case 'validate': {
469
+ const errors = validate(config());
470
+ if (errors.length) {
471
+ for (const e of errors) say(`✗ ${e}`);
472
+ fail(`\n${errors.length} problem${errors.length === 1 ? '' : 's'}.`);
473
+ }
474
+ say('Every file fits the content model.');
475
+ return;
476
+ }
477
+ case 'push':
478
+ return push(config());
479
+ case 'shot':
480
+ return shot(config());
481
+ case 'gallery': {
482
+ // One type at a time is readable; the whole gallery is a very tall picture.
483
+ const type = positional[0];
484
+ return shot(config(), `/_tidy/gallery${type ? `#${type}` : ''}`, { view: 'desktop', name: type ? `gallery-${type}` : 'gallery', firstScreen: Boolean(type) });
485
+ }
486
+ case 'pages':
487
+ return pages(config());
488
+ case 'page':
489
+ return page(config());
490
+ case 'schema':
491
+ return schema(config());
492
+ case 'fetch':
493
+ return fetchCmd(config(false));
494
+ case 'publish':
495
+ return publish(config());
496
+ case 'help':
497
+ case '--help':
498
+ case '-h':
499
+ case undefined:
500
+ return help();
501
+ default:
502
+ fail(`Unknown command "${command}". \`tidysites help\` lists them.`);
503
+ }
504
+ }
505
+
506
+ main().catch((e) => fail(e?.stack ?? String(e)));
package/lib/api.mjs ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The website connector over HTTP. The command line is one more client of
3
+ * the Tidysites connector (`<host>/mcp/tidysites`): every call is a tool
4
+ * call with the credential as a bearer, so whatever the connector can do
5
+ * for an assistant, the command line can do from a shell, and nothing is
6
+ * opened for it that was not open already.
7
+ */
8
+
9
+ export class ApiError extends Error {
10
+ constructor(message, { status = null, details = null } = {}) {
11
+ super(message);
12
+ this.status = status;
13
+ this.details = details;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Call one connector tool. Resolves to { text, json, images }: the text
19
+ * content, the same parsed as JSON when it is JSON, and any images
20
+ * (base64 with their media type).
21
+ */
22
+ export async function callTool(cfg, name, args = {}, { fetchImpl = fetch } = {}) {
23
+ const url = `${cfg.host.replace(/\/+$/, '')}/mcp/tidysites`;
24
+ const res = await fetchImpl(url, {
25
+ method: 'POST',
26
+ headers: { authorization: `Bearer ${cfg.key}`, 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'tidysites-cli' },
27
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } })
28
+ });
29
+ const bodyText = await res.text();
30
+ let rpc;
31
+ try {
32
+ rpc = JSON.parse(bodyText);
33
+ } catch {
34
+ throw new ApiError(`${url} answered ${res.status} with something that is not JSON: ${bodyText.slice(0, 200)}`, { status: res.status });
35
+ }
36
+ if (res.status === 401) throw new ApiError('The credential was refused. Run `tidysites init` again with a fresh command from the connector (connect_tidysites_cli).', { status: 401 });
37
+ if (rpc.error) throw new ApiError(`${name}: ${rpc.error.message ?? JSON.stringify(rpc.error)}`, { status: res.status, details: rpc.error });
38
+ const content = Array.isArray(rpc.result?.content) ? rpc.result.content : [];
39
+ const text = content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
40
+ const images = content.filter((c) => c.type === 'image').map((c) => ({ base64: c.data, mimeType: c.mimeType || 'image/png' }));
41
+ if (rpc.result?.isError) {
42
+ // "HTTP 422 — invalid_content: ... {details}" is how the connector words a refusal; keep the details readable.
43
+ const m = text.match(/^HTTP (\d+) — ([a-z_]+): ([\s\S]*)$/);
44
+ if (m) {
45
+ const [, status, code, rest] = m;
46
+ const brace = rest.indexOf(' {');
47
+ let details = null;
48
+ let message = rest;
49
+ if (brace > 0) {
50
+ try {
51
+ details = JSON.parse(rest.slice(brace + 1));
52
+ message = rest.slice(0, brace);
53
+ } catch {
54
+ /* leave the text whole */
55
+ }
56
+ }
57
+ throw new ApiError(`${code}: ${message}`, { status: Number(status), details, code });
58
+ }
59
+ throw new ApiError(text || `${name} failed`, { status: res.status });
60
+ }
61
+ let json = null;
62
+ try {
63
+ json = JSON.parse(text);
64
+ } catch {
65
+ /* not JSON: fine, text stands */
66
+ }
67
+ return { text, json, images };
68
+ }
package/lib/fetch.mjs ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Read a page of the site being ported: the HTML with scripts, styles and
3
+ * SVG noise taken out, and an outline (title, description, headings in
4
+ * order, navigation, images with their alt, phone numbers and emails, the
5
+ * stylesheets) so a builder can see the page's bones before deciding which
6
+ * section each band becomes.
7
+ */
8
+
9
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
10
+
11
+ export async function fetchPage(url, { fetchImpl = fetch } = {}) {
12
+ const res = await fetchImpl(url, { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow' });
13
+ const html = await res.text();
14
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
15
+ return { url: res.url || url, html };
16
+ }
17
+
18
+ const decode = (s) =>
19
+ s
20
+ .replace(/&nbsp;/g, ' ')
21
+ .replace(/&amp;/g, '&')
22
+ .replace(/&lt;/g, '<')
23
+ .replace(/&gt;/g, '>')
24
+ .replace(/&quot;/g, '"')
25
+ .replace(/&#39;|&apos;/g, "'")
26
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
27
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)));
28
+
29
+ const text = (s) => decode(s.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
30
+
31
+ /** The HTML without what a builder never needs to read. */
32
+ export function cleanHtml(html) {
33
+ return html
34
+ .replace(/<!--[\s\S]*?-->/g, '')
35
+ .replace(/<(script|style|noscript|template|iframe)\b[\s\S]*?<\/\1>/gi, '')
36
+ .replace(/<svg\b[\s\S]*?<\/svg>/gi, '<svg></svg>')
37
+ .replace(/\son[a-z]+="[^"]*"/gi, '')
38
+ .replace(/\sstyle="[^"]*"/gi, '')
39
+ .replace(/\s(data-[a-z0-9-]+|class|id)="[^"]*"/gi, (m, name) => (name === 'class' || name === 'id' ? m : ''))
40
+ .replace(/[ \t]+\n/g, '\n')
41
+ .replace(/\n{3,}/g, '\n\n');
42
+ }
43
+
44
+ function abs(base, href) {
45
+ try {
46
+ return new URL(href, base).toString();
47
+ } catch {
48
+ return href;
49
+ }
50
+ }
51
+
52
+ /** What the page is made of, in order. */
53
+ export function outline(html, base) {
54
+ const out = { url: base, title: null, description: null, headings: [], nav: [], images: [], phones: [], emails: [], stylesheets: [], fonts: [], colors: [] };
55
+ const t = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
56
+ if (t) out.title = text(t[1]);
57
+ const d = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i);
58
+ if (d) out.description = decode(d[1]).trim();
59
+ for (const m of html.matchAll(/<(h[1-4])\b[^>]*>([\s\S]*?)<\/\1>/gi)) {
60
+ const s = text(m[2]);
61
+ if (s) out.headings.push({ level: Number(m[1][1]), text: s });
62
+ }
63
+ const navBlocks = [...html.matchAll(/<nav\b[\s\S]*?<\/nav>/gi)].map((m) => m[0]);
64
+ const navSource = navBlocks.length ? navBlocks.join('\n') : (html.match(/<header\b[\s\S]*?<\/header>/i)?.[0] ?? '');
65
+ const seen = new Set();
66
+ for (const m of navSource.matchAll(/<a\b[^>]*href=["']([^"'#][^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi)) {
67
+ const label = text(m[2]);
68
+ const href = abs(base, m[1]);
69
+ if (label && !seen.has(href)) {
70
+ seen.add(href);
71
+ out.nav.push({ label, href });
72
+ }
73
+ }
74
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
75
+ const tag = m[0];
76
+ const src = tag.match(/\s(?:data-src|data-lazy-src|src)=["']([^"']+)["']/i)?.[1];
77
+ if (!src || /^data:/.test(src)) continue;
78
+ const alt = decode(tag.match(/\salt=["']([^"']*)["']/i)?.[1] ?? '');
79
+ const width = tag.match(/\swidth=["']?(\d+)/i)?.[1];
80
+ const height = tag.match(/\sheight=["']?(\d+)/i)?.[1];
81
+ out.images.push({ src: abs(base, src), alt, ...(width ? { width: Number(width) } : {}), ...(height ? { height: Number(height) } : {}) });
82
+ }
83
+ const body = text(html.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ''));
84
+ out.phones = [...new Set([...body.matchAll(/(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/g)].map((m) => m[0].trim()))].slice(0, 10);
85
+ out.emails = [...new Set([...body.matchAll(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi)].map((m) => m[0]))].slice(0, 10);
86
+ for (const m of html.matchAll(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi)) {
87
+ const href = m[0].match(/\shref=["']([^"']+)["']/i)?.[1];
88
+ if (href) out.stylesheets.push(abs(base, href));
89
+ }
90
+ for (const m of html.matchAll(/fonts\.googleapis\.com\/css2?\?[^"'<>\s]+/gi)) {
91
+ const fams = [...m[0].matchAll(/family=([^&:]+)/g)].map((f) => decodeURIComponent(f[1]).replace(/\+/g, ' '));
92
+ out.fonts.push(...fams);
93
+ }
94
+ out.fonts = [...new Set(out.fonts)];
95
+ return out;
96
+ }
97
+
98
+ /** The colours and font families a stylesheet leans on, most used first. */
99
+ export function cssSummary(css) {
100
+ const colors = new Map();
101
+ for (const m of css.matchAll(/#(?:[0-9a-f]{3}){1,2}\b|rgba?\([^)]*\)/gi)) {
102
+ const c = m[0].toLowerCase();
103
+ if (/^#(?:fff|ffffff|000|000000)$/.test(c)) continue;
104
+ colors.set(c, (colors.get(c) ?? 0) + 1);
105
+ }
106
+ const fonts = new Map();
107
+ for (const m of css.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
108
+ const f = m[1].split(',')[0].replace(/["']/g, '').trim();
109
+ if (f && !/^(inherit|initial|unset|var\()/.test(f)) fonts.set(f, (fonts.get(f) ?? 0) + 1);
110
+ }
111
+ const top = (map, n) => [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
112
+ return { colors: top(colors, 12), fonts: top(fonts, 6) };
113
+ }
114
+
115
+ export function outlineText(o) {
116
+ const lines = [];
117
+ lines.push(`URL: ${o.url}`);
118
+ if (o.title) lines.push(`Title: ${o.title}`);
119
+ if (o.description) lines.push(`Description: ${o.description}`);
120
+ if (o.nav.length) {
121
+ lines.push('', 'Navigation:');
122
+ for (const n of o.nav) lines.push(` ${n.label} → ${n.href}`);
123
+ }
124
+ if (o.headings.length) {
125
+ lines.push('', 'Headings, in order:');
126
+ for (const h of o.headings) lines.push(` ${'#'.repeat(h.level)} ${h.text}`);
127
+ }
128
+ if (o.images.length) {
129
+ lines.push('', `Images (${o.images.length}):`);
130
+ for (const i of o.images.slice(0, 60)) lines.push(` ${i.src}${i.width ? ` (${i.width}x${i.height ?? '?'})` : ''}${i.alt ? ` alt: ${i.alt}` : ''}`);
131
+ }
132
+ if (o.phones.length) lines.push('', `Phones: ${o.phones.join(', ')}`);
133
+ if (o.emails.length) lines.push(`Emails: ${o.emails.join(', ')}`);
134
+ if (o.fonts.length) lines.push(`Google fonts: ${o.fonts.join(', ')}`);
135
+ if (o.css) {
136
+ lines.push('', 'Stylesheet colours (most used first):', ` ${o.css.colors.map((c) => `${c.value} ×${c.count}`).join(' ')}`);
137
+ lines.push('Stylesheet fonts:', ` ${o.css.fonts.map((c) => `${c.value} ×${c.count}`).join(' ')}`);
138
+ }
139
+ if (o.stylesheets.length) {
140
+ lines.push('', 'Stylesheets:');
141
+ for (const s of o.stylesheets) lines.push(` ${s}`);
142
+ }
143
+ return `${lines.join('\n')}\n`;
144
+ }
package/lib/folder.mjs ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The folder: a Tidysite's content as files.
3
+ *
4
+ * <collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": { ... } }
5
+ * <collection>/<slug>.css data.custom_css, as a file (rows: data.css)
6
+ * rows/<slug>.html data.html, as a file
7
+ * .tidysites/ config, key, the last pull (snapshot), the content model, screenshots
8
+ *
9
+ * A new entry is a new <collection>/<slug>.json with at least { "data": { ... } };
10
+ * the slug is the file name. Everything here is pure: reading and writing the
11
+ * disk is the command's job, so the rules are testable on their own.
12
+ */
13
+
14
+ export const SIDECARS = {
15
+ default: { css: 'custom_css' },
16
+ rows: { css: 'css', html: 'html' }
17
+ };
18
+
19
+ export const FILE_NAME = /^[a-z0-9][a-z0-9_-]*$/i;
20
+
21
+ function sidecarsFor(collection) {
22
+ return SIDECARS[collection] ?? SIDECARS.default;
23
+ }
24
+
25
+ /** A pulled entry as the files it becomes: the JSON and its sidecars. */
26
+ export function entryToFiles(collection, entry) {
27
+ const name = entry.slug ?? entry.id;
28
+ const data = { ...entry.data };
29
+ const files = {};
30
+ for (const [ext, field] of Object.entries(sidecarsFor(collection))) {
31
+ const v = data[field];
32
+ if (typeof v === 'string' && v.trim()) {
33
+ // Always one newline added on write and one taken on read, so the text round-trips exactly.
34
+ files[`${collection}/${name}.${ext}`] = `${v}\n`;
35
+ delete data[field];
36
+ } else if (v === '' || v === null) {
37
+ delete data[field];
38
+ }
39
+ }
40
+ const json = { id: entry.id, slug: entry.slug ?? null, status: entry.status, updated_at: entry.updated_at ?? null, ...(entry.has_draft ? { has_draft: true } : {}), data };
41
+ files[`${collection}/${name}.json`] = `${JSON.stringify(json, null, '\t')}\n`;
42
+ return files;
43
+ }
44
+
45
+ /** The whole document as files: every entry. The site title and tagline a theme prints are the Business record's, so there is no settings file. */
46
+ export function docToFiles(doc) {
47
+ const files = {};
48
+ for (const [collection, entries] of Object.entries(doc.content ?? {})) {
49
+ for (const e of entries) Object.assign(files, entryToFiles(collection, e));
50
+ }
51
+ return files;
52
+ }
53
+
54
+ /**
55
+ * One entry read back from its JSON text and the sidecar texts beside it
56
+ * ({ css?, html? }). Throws with a message that names the file.
57
+ */
58
+ export function filesToEntry(collection, name, jsonText, sidecars = {}) {
59
+ const file = `${collection}/${name}.json`;
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(jsonText);
63
+ } catch (e) {
64
+ throw new Error(`${file}: not valid JSON (${e.message})`);
65
+ }
66
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${file}: must be an object with a data field`);
67
+ if (!parsed.data || typeof parsed.data !== 'object' || Array.isArray(parsed.data)) throw new Error(`${file}: needs "data": { ... }`);
68
+ if (parsed.slug != null && parsed.slug !== name) throw new Error(`${file}: slug "${parsed.slug}" does not match the file name; rename the file or the slug`);
69
+ if (!FILE_NAME.test(name)) throw new Error(`${file}: the file name is the slug; use letters, digits, - or _`);
70
+ const data = { ...parsed.data };
71
+ for (const [ext, field] of Object.entries(sidecarsFor(collection))) {
72
+ const text = sidecars[ext];
73
+ if (typeof text === 'string') {
74
+ if (typeof data[field] === 'string' && data[field] !== '') throw new Error(`${file}: "${field}" is set both in the JSON and in ${collection}/${name}.${ext}; keep one`);
75
+ data[field] = text.endsWith('\n') ? text.slice(0, -1) : text;
76
+ }
77
+ }
78
+ const status = parsed.status === 'draft' ? 'draft' : parsed.status === 'published' || parsed.status === undefined ? 'published' : null;
79
+ if (status === null) throw new Error(`${file}: status is published or draft`);
80
+ return {
81
+ id: typeof parsed.id === 'string' && parsed.id ? parsed.id : name,
82
+ slug: name,
83
+ status,
84
+ updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null,
85
+ data
86
+ };
87
+ }
88
+
89
+ /** The snapshot kept from a pull: what the site had, by "collection/slug". */
90
+ export function snapshotOf(doc) {
91
+ const entries = {};
92
+ for (const [collection, list] of Object.entries(doc.content ?? {})) {
93
+ for (const e of list) entries[`${collection}/${e.slug ?? e.id}`] = { id: e.id, status: e.status, updated_at: e.updated_at ?? null, data: e.data };
94
+ }
95
+ return { pulled_at: doc.site?.exported_at ?? new Date().toISOString(), site: doc.site ?? null, settings: doc.settings ?? {}, collections: (doc.collections ?? []).map((c) => c.slug), entries };
96
+ }
97
+
98
+ function stable(value) {
99
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
100
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
101
+ return JSON.stringify(value);
102
+ }
103
+
104
+ /** An empty field and a missing one say the same thing to a push (the site keeps what it has). */
105
+ function bare(data) {
106
+ const out = {};
107
+ for (const [k, v] of Object.entries(data ?? {})) if (v !== null && v !== '' && v !== undefined) out[k] = v;
108
+ return out;
109
+ }
110
+
111
+ /** Whether two entries would write the same thing. */
112
+ export function sameEntry(a, b) {
113
+ return a.status === b.status && stable(bare(a.data)) === stable(bare(b.data));
114
+ }
115
+
116
+ /**
117
+ * Local entries (by "collection/slug") against the snapshot: what a push
118
+ * would create, update, and (with prune) remove. Only collections the
119
+ * snapshot covered can report removals; a collection never pulled is new.
120
+ */
121
+ export function diffEntries(local, snapshot) {
122
+ const out = { created: [], updated: [], removed: [], unchanged: [] };
123
+ for (const [key, entry] of Object.entries(local)) {
124
+ const was = snapshot.entries[key];
125
+ if (!was) out.created.push(key);
126
+ else if (!sameEntry(entry, was)) out.updated.push(key);
127
+ else out.unchanged.push(key);
128
+ }
129
+ for (const key of Object.keys(snapshot.entries)) if (!local[key]) out.removed.push(key);
130
+ out.created.sort();
131
+ out.updated.sort();
132
+ out.removed.sort();
133
+ return out;
134
+ }
135
+
136
+ /** The document a push sends for the given keys, with expect from the snapshot. */
137
+ export function pushDocument(local, snapshot, { keys, removeKeys = [], settings = null }) {
138
+ const content = {};
139
+ const expect = {};
140
+ for (const key of keys) {
141
+ const [collection, slug] = splitKey(key);
142
+ const e = local[key];
143
+ const was = snapshot.entries[key];
144
+ // A field taken out of the file is cleared on the site: the site keeps what a push does not name,
145
+ // so the names the snapshot had and the file no longer has go as null.
146
+ const data = { ...e.data };
147
+ if (was?.data) for (const k of Object.keys(was.data)) if (!(k in data) && was.data[k] !== null && was.data[k] !== '') data[k] = null;
148
+ (content[collection] ??= []).push({ id: e.id, slug, status: e.status, data });
149
+ if (was?.updated_at) expect[key] = was.updated_at;
150
+ }
151
+ const remove = {};
152
+ for (const key of removeKeys) {
153
+ const [collection, slug] = splitKey(key);
154
+ (remove[collection] ??= []).push(slug);
155
+ const was = snapshot.entries[key];
156
+ if (was?.updated_at) expect[key] = was.updated_at;
157
+ }
158
+ const doc = {};
159
+ if (settings && Object.keys(settings).length) doc.settings = settings;
160
+ if (Object.keys(content).length) doc.content = content;
161
+ if (Object.keys(remove).length) doc.remove = remove;
162
+ if (Object.keys(expect).length) doc.expect = expect;
163
+ return doc;
164
+ }
165
+
166
+ export function splitKey(key) {
167
+ const i = key.indexOf('/');
168
+ return [key.slice(0, i), key.slice(i + 1)];
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Menus: menus/<name>.json, a tree of items; the site keeps a flat list with parent indexes.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** The site's flat item list (parentId, sortOrder) as the tree the file holds. */
176
+ export function menuTree(items) {
177
+ const byParent = new Map();
178
+ for (const it of [...items].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))) {
179
+ const k = it.parentId ?? null;
180
+ (byParent.get(k) ?? byParent.set(k, []).get(k)).push(it);
181
+ }
182
+ const node = (it) => {
183
+ const out = { label: it.label };
184
+ if (it.type === 'custom' || !it.type) out.url = it.customUrl ?? '';
185
+ else {
186
+ out.type = it.type;
187
+ if (it.referenceCollection) out.collection = it.referenceCollection;
188
+ if (it.referenceId) out.id = it.referenceId;
189
+ }
190
+ if (it.titleAttr) out.title = it.titleAttr;
191
+ if (it.target) out.target = it.target;
192
+ if (it.cssClasses) out.classes = it.cssClasses;
193
+ const kids = byParent.get(it.id) ?? [];
194
+ if (kids.length) out.children = kids.map(node);
195
+ return out;
196
+ };
197
+ return (byParent.get(null) ?? []).map(node);
198
+ }
199
+
200
+ /** The tree flattened to what menu_set_items takes: each child names its parent's index. */
201
+ export function menuItems(tree) {
202
+ const out = [];
203
+ const walk = (nodes, parentIndex) => {
204
+ for (const n of nodes) {
205
+ if (!n || typeof n.label !== 'string' || !n.label) throw new Error('every menu item needs a label');
206
+ const item = { label: n.label, type: n.url !== undefined || !n.type ? 'custom' : n.type };
207
+ if (item.type === 'custom') item.customUrl = n.url ?? '#';
208
+ if (n.collection) item.referenceCollection = n.collection;
209
+ if (n.id) item.referenceId = n.id;
210
+ if (n.title) item.titleAttr = n.title;
211
+ if (n.target) item.target = n.target;
212
+ if (n.classes) item.cssClasses = n.classes;
213
+ if (parentIndex !== null) item.parentIndex = parentIndex;
214
+ out.push(item);
215
+ if (Array.isArray(n.children) && n.children.length) walk(n.children, out.length - 1);
216
+ }
217
+ };
218
+ walk(tree, null);
219
+ return out;
220
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Check entries against the website's content model before anything is
3
+ * sent: unknown fields, unknown section types and section fields, select
4
+ * values outside their options, repeater items with fields the row does not
5
+ * have, values of the wrong shape. The website runs its own validator too;
6
+ * this one says it first, with the file path, so a build is fixed locally.
7
+ *
8
+ * The model is what get_website_schema returns:
9
+ * collections.items[] { slug, label, fields: [{ slug, type, required, validation }] }
10
+ * section_types.items[] { slug, label, description, fields: [{ slug, type, validation, default }] }
11
+ * with validation.options on a select, validation.allowedTypes on a blocks
12
+ * field, validation.subFields on a repeater.
13
+ */
14
+
15
+ const BLOCK_META = new Set(['_type', '_key', '_version']);
16
+
17
+ export function indexSchema(schema) {
18
+ const collections = new Map();
19
+ for (const c of schema?.collections?.items ?? []) if (c?.slug) collections.set(c.slug, c);
20
+ const sectionTypes = new Map();
21
+ for (const t of schema?.section_types?.items ?? []) if (t?.slug) sectionTypes.set(t.slug, t);
22
+ return { collections, sectionTypes };
23
+ }
24
+
25
+ const isMedia = (v) => v && typeof v === 'object' && !Array.isArray(v) && (typeof v.id === 'string' || (v.$media && typeof v.$media === 'object'));
26
+ const empty = (v) => v === null || v === undefined || v === '';
27
+
28
+ function checkScalar(type, value, at, errors) {
29
+ if (empty(value)) return;
30
+ switch (type) {
31
+ case 'string':
32
+ case 'text':
33
+ case 'url':
34
+ case 'email':
35
+ case 'portableText':
36
+ case 'richText':
37
+ case 'markdown':
38
+ if (typeof value !== 'string') {
39
+ // A rich-text field may travel as blocks already; a plain field may not.
40
+ if (type === 'portableText' && Array.isArray(value)) return;
41
+ errors.push(`${at}: expected text, got ${describe(value)}`);
42
+ }
43
+ return;
44
+ case 'boolean':
45
+ // The site stores a switch as 1 or 0, so a pull shows numbers; both spellings are fine.
46
+ if (typeof value !== 'boolean' && value !== 0 && value !== 1) errors.push(`${at}: expected true or false, got ${describe(value)}`);
47
+ return;
48
+ case 'integer':
49
+ case 'number':
50
+ if (typeof value !== 'number' && !(typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value))) errors.push(`${at}: expected a number, got ${describe(value)}`);
51
+ return;
52
+ case 'image':
53
+ case 'media':
54
+ case 'file':
55
+ if (!isMedia(value)) errors.push(`${at}: an image is the stored value (from the pull or add_website_image) or { "$media": { "url": "https://...", "alt": "..." } }`);
56
+ else if (value.$media && (typeof value.$media.url !== 'string' || !/^https?:\/\//.test(value.$media.url))) errors.push(`${at}: $media.url must be an http(s) address`);
57
+ else if (value.$media && typeof value.$media.alt !== 'string') errors.push(`${at}: $media.alt is required (what the image shows; "" for a decorative one)`);
58
+ return;
59
+ case 'json':
60
+ return;
61
+ default:
62
+ return;
63
+ }
64
+ }
65
+
66
+ function checkSelect(field, value, at, errors) {
67
+ if (empty(value)) return;
68
+ const options = field.validation?.options ?? field.options;
69
+ if (!Array.isArray(options) || options.length === 0) return;
70
+ if (typeof value !== 'string' || !options.includes(value)) errors.push(`${at}: "${String(value)}" is not one of ${options.map((o) => `"${o}"`).join(', ')}`);
71
+ }
72
+
73
+ function checkRepeater(field, value, at, errors) {
74
+ if (empty(value)) return;
75
+ if (!Array.isArray(value)) {
76
+ errors.push(`${at}: expected a list of items, got ${describe(value)}`);
77
+ return;
78
+ }
79
+ const sub = field.validation?.subFields ?? field.subFields ?? [];
80
+ const known = new Map(sub.map((f) => [f.slug, f]));
81
+ value.forEach((item, i) => {
82
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
83
+ errors.push(`${at}[${i}]: expected an object`);
84
+ return;
85
+ }
86
+ for (const [k, v] of Object.entries(item)) {
87
+ if (k === '_key') continue;
88
+ const f = known.get(k);
89
+ if (!f) {
90
+ errors.push(`${at}[${i}].${k}: not a field of this row (fields: ${[...known.keys()].join(', ')})`);
91
+ continue;
92
+ }
93
+ checkField(f, v, `${at}[${i}].${k}`, errors, null);
94
+ }
95
+ });
96
+ }
97
+
98
+ function checkField(field, value, at, errors, ctx) {
99
+ switch (field.type) {
100
+ case 'select':
101
+ checkSelect(field, value, at, errors);
102
+ return;
103
+ case 'repeater':
104
+ checkRepeater(field, value, at, errors);
105
+ return;
106
+ case 'blocks':
107
+ checkBlocks(field, value, at, errors, ctx);
108
+ return;
109
+ case 'reference':
110
+ case 'relation':
111
+ if (!empty(value) && typeof value !== 'string' && !Array.isArray(value)) errors.push(`${at}: a reference is the entry id (or $ref:<id>) as a string`);
112
+ return;
113
+ default:
114
+ checkScalar(field.type, value, at, errors);
115
+ }
116
+ }
117
+
118
+ function checkBlocks(field, value, at, errors, ctx) {
119
+ if (empty(value)) return;
120
+ if (!Array.isArray(value)) {
121
+ errors.push(`${at}: sections are a list`);
122
+ return;
123
+ }
124
+ const allowed = field.validation?.allowedTypes;
125
+ value.forEach((block, i) => {
126
+ const where = `${at}[${i}]`;
127
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
128
+ errors.push(`${where}: a section is an object with _type`);
129
+ return;
130
+ }
131
+ const type = block._type;
132
+ if (typeof type !== 'string' || !type) {
133
+ errors.push(`${where}: missing _type`);
134
+ return;
135
+ }
136
+ const def = ctx?.sectionTypes.get(type);
137
+ if (!def || (Array.isArray(allowed) && !allowed.includes(type))) {
138
+ errors.push(`${where}: "${type}" is not a section type this field takes (run \`tidysites schema\` for the list)`);
139
+ return;
140
+ }
141
+ const fields = new Map((def.fields ?? []).map((f) => [f.slug, f]));
142
+ for (const [k, v] of Object.entries(block)) {
143
+ if (BLOCK_META.has(k)) continue;
144
+ const f = fields.get(k);
145
+ if (!f) {
146
+ errors.push(`${where} (${type}).${k}: not a field of ${type} (fields: ${[...fields.keys()].join(', ')})`);
147
+ continue;
148
+ }
149
+ checkField(f, v, `${where} (${type}).${k}`, errors, ctx);
150
+ }
151
+ });
152
+ }
153
+
154
+ function describe(v) {
155
+ if (v === null) return 'null';
156
+ if (Array.isArray(v)) return 'a list';
157
+ return typeof v === 'object' ? 'an object' : typeof v;
158
+ }
159
+
160
+ /**
161
+ * Errors for one entry, each prefixed with the file. An empty list means the
162
+ * website's own validator should accept it too (its rules are the same model).
163
+ */
164
+ export function validateEntry(collection, entry, model, file = `${collection}/${entry.slug}.json`) {
165
+ const errors = [];
166
+ const col = model.collections.get(collection);
167
+ if (!col) {
168
+ errors.push(`${file}: no collection named "${collection}" on this website (collections: ${[...model.collections.keys()].join(', ')})`);
169
+ return errors;
170
+ }
171
+ const fields = new Map((col.fields ?? []).map((f) => [f.slug, f]));
172
+ for (const f of col.fields ?? []) {
173
+ if (f.required && empty(entry.data[f.slug]) && !(f.slug in entry.data && f.type === 'blocks')) errors.push(`${file}: data.${f.slug} is required`);
174
+ }
175
+ for (const [k, v] of Object.entries(entry.data)) {
176
+ const f = fields.get(k);
177
+ if (!f) {
178
+ errors.push(`${file}: data.${k} is not a field of ${collection} (fields: ${[...fields.keys()].join(', ')})`);
179
+ continue;
180
+ }
181
+ checkField(f, v, `${file}: data.${k}`, errors, model);
182
+ }
183
+ return errors;
184
+ }
185
+
186
+ /** Every entry (by "collection/slug") checked; the errors in file order. */
187
+ export function validateAll(local, schema) {
188
+ const model = indexSchema(schema);
189
+ const errors = [];
190
+ for (const key of Object.keys(local).sort()) {
191
+ const i = key.indexOf('/');
192
+ errors.push(...validateEntry(key.slice(0, i), local[key], model));
193
+ }
194
+ return errors;
195
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Pull a Tidysites website to files, edit, validate against its content model, push it back, screenshot the result.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tidysites": "./bin/tidysites.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs",
19
+ "check": "node --check bin/tidysites.mjs && npm test"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/4sons/tidysites-platform.git",
24
+ "directory": "cli"
25
+ },
26
+ "license": "UNLICENSED"
27
+ }