@tideworthy/tidysites 0.1.10 → 0.1.12

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
@@ -22,8 +22,9 @@ nothing else; it lives in `.tidysites/key`, which is kept out of git.
22
22
  | `tidysites status` | what changed here since the pull |
23
23
  | `tidysites validate` | every file against the content model, with the file and the field in each message |
24
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] [--tablet] [--desktop] [--wide] [--all] [--first-screen] [--live]` | a screenshot of a page of the preview site, the live site, or any address, saved under `.tidysites/shots/`; none of the width flags means desktop and mobile; a port compares all four. A page taller than 2400px comes in 1600px bands (`<name>-band-01.jpg`, …), top to bottom, that an image reader can take in one at a time |
26
- | `tidysites measure <path or url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--full]` | the page as the browser lays it out: every heading, paragraph, button, picture and section box with its computed type (size, line height, weight, family, colour) and box (position, size, padding, margin, background), saved as JSON under `.tidysites/measures/` and printed. Measure the old page and the new one at the same width and style to the numbers |
25
+ | `tidysites shot <path or url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--first-screen] [--live]` | a screenshot of a page of the preview site, the live site, or any address, saved under `.tidysites/shots/`; none of the width flags means desktop and mobile; a port compares all four. A page taller than 2400px comes in 1600px bands (`<name>-band-01.jpg`, …), top to bottom, that an image reader can take in one at a time. `--menu` opens every navigation menu (burgers, `<details>`, hover dropdowns) before the capture; `--open a,b` clicks those selectors and `--hover a,b` shows the submenus under them, for a popup, a tab or one dropdown |
26
+ | `tidysites measure <path or url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--full]` | the page as the browser lays it out: every heading, paragraph, button, picture and section box with its computed type (size, line height, weight, family, colour) and box (position, size, padding, margin, background), saved as JSON under `.tidysites/measures/` and printed. Measure the old page and the new one at the same width and style to the numbers. The same `--menu`, `--open` and `--hover` as `shot`, so a dropdown's open type is measured |
27
+ | `tidysites speed <path or url> [--mobile\|--desktop] [--live] [--against <url>]` | PageSpeed Insights (Google's Lighthouse) for a page of the preview or live site, or any address: scores, lab and field metrics, the largest-paint element and the opportunities with the files they name, saved under `.tidysites/speed/`. `--against <url>` tests the old page in the same call for a side-by-side. Run it on the live site before calling a build done; what the content cannot fix goes in the report |
27
28
  | `tidysites redirect [add <old path> <new path> \| rm <old path>]` | the website's redirects: an old address that must keep working after a port goes here; they ship with the next publish |
28
29
  | `tidysites pages` | every address the website renders |
29
30
  | `tidysites page <path> [--html]` | the rendered page, section by section, with the key each section carries |
@@ -59,6 +60,8 @@ out of a file is cleared on the website at the next push. `id` and
59
60
  a file you changed and have not pushed; it keeps the file and the difference
60
61
  shows in `status`.
61
62
 
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
+
62
65
  Images: an image field, a hero's image or a Columns image slot takes
63
66
  `{ "$media": { "url": "https://...", "alt": "what it shows" } }` and the
64
67
  push fetches the file into the website's library; a file the library already
package/bin/tidysites.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
  * status what changed here since the pull
8
8
  * validate check the files against the content model
9
9
  * push [--prune] [--force] [--dry] write the changes back, then pull
10
- * shot <path|url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--first-screen] screenshot to .tidysites/shots (none = desktop and mobile); a tall page comes in 1600px bands
11
- * measure <path|url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--full] the computed type and boxes of a page: the numbers to style to and diff
10
+ * shot <path|url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--first-screen] [--menu] [--open sel,..] [--hover sel,..] screenshot to .tidysites/shots (none = desktop and mobile); a tall page comes in 1600px bands; --menu opens every navigation menu first
11
+ * measure <path|url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--full] [--menu] [--open sel,..] [--hover sel,..] the computed type and boxes of a page: the numbers to style to and diff
12
+ * speed <path|url> [--mobile|--desktop] [--live] [--against <url>] PageSpeed Insights for a page, and the old page beside it
12
13
  * redirect [add <old> <new> | rm <old>] the website's redirects (an old address that must keep working goes here)
13
14
  * pages every address the website renders
14
15
  * page <path> [--html] the rendered page, section by section
@@ -189,9 +190,13 @@ async function pull({ dir, cfg }, collections = null, { overwrite = new Set(), m
189
190
  // website's version underneath it, so status shows the difference and a push carries the new stamp.
190
191
  const files = {};
191
192
  const kept = [];
193
+ const removedHere = readRemoved(dir);
194
+ const heldBack = [];
192
195
  for (const [collection, entries] of Object.entries(doc.content)) {
193
196
  for (const e of entries) {
194
197
  const key = `${collection}/${e.slug ?? e.id}`;
198
+ // Removed here with `tidysites rm` and not pruned yet: the website still has it, the folder does not get it back.
199
+ if (removedHere.has(key) && !overwrite.has(key)) { heldBack.push(key); continue; }
195
200
  const mine = local[key];
196
201
  const was = prev?.entries?.[key];
197
202
  // An entry this push just wrote is the website's now: its file takes the website's version ($media
@@ -244,6 +249,7 @@ async function pull({ dir, cfg }, collections = null, { overwrite = new Set(), m
244
249
  for (const c of doc.collections.filter((c) => c.count)) say(` ${c.slug.padEnd(20)} ${c.count}`);
245
250
  if (snap.menus) say(` ${'menus'.padEnd(20)} ${Object.keys(snap.menus).length}`);
246
251
  for (const k of kept) say(` kept your unpushed changes in ${k}`);
252
+ if (heldBack.length) say(` ${heldBack.length} entr${heldBack.length === 1 ? 'y' : 'ies'} you removed here stay removed (the website still has them until \`push --prune\`)`);
247
253
  }
248
254
 
249
255
  /** The menus alone: files and the snapshot's menus, the entries untouched. */
@@ -339,6 +345,8 @@ async function push(ctx) {
339
345
  process.exit(1);
340
346
  }
341
347
  say(`Pushed: ${r.content.created} created, ${r.content.updated} updated${r.images ? `, ${r.images} image${r.images === 1 ? '' : 's'} added` : ''}${r.images_reused ? `, ${r.images_reused} already in the library` : ''}${r.removed ? `, ${r.removed} removed` : ''}.`);
348
+ // Removed from the website now: the folder stops holding them back.
349
+ if (removeKeys.length) { const rem = readRemoved(ctx.dir); for (const k of removeKeys) rem.delete(k); writeRemoved(ctx.dir, rem); }
342
350
  if (r.missing?.length) say(`Not found to remove: ${r.missing.map((m) => `${m.collection}/${m.slug}`).join(', ')}`);
343
351
  }
344
352
  for (const name of menus) {
@@ -363,7 +371,7 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
363
371
  // --desktop, --mobile, --tablet, --wide, any of them together; --all for every width; none = desktop and mobile.
364
372
  const picked = ['desktop', 'mobile', 'tablet', 'wide'].filter((v) => flags[v]);
365
373
  const view = flags.all ? 'all' : picked.length ? picked.join(',') : (opts.view ?? 'both');
366
- 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' }) };
374
+ const args = { site_id: ctx.cfg.site, view, full_page: !(flags['first-screen'] || opts.firstScreen), ...openStates(), ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }) };
367
375
  const { json, images } = await tool(ctx.cfg, 'screenshot_website', args);
368
376
  const outDir = join(ctx.dir, META, 'shots');
369
377
  mkdirSync(outDir, { recursive: true });
@@ -382,13 +390,23 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
382
390
  if (!images.length) say(json ? JSON.stringify(json).slice(0, 500) : 'No image came back.');
383
391
  }
384
392
 
393
+ /** What a shot or a measure opens first: --menu (every navigation menu), --open <selector,...> (clicked), --hover <selector,...> (their submenus shown). */
394
+ function openStates() {
395
+ const list = (v) => (v ? String(v).split(',').map((x) => x.trim()).filter(Boolean) : []);
396
+ const out = {};
397
+ if (flags.menu) out.menu = true;
398
+ if (flags.open) out.open = list(flags.open);
399
+ if (flags.hover) out.hover = list(flags.hover);
400
+ return out;
401
+ }
402
+
385
403
  /** The computed type and boxes of a page, old or new, at one or several widths: the numbers a port styles to. */
386
404
  async function measure(ctx) {
387
405
  const target = positional[0] ?? '/';
388
406
  const isUrl = /^https?:\/\//.test(target);
389
407
  const picked = ['desktop', 'mobile', 'tablet', 'wide'].filter((v) => flags[v]);
390
408
  const view = flags.all ? 'all' : picked.length ? picked.join(',') : 'desktop';
391
- const args = { site_id: ctx.cfg.site, view, ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }) };
409
+ const args = { site_id: ctx.cfg.site, view, ...openStates(), ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }) };
392
410
  const { json } = await tool(ctx.cfg, 'measure_website', args);
393
411
  if (!json?.views) fail('No measurements came back.');
394
412
  const outDir = join(ctx.dir, META, 'measures');
@@ -417,6 +435,39 @@ async function measure(ctx) {
417
435
  }
418
436
  }
419
437
 
438
+ /** PageSpeed Insights for a page of the website (or any address), and the old page beside it with --against. */
439
+ async function speed(ctx) {
440
+ const target = positional[0] ?? '/';
441
+ const isUrl = /^https?:\/\//.test(target);
442
+ const strategy = flags.mobile && !flags.desktop ? 'mobile' : flags.desktop && !flags.mobile ? 'desktop' : 'both';
443
+ const args = { site_id: ctx.cfg.site, strategy, ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }), ...(flags.against ? { compare_url: String(flags.against) } : {}) };
444
+ say(`Testing${flags.against ? ' both pages' : ''} with PageSpeed Insights (a minute or two)...`);
445
+ const { json } = await tool(ctx.cfg, 'speed_website', args);
446
+ if (!json?.page) fail('No result came back.');
447
+ const outDir = join(ctx.dir, META, 'speed');
448
+ mkdirSync(outDir, { recursive: true });
449
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(4, 18);
450
+ const base = (isUrl ? new URL(target).hostname + new URL(target).pathname : target).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home';
451
+ const file = join(outDir, `${base}-${stamp}.json`);
452
+ writeFileSync(file, JSON.stringify(json, null, '\t'));
453
+ const n = (v, unit = '') => (v === null || v === undefined ? '—' : `${v}${unit}`);
454
+ const line = (r) => r.error ? ` ${r.strategy.padEnd(8)} ${r.error}` : ` ${r.strategy.padEnd(8)} perf ${n(r.scores.performance)} a11y ${n(r.scores.accessibility)} best ${n(r.scores.best_practices)} seo ${n(r.scores.seo)} LCP ${n(r.lab.lcp_ms, 'ms')} FCP ${n(r.lab.fcp_ms, 'ms')} TBT ${n(r.lab.tbt_ms, 'ms')} CLS ${n(r.lab.cls)} ${r.lab.total_bytes ? Math.round(r.lab.total_bytes / 1024) + ' KB' : '—'} in ${n(r.lab.requests)} requests${r.field ? ` field: LCP ${n(r.field.lcp_ms, 'ms')} INP ${n(r.field.inp_ms, 'ms')} CLS ${n(r.field.cls)} (${r.field.overall})` : ''}`;
455
+ say(`\n${json.target}`);
456
+ for (const r of json.page) say(line(r));
457
+ if (json.compare) { say(`\n${json.compare_url} (the page it is compared with)`); for (const r of json.compare) say(line(r)); }
458
+ for (const r of json.page) {
459
+ if (r.error) continue;
460
+ say(`\n ${r.strategy}: what to change`);
461
+ if (r.lcp_element) say(` largest paint: ${r.lcp_element.replace(/\s+/g, ' ').slice(0, 160)}`);
462
+ for (const o of r.opportunities.slice(0, 8)) {
463
+ say(` • ${o.title}${o.savings_ms ? ` (${o.savings_ms} ms)` : ''}${o.savings_kb ? ` (${o.savings_kb} KB)` : ''}`);
464
+ for (const it of o.items.slice(0, 4)) say(` ${it}`);
465
+ }
466
+ for (const d of r.diagnostics.slice(0, 8)) say(` · ${d}`);
467
+ }
468
+ say(`\nSaved to ${relative(process.cwd(), file)}. Full reports: ${json.page.filter((r) => !r.error).map((r) => r.report_url).join(' ')}`);
469
+ }
470
+
420
471
  /** The website's redirects: list them, add one, remove one. They ship with the next publish. */
421
472
  async function redirect(ctx) {
422
473
  const sub = positional[0];
@@ -556,12 +607,21 @@ function rmCmd({ dir }) {
556
607
  const f = join(colDir, `${sl}.${ext}`);
557
608
  if (existsSync(f)) { rmSync(f); hit = true; }
558
609
  }
559
- if (hit) { removed++; say(`- ${collection}/${sl}`); } else say(`${collection}/${sl}: no such entry file`);
610
+ if (hit) { removed++; say(`- ${collection}/${sl}`); rememberRemoved(dir, `${collection}/${sl}`); } else say(`${collection}/${sl}: no such entry file`);
560
611
  }
561
612
  }
562
613
  say(`${removed} entr${removed === 1 ? 'y' : 'ies'} removed from the folder; \`tidysites push --prune\` removes them from the website.`);
563
614
  }
564
615
 
616
+ /**
617
+ * Entries removed here and not yet pruned from the website. A pull (the one after a failed push included) does not
618
+ * write them back; `push --prune` removes them from the website and forgets them. The list lives beside the snapshot.
619
+ */
620
+ const REMOVED_FILE = 'removed.json';
621
+ function readRemoved(dir) { return new Set(readJson(join(dir, META, REMOVED_FILE)) ?? []); }
622
+ function writeRemoved(dir, set) { writeFileSync(join(dir, META, REMOVED_FILE), JSON.stringify([...set].sort(), null, '\t')); }
623
+ function rememberRemoved(dir, key) { const s = readRemoved(dir); s.add(key); writeRemoved(dir, s); }
624
+
565
625
  async function publish(ctx) {
566
626
  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`.');
567
627
  // A website live on the client's own domain is never published from here: the website refuses (409 live_domain) and says who may.
@@ -616,6 +676,8 @@ async function main() {
616
676
  }
617
677
  case 'measure':
618
678
  return measure(config());
679
+ case 'speed':
680
+ return speed(config());
619
681
  case 'redirect':
620
682
  case 'redirects':
621
683
  return redirect(config());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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": {