@tideworthy/tidysites 0.1.6 → 0.1.8

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,7 +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] [--no-bands]` | 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 picture taller than 2400px is also written in 1600px bands (`<name>-band-01.jpg`, …) that an image reader can take in one at a time |
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 |
27
+ | `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 |
26
28
  | `tidysites pages` | every address the website renders |
27
29
  | `tidysites page <path> [--html]` | the rendered page, section by section, with the key each section carries |
28
30
  | `tidysites schema [type or collection]` | the section types and layouts, or one type's or collection's fields |
package/bin/tidysites.mjs CHANGED
@@ -7,7 +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] [--no-bands] screenshot to .tidysites/shots (none = desktop and mobile); a tall one also in 1600px bands
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
12
+ * redirect [add <old> <new> | rm <old>] the website's redirects (an old address that must keep working goes here)
11
13
  * pages every address the website renders
12
14
  * page <path> [--html] the rendered page, section by section
13
15
  * schema [name] section types and layouts, or one type's or collection's fields
@@ -26,7 +28,6 @@ import { callTool, ApiError } from '../lib/api.mjs';
26
28
  import { diffEntries, entryToFiles, filesToEntry, FILE_NAME, menuItems, menuTree, pushDocument, sameEntry, snapshotOf, SIDECARS } from '../lib/folder.mjs';
27
29
  import { validateAll, indexSchema } from '../lib/validate.mjs';
28
30
  import { cleanHtml, cssSummary, fetchPage, outline, outlineText } from '../lib/fetch.mjs';
29
- import { writeBands } from '../lib/bands.mjs';
30
31
 
31
32
  const META = '.tidysites';
32
33
  const argv = process.argv.slice(2);
@@ -369,22 +370,80 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
369
370
  const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(4, 18);
370
371
  const base = (opts.name ?? (isUrl ? new URL(target).hostname + new URL(target).pathname : target)).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home';
371
372
  const named = Array.isArray(json?.captures) ? json.captures : [];
373
+ // The pictures come in the order of the captures: a tall page as its bands (top to bottom), a short one whole.
374
+ const slots = named.filter((c) => !c.error).flatMap((c) => (Array.isArray(c.bands) && c.bands.length ? c.bands.map((b) => ({ view: c.view, band: b.index })) : [{ view: c.view, band: 0 }]));
372
375
  images.forEach((img, i) => {
373
- const v = named[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view === 'all' ? ['desktop', 'mobile', 'tablet', 'wide'][i] : view.split(',')[i] ?? view);
374
- const p = join(outDir, `${base}-${v}-${stamp}.${img.mimeType.includes('png') ? 'png' : 'jpg'}`);
375
- const buf = Buffer.from(img.base64, 'base64');
376
- writeFileSync(p, buf);
376
+ const v = slots[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view === 'all' ? ['desktop', 'mobile', 'tablet', 'wide'][i] : view.split(',')[i] ?? view);
377
+ const band = slots[i]?.band ? `-band-${String(slots[i].band).padStart(2, '0')}` : '';
378
+ const p = join(outDir, `${base}-${v}-${stamp}${band}.${img.mimeType.includes('png') ? 'png' : 'jpg'}`);
379
+ writeFileSync(p, Buffer.from(img.base64, 'base64'));
377
380
  say(relative(process.cwd(), p));
378
- // A tall full-page picture is also written in bands an image reader can take in one at a time.
379
- if (!flags['no-bands']) {
380
- const bands = writeBands(p, buf);
381
- for (const b of bands.paths) say(` ${relative(process.cwd(), b)}`);
382
- if (bands.reason) say(` (${bands.reason})`);
383
- }
384
381
  });
385
382
  if (!images.length) say(json ? JSON.stringify(json).slice(0, 500) : 'No image came back.');
386
383
  }
387
384
 
385
+ /** The computed type and boxes of a page, old or new, at one or several widths: the numbers a port styles to. */
386
+ async function measure(ctx) {
387
+ const target = positional[0] ?? '/';
388
+ const isUrl = /^https?:\/\//.test(target);
389
+ const picked = ['desktop', 'mobile', 'tablet', 'wide'].filter((v) => flags[v]);
390
+ 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' }) };
392
+ const { json } = await tool(ctx.cfg, 'measure_website', args);
393
+ if (!json?.views) fail('No measurements came back.');
394
+ const outDir = join(ctx.dir, META, 'measures');
395
+ mkdirSync(outDir, { recursive: true });
396
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(4, 18);
397
+ const base = (isUrl ? new URL(target).hostname + new URL(target).pathname : target).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home';
398
+ for (const v of json.views) {
399
+ if (v.error) { say(`${v.view}: ${v.error}`); continue; }
400
+ const file = join(outDir, `${base}-${v.view}-${stamp}.json`);
401
+ writeFileSync(file, JSON.stringify(v, null, '\t'));
402
+ say(`\n${v.view} (${v.width}px wide, page ${v.page_height}px tall; fonts: ${v.fonts.join(', ')}) → ${relative(process.cwd(), file)}`);
403
+ const kinds = flags.kind ? String(flags.kind).split(',') : ['box', 'text', 'button', 'image'];
404
+ const items = v.items.filter((it) => kinds.includes(it.kind)).filter((it) => it.kind !== 'text' || /^h[1-6]$/.test(it.tag) || flags.all || flags.text || it.font?.size >= 16 || /^(p|li|a)$/.test(it.tag));
405
+ const shown = flags.full ? items : items.slice(0, 120);
406
+ for (const it of shown) {
407
+ const where = `y${String(it.y).padStart(5)} x${String(it.x).padStart(4)} ${String(it.width).padStart(4)}×${String(it.height).padEnd(4)}`;
408
+ if (it.kind === 'box') say(` ${where} ▭ ${it.selector} pad ${it.padding.join('/')} bg ${it.background}${it.text ? ` "${it.text}"` : ''}`);
409
+ else if (it.kind === 'image') say(` ${where} ▣ ${it.selector} ${it.natural?.width}×${it.natural?.height} natural${it.radius && it.radius !== '0px' ? ` radius ${it.radius}` : ''} ${it.text ? `"${it.text}"` : it.src?.split('/').pop()}`);
410
+ else {
411
+ const f = it.font;
412
+ const type = `${f.size}px/${f.line_height ?? 'auto'} w${f.weight} ${f.family}${f.letter_spacing !== 'normal' ? ` ls ${f.letter_spacing}` : ''}${f.transform !== 'none' ? ` ${f.transform}` : ''} ${f.color}`;
413
+ say(` ${where} ${it.kind === 'button' ? '▶' : it.tag.padEnd(2)} ${type}${it.kind === 'button' ? ` pad ${it.padding.join('/')} bg ${it.background} r ${it.radius}` : ''} "${it.text}"`);
414
+ }
415
+ }
416
+ if (items.length > shown.length) say(` … ${items.length - shown.length} more in the file (--full prints all)`);
417
+ }
418
+ }
419
+
420
+ /** The website's redirects: list them, add one, remove one. They ship with the next publish. */
421
+ async function redirect(ctx) {
422
+ const sub = positional[0];
423
+ if (sub === 'add') {
424
+ const [, from, to] = positional;
425
+ if (!from || !to) fail('Usage: tidysites redirect add <old path> <new path or url>');
426
+ const { json } = await tool(ctx.cfg, 'add_website_redirect', { site_id: ctx.cfg.site, source: from, destination: to });
427
+ say(json ? `${from} → ${to}` : 'Added.');
428
+ return;
429
+ }
430
+ if (sub === 'rm' || sub === 'remove') {
431
+ const from = positional[1];
432
+ if (!from) fail('Usage: tidysites redirect rm <old path>');
433
+ const { json: list } = await tool(ctx.cfg, 'list_website_redirects', { site_id: ctx.cfg.site });
434
+ const rows = list?.redirects ?? list?.items ?? [];
435
+ const hit = rows.find((r) => (r.source ?? r.from) === from || (r.source ?? r.from) === from.replace(/\/$/, '') || (r.source ?? r.from) === `${from}/`);
436
+ if (!hit) fail(`No redirect from ${from}.`);
437
+ await tool(ctx.cfg, 'remove_website_redirect', { site_id: ctx.cfg.site, redirect_id: hit.id });
438
+ say(`Removed ${from}.`);
439
+ return;
440
+ }
441
+ const { json } = await tool(ctx.cfg, 'list_website_redirects', { site_id: ctx.cfg.site });
442
+ const rows = json?.redirects ?? json?.items ?? [];
443
+ if (!rows.length) { say('No redirects.'); return; }
444
+ for (const r of rows) say(`${(r.source ?? r.from ?? '').padEnd(44)} → ${r.destination ?? r.to ?? ''}${r.status && r.status !== 301 ? ` (${r.status})` : ''}`);
445
+ }
446
+
388
447
  async function pages(ctx) {
389
448
  const { json } = await tool(ctx.cfg, 'list_website_pages', { site_id: ctx.cfg.site });
390
449
  const list = json?.pages ?? json?.items ?? [];
@@ -554,6 +613,11 @@ async function main() {
554
613
  const type = positional[0];
555
614
  return shot(config(), `/_tidy/gallery${type ? `#${type}` : ''}`, { view: 'desktop', name: type ? `gallery-${type}` : 'gallery', firstScreen: Boolean(type) });
556
615
  }
616
+ case 'measure':
617
+ return measure(config());
618
+ case 'redirect':
619
+ case 'redirects':
620
+ return redirect(config());
557
621
  case 'pages':
558
622
  return pages(config());
559
623
  case 'page':
package/lib/fetch.mjs CHANGED
@@ -56,6 +56,19 @@ export function outline(html, base) {
56
56
  if (t) out.title = text(t[1]);
57
57
  const d = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i);
58
58
  if (d) out.description = decode(d[1]).trim();
59
+ // The rest of the page's SEO, so a port carries it over: canonical, robots, share tags, the icon, the structured data types.
60
+ const meta = (attr, name) => { const m = html.match(new RegExp(`<meta[^>]+${attr}=["']${name}["'][^>]*content=["']([^"']*)["']`, 'i')) || html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]*${attr}=["']${name}["']`, 'i')); return m ? decode(m[1]).trim() : null; };
61
+ const link = (rel) => { const m = html.match(new RegExp(`<link[^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]*href=["']([^"']+)["']`, 'i')) || html.match(new RegExp(`<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\\b${rel}\\b[^"']*["']`, 'i')); return m ? abs(base, m[1]) : null; };
62
+ out.seo = {
63
+ canonical: link('canonical'),
64
+ robots: meta('name', 'robots'),
65
+ og_title: meta('property', 'og:title'), og_description: meta('property', 'og:description'), og_image: meta('property', 'og:image') ? abs(base, meta('property', 'og:image')) : null, og_type: meta('property', 'og:type'),
66
+ twitter_card: meta('name', 'twitter:card'),
67
+ favicon: link('icon') || link('shortcut icon') || abs(base, '/favicon.ico'),
68
+ apple_touch_icon: link('apple-touch-icon'),
69
+ schema_types: Array.from(html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)).flatMap((m) => { try { const j = JSON.parse(m[1]); const list = Array.isArray(j) ? j : j['@graph'] ? j['@graph'] : [j]; return list.map((x) => x['@type']).filter(Boolean).flat(); } catch { return []; } }),
70
+ lang: (html.match(/<html[^>]+lang=["']([^"']+)["']/i) || [])[1] ?? null
71
+ };
59
72
  for (const m of html.matchAll(/<(h[1-4])\b[^>]*>([\s\S]*?)<\/\1>/gi)) {
60
73
  const s = text(m[2]);
61
74
  if (s) out.headings.push({ level: Number(m[1][1]), text: s });
@@ -130,6 +143,10 @@ export function outlineText(o) {
130
143
  lines.push(`URL: ${o.url}`);
131
144
  if (o.title) lines.push(`Title: ${o.title}`);
132
145
  if (o.description) lines.push(`Description: ${o.description}`);
146
+ if (o.seo) {
147
+ lines.push('', 'SEO (carry these over: meta_title, meta_description, canonical, share image, schema; the icon onto the Business record):');
148
+ for (const [k, v] of Object.entries(o.seo)) if (v && (!Array.isArray(v) || v.length)) lines.push(` ${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
149
+ }
133
150
  if (o.nav.length) {
134
151
  lines.push('', 'Navigation:');
135
152
  for (const n of o.nav) lines.push(` ${n.label} → ${n.href}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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": {
package/lib/bands.mjs DELETED
@@ -1,78 +0,0 @@
1
- /**
2
- * A full-page screenshot of a long page is too tall for an assistant's image
3
- * reader to take in. Cut it into bands of a readable height, written beside
4
- * the original as <name>-band-01.jpg, -02, ... so a compare can go band by
5
- * band. The slicing uses a tool the machine already has (sips on macOS,
6
- * ImageMagick where installed); without one the original stands alone and
7
- * the caller is told.
8
- */
9
- import { spawnSync } from 'node:child_process';
10
- import { existsSync } from 'node:fs';
11
-
12
- export const BAND_HEIGHT = 1600;
13
- /** A picture no taller than this is readable whole and is not cut. */
14
- export const BAND_WHOLE = 2400;
15
-
16
- /** Width and height of a JPEG or PNG from its header, or null when it is neither. */
17
- export function imageSize(buf) {
18
- if (buf.length > 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
19
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
20
- }
21
- if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
22
- let i = 2;
23
- while (i + 9 < buf.length) {
24
- if (buf[i] !== 0xff) { i++; continue; }
25
- const marker = buf[i + 1];
26
- if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
27
- const len = buf.readUInt16BE(i + 2);
28
- // SOF0..SOF15 except DHT (C4), JPG (C8), DAC (CC) carry the frame size.
29
- if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
30
- return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
31
- }
32
- i += 2 + len;
33
- }
34
- }
35
- return null;
36
- }
37
-
38
- /** The band files a picture of this height needs: [{ y, height }] from the top. */
39
- export function bandPlan(height, band = BAND_HEIGHT, whole = BAND_WHOLE) {
40
- if (height <= whole) return [];
41
- const out = [];
42
- for (let y = 0; y < height; y += band) out.push({ y, height: Math.min(band, height - y) });
43
- return out;
44
- }
45
-
46
- function slicer() {
47
- if (process.platform === 'darwin' && existsSync('/usr/bin/sips')) return 'sips';
48
- for (const bin of ['magick', 'convert']) {
49
- const r = spawnSync(bin, ['-version'], { stdio: 'ignore' });
50
- if (r.status === 0) return bin;
51
- }
52
- return null;
53
- }
54
-
55
- /**
56
- * Write the bands of a saved picture beside it. Returns the band paths, or
57
- * an empty list with `reason` when the picture needs no cutting or the
58
- * machine has no tool to cut it with.
59
- */
60
- export function writeBands(file, buf) {
61
- const size = imageSize(buf);
62
- if (!size) return { paths: [], reason: 'not a JPEG or PNG' };
63
- const plan = bandPlan(size.height);
64
- if (!plan.length) return { paths: [], reason: null };
65
- const tool = slicer();
66
- if (!tool) return { paths: [], reason: `the picture is ${size.height}px tall; no sips or ImageMagick here to cut it into bands` };
67
- const stem = file.replace(/\.(jpe?g|png)$/i, '');
68
- const ext = /\.png$/i.test(file) ? 'png' : 'jpg';
69
- const paths = [];
70
- plan.forEach((b, i) => {
71
- const out = `${stem}-band-${String(i + 1).padStart(2, '0')}.${ext}`;
72
- const r = tool === 'sips'
73
- ? spawnSync('/usr/bin/sips', ['-c', String(b.height), String(size.width), '--cropOffset', String(b.y), '0', file, '--out', out], { stdio: 'ignore' })
74
- : spawnSync(tool, [file, '-crop', `${size.width}x${b.height}+0+${b.y}`, '+repage', out], { stdio: 'ignore' });
75
- if (r.status === 0 && existsSync(out)) paths.push(out);
76
- });
77
- return { paths, reason: null };
78
- }