@tideworthy/tidysites 0.1.19 → 0.1.21
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 +1 -1
- package/bin/tidysites.mjs +14 -7
- package/lib/folder.mjs +32 -0
- package/lib/validate.mjs +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ shows in `status`.
|
|
|
63
63
|
Styling: a row's `.css` file is scoped to that row (every rule is prefixed with the row's own selector), so it cannot style the rest of the page; rules for the whole page, the sections and the theme's classes go in `business/business.css`.
|
|
64
64
|
|
|
65
65
|
Images: an image field, a hero's image or a Columns image slot takes
|
|
66
|
-
`{ "$media": { "url": "https://...", "alt": "what it shows" } }` (or `{ "$media": { "file": "assets/x.png", "alt": "..." } }` for a picture that exists only as a file in the folder, say an illustration drawn from the old page's inline SVG; the push carries its bytes; a `file` field, such as a row's `media` list or the Business font files, takes a video (mp4, webm, up to
|
|
66
|
+
`{ "$media": { "url": "https://...", "alt": "what it shows" } }` (or `{ "$media": { "file": "assets/x.png", "alt": "..." } }` for a picture that exists only as a file in the folder, say an illustration drawn from the old page's inline SVG; the push carries its bytes; a `file` field, such as a row's `media` list or the Business font files, takes a video (mp4, webm, up to 25 MB, the most one file of a published site may weigh), a font (woff2) or a pdf the same way) and the
|
|
67
67
|
push fetches the file into the website's library; a file the library already
|
|
68
68
|
holds (the same bytes) is reused, not added again. An image already in the
|
|
69
69
|
library keeps the value the pull gave it.
|
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
|
-
|
|
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));
|
|
@@ -392,8 +399,8 @@ async function push(ctx) {
|
|
|
392
399
|
}
|
|
393
400
|
|
|
394
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' };
|
|
395
|
-
/** Pictures travel up to 15 MB; a video
|
|
396
|
-
const mediaCap = (type) => (type.startsWith('video/') ?
|
|
402
|
+
/** Pictures travel up to 15 MB; a video up to 25 MB, the most one file of a published site may weigh. */
|
|
403
|
+
const mediaCap = (type) => (type.startsWith('video/') ? 25 : 15);
|
|
397
404
|
/** Every { "$media": { "file": ... } } in the document becomes { "$media": { "data", "type", "filename", "alt" } }, read from the folder. */
|
|
398
405
|
function inlineMediaFiles(doc, dir) {
|
|
399
406
|
const walk = (v) => {
|
|
@@ -407,7 +414,7 @@ function inlineMediaFiles(doc, dir) {
|
|
|
407
414
|
const bytes = readFileSync(path);
|
|
408
415
|
const ext = m.file.split('.').pop().toLowerCase();
|
|
409
416
|
const type = MEDIA_TYPES[ext] ?? 'application/octet-stream';
|
|
410
|
-
if (bytes.length > mediaCap(type) * 1024 * 1024) fail(`$media.file ${m.file}: ${(bytes.length / 1048576).toFixed(1)} MB; ${type.startsWith('video/') ? 'videos up to
|
|
417
|
+
if (bytes.length > mediaCap(type) * 1024 * 1024) fail(`$media.file ${m.file}: ${(bytes.length / 1048576).toFixed(1)} MB; ${type.startsWith('video/') ? 'videos up to 25 MB (the most one file of a published site may weigh; re-encode it smaller)' : 'pictures up to 15 MB'}`);
|
|
411
418
|
return { $media: { data: bytes.toString('base64'), type, filename: m.file.split('/').pop(), alt: typeof m.alt === 'string' ? m.alt : '' } };
|
|
412
419
|
}
|
|
413
420
|
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
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