@tideworthy/tidysites 0.1.19 → 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 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';
@@ -297,7 +297,7 @@ function validate({ dir }, local = null) {
297
297
  const schema = readJson(join(dir, META, 'schema.json'));
298
298
  if (!schema) fail('No content model here yet. Run `tidysites pull`.');
299
299
  const read = local ? { local, problems: [] } : readLocal(dir);
300
- 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 ?? {}))];
301
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.
302
302
  const snap = readJson(join(dir, META, 'snapshot.json'));
303
303
  for (const [key, entry] of Object.entries(read.local)) {
@@ -341,7 +341,14 @@ async function push(ctx) {
341
341
  if (keys.length || removeKeys.length) {
342
342
  let r;
343
343
  try {
344
- ({ json: r } = await callTool(ctx.cfg, 'push_website', { site_id: ctx.cfg.site, ...doc }));
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
+ }
345
352
  } catch (e) {
346
353
  if (!(e instanceof ApiError)) throw e;
347
354
  say(describeError(e));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Pull a Tidysites website to files, edit, validate against its content model, push it back, screenshot the result.",
5
5
  "type": "module",
6
6
  "bin": {