@tideworthy/tidysites 0.1.18 → 0.1.20
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/bin/tidysites.mjs +25 -14
- package/lib/folder.mjs +32 -0
- package/lib/validate.mjs +5 -0
- package/package.json +1 -1
package/bin/tidysites.mjs
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
28
28
|
import { callTool, ApiError } from '../lib/api.mjs';
|
|
29
|
-
import { diffEntries, entryToFiles, filesToEntry, FILE_NAME, menuItems, menuTree, pushDocument, sameEntry, snapshotOf, SIDECARS } from '../lib/folder.mjs';
|
|
30
|
-
import { validateAll, indexSchema } from '../lib/validate.mjs';
|
|
29
|
+
import { diffEntries, entryToFiles, filesToEntry, FILE_NAME, menuItems, menuTree, pushDocument, sameEntry, snapshotOf, SIDECARS, splitDocument, documentWeight, mergePushResults } from '../lib/folder.mjs';
|
|
30
|
+
import { validateAll, indexSchema, validateMenuNames } from '../lib/validate.mjs';
|
|
31
31
|
import { cleanHtml, cssSummary, fetchPage, outline, outlineText } from '../lib/fetch.mjs';
|
|
32
32
|
|
|
33
33
|
const META = '.tidysites';
|
|
@@ -133,11 +133,12 @@ function describeError(e) {
|
|
|
133
133
|
return msg;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function tool(cfg, name, args) {
|
|
136
|
+
async function tool(cfg, name, args, { throws = false } = {}) {
|
|
137
137
|
try {
|
|
138
138
|
return await callTool(cfg, name, args);
|
|
139
139
|
} catch (e) {
|
|
140
|
-
|
|
140
|
+
// A caller that can recover (a menu to create first) takes the error; every other call ends the command with it.
|
|
141
|
+
if (e instanceof ApiError && !throws) fail(describeError(e));
|
|
141
142
|
throw e;
|
|
142
143
|
}
|
|
143
144
|
}
|
|
@@ -296,7 +297,7 @@ function validate({ dir }, local = null) {
|
|
|
296
297
|
const schema = readJson(join(dir, META, 'schema.json'));
|
|
297
298
|
if (!schema) fail('No content model here yet. Run `tidysites pull`.');
|
|
298
299
|
const read = local ? { local, problems: [] } : readLocal(dir);
|
|
299
|
-
const errors = [...read.problems, ...validateAll(read.local, schema)];
|
|
300
|
+
const errors = [...read.problems, ...validateAll(read.local, schema), ...validateMenuNames(Object.keys(readLocalMenus(dir)), Object.keys(readJson(join(dir, META, 'snapshot.json'))?.menus ?? {}))];
|
|
300
301
|
// The website keeps a section's type with its key: a key that changes type is refused with a bare "internal error", so say it here.
|
|
301
302
|
const snap = readJson(join(dir, META, 'snapshot.json'));
|
|
302
303
|
for (const [key, entry] of Object.entries(read.local)) {
|
|
@@ -340,7 +341,14 @@ async function push(ctx) {
|
|
|
340
341
|
if (keys.length || removeKeys.length) {
|
|
341
342
|
let r;
|
|
342
343
|
try {
|
|
343
|
-
|
|
344
|
+
// A push heavy with folder pictures goes in parts: the website takes each part well inside its time, where one request of tens of megabytes timed out part way.
|
|
345
|
+
const parts = splitDocument(doc);
|
|
346
|
+
if (parts.length > 1) say(`${parts.length} parts: the folder's pictures and videos weigh ${(documentWeight(doc) / 1048576).toFixed(0)} MB.`);
|
|
347
|
+
for (const [i, part] of parts.entries()) {
|
|
348
|
+
const { json } = await callTool(ctx.cfg, 'push_website', { site_id: ctx.cfg.site, ...part });
|
|
349
|
+
if (!json || typeof json !== 'object' || !json.content) throw new ApiError(`The website did not answer the push${parts.length > 1 ? ` (part ${i + 1} of ${parts.length})` : ''} in time or in a readable way`, { status: 504 });
|
|
350
|
+
r = r ? mergePushResults(r, json) : json;
|
|
351
|
+
}
|
|
344
352
|
} catch (e) {
|
|
345
353
|
if (!(e instanceof ApiError)) throw e;
|
|
346
354
|
say(describeError(e));
|
|
@@ -367,24 +375,27 @@ async function push(ctx) {
|
|
|
367
375
|
}
|
|
368
376
|
try {
|
|
369
377
|
try {
|
|
370
|
-
await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_set_items', arguments: { name, items } });
|
|
378
|
+
await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_set_items', arguments: { name, items } }, { throws: true });
|
|
371
379
|
} catch (e) {
|
|
372
380
|
// A menu the website does not have yet (a phone drawer, a footer column) is created from the file, then filled.
|
|
373
381
|
if (!/not.?found/i.test(e.message ?? '')) throw e;
|
|
374
|
-
|
|
375
|
-
await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: '
|
|
382
|
+
if (!/^[a-z][a-z0-9_]*$/.test(name)) throw new Error(`a new menu's name is lowercase letters, digits and underscores (${name} has something else)`);
|
|
383
|
+
await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_create', arguments: { name, label: m.label || name } }, { throws: true });
|
|
384
|
+
await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_set_items', arguments: { name, items } }, { throws: true });
|
|
376
385
|
say(`Menu ${name}: created.`);
|
|
377
386
|
}
|
|
378
387
|
say(`Menu ${name}: ${items.length} item${items.length === 1 ? '' : 's'} written.`);
|
|
379
388
|
} catch (e) {
|
|
380
|
-
menuProblems.push(`menus/${name}.json: ${e.message}`);
|
|
389
|
+
menuProblems.push(`menus/${name}.json: ${e instanceof ApiError ? describeError(e) : e.message}`);
|
|
381
390
|
}
|
|
382
391
|
}
|
|
392
|
+
// Refresh what went (the entries, and the menus that were written) before any menu failure is reported, so the snapshot
|
|
393
|
+
// knows the website's new versions and the next push is not refused as "changed on the website".
|
|
394
|
+
const written = menus.filter((n) => !menuProblems.some((p) => p.startsWith(`menus/${n}.json`)));
|
|
395
|
+
const pushed = new Set([...keys, ...written.map((m) => `menus/${m}`)]);
|
|
396
|
+
if (touched.length) await pull(ctx, touched, { overwrite: pushed, menus: written.length > 0 });
|
|
397
|
+
else if (written.length) await pullMenus(ctx);
|
|
383
398
|
if (menuProblems.length) fail(menuProblems.join('\n'));
|
|
384
|
-
// Refresh only what went, and take the website's version of exactly those entries; every other file stays as it is.
|
|
385
|
-
const pushed = new Set([...keys, ...menus.map((m) => `menus/${m}`)]);
|
|
386
|
-
if (touched.length) await pull(ctx, touched, { overwrite: pushed, menus: menus.length > 0 });
|
|
387
|
-
else if (menus.length) await pullMenus(ctx);
|
|
388
399
|
}
|
|
389
400
|
|
|
390
401
|
const MEDIA_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', ico: 'image/x-icon', mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', pdf: 'application/pdf' };
|
package/lib/folder.mjs
CHANGED
|
@@ -232,3 +232,35 @@ export function menuItems(tree) {
|
|
|
232
232
|
walk(tree, null);
|
|
233
233
|
return out;
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
/** How many bytes of inlined pictures and video one push carries at most; a heavier document goes in parts. */
|
|
237
|
+
export const PUSH_PART_BYTES = 12 * 1024 * 1024;
|
|
238
|
+
/** The inlined media weight of a document, in bytes of base64 text. */
|
|
239
|
+
export function documentWeight(doc) {
|
|
240
|
+
let n = 0;
|
|
241
|
+
const walk = (v) => { if (Array.isArray(v)) v.forEach(walk); else if (v && typeof v === 'object') { if (v.$media && typeof v.$media.data === 'string') n += v.$media.data.length; else Object.values(v).forEach(walk); } };
|
|
242
|
+
walk(doc.content ?? {});
|
|
243
|
+
return n;
|
|
244
|
+
}
|
|
245
|
+
/** The document as a list of pushes, each under the weight cap (an entry heavier than the cap travels alone); settings, removals and the expectations ride with the last part. */
|
|
246
|
+
export function splitDocument(doc) {
|
|
247
|
+
if (!doc.content || documentWeight(doc) <= PUSH_PART_BYTES) return [doc];
|
|
248
|
+
const parts = [];
|
|
249
|
+
let current = { content: {} }; let weight = 0;
|
|
250
|
+
for (const [collection, entries] of Object.entries(doc.content)) {
|
|
251
|
+
for (const e of entries) {
|
|
252
|
+
const w = documentWeight({ content: { x: [e] } });
|
|
253
|
+
if (weight > 0 && weight + w > PUSH_PART_BYTES) { parts.push(current); current = { content: {} }; weight = 0; }
|
|
254
|
+
(current.content[collection] ??= []).push(e); weight += w;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
parts.push(current);
|
|
258
|
+
const last = parts[parts.length - 1];
|
|
259
|
+
for (const k of ['settings', 'remove', 'expect']) if (doc[k] !== undefined) last[k] = doc[k];
|
|
260
|
+
// Every part expects the same website state it was read against.
|
|
261
|
+
if (doc.expect) for (const p of parts) p.expect = Object.fromEntries(Object.entries(doc.expect).filter(([k]) => Object.entries(p.content).some(([c, es]) => es.some((e) => `${c}/${e.slug}` === k))));
|
|
262
|
+
return parts;
|
|
263
|
+
}
|
|
264
|
+
export function mergePushResults(a, b) {
|
|
265
|
+
return { ...a, ...b, content: { created: (a.content?.created ?? 0) + (b.content?.created ?? 0), updated: (a.content?.updated ?? 0) + (b.content?.updated ?? 0) }, images: (a.images ?? 0) + (b.images ?? 0), images_reused: (a.images_reused ?? 0) + (b.images_reused ?? 0), missing: [...(a.missing ?? []), ...(b.missing ?? [])] };
|
|
266
|
+
}
|
package/lib/validate.mjs
CHANGED
|
@@ -188,6 +188,11 @@ export function validateEntry(collection, entry, model, file = `${collection}/${
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/** Every entry (by "collection/slug") checked; the errors in file order. */
|
|
191
|
+
/** A menu the website lacks is created at push time under its file name: lowercase letters, digits and underscores. */
|
|
192
|
+
export function validateMenuNames(menuNames, knownNames = []) {
|
|
193
|
+
return menuNames.filter((n) => !knownNames.includes(n) && !/^[a-z][a-z0-9_]*$/.test(n)).map((n) => `menus/${n}.json: a new menu's name is lowercase letters, digits and underscores (rename the file)`);
|
|
194
|
+
}
|
|
195
|
+
|
|
191
196
|
export function validateAll(local, schema) {
|
|
192
197
|
const model = indexSchema(schema);
|
|
193
198
|
const errors = [];
|
package/package.json
CHANGED