@tideworthy/tidysites 0.1.5 → 0.1.6
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 +5 -1
- package/bin/tidysites.mjs +10 -2
- package/lib/bands.mjs +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ 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 |
|
|
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 |
|
|
26
26
|
| `tidysites pages` | every address the website renders |
|
|
27
27
|
| `tidysites page <path> [--html]` | the rendered page, section by section, with the key each section carries |
|
|
28
28
|
| `tidysites schema [type or collection]` | the section types and layouts, or one type's or collection's fields |
|
|
@@ -62,6 +62,10 @@ Images: an image field, a hero's image or a Columns image slot takes
|
|
|
62
62
|
push fetches the file into the website's library. An image already in the
|
|
63
63
|
library keeps the value the pull gave it.
|
|
64
64
|
|
|
65
|
+
A picture in custom HTML is written as a plain `<img src="{{ s.image.url }}" alt="{{ s.image.alt }}">`: a stored picture gets its resized variants (`srcset`, and a `sizes` hint when none is written) on the way out, so a phone downloads a phone-sized file; write your own `sizes` when the picture is narrower than half the screen on a laptop, and your own `srcset` to take over entirely. Pictures in the first section load at once and the first of them asks to go first; pictures below load when they are needed, unless the markup says `loading="eager"`.
|
|
66
|
+
|
|
67
|
+
Every form carries a hidden trap field a scraper fills and a person never sees. A Forms record or a Request form section can add Cloudflare Turnstile through its **Spam protection** field; it is off unless chosen, and when chosen its script loads only as the form comes into view or is touched.
|
|
68
|
+
|
|
65
69
|
References: a record names another by its id (what the pull shows), or by
|
|
66
70
|
`"$ref:<slug>"` for one created in the same push.
|
|
67
71
|
|
package/bin/tidysites.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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)
|
|
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
|
|
11
11
|
* pages every address the website renders
|
|
12
12
|
* page <path> [--html] the rendered page, section by section
|
|
13
13
|
* schema [name] section types and layouts, or one type's or collection's fields
|
|
@@ -26,6 +26,7 @@ import { callTool, ApiError } from '../lib/api.mjs';
|
|
|
26
26
|
import { diffEntries, entryToFiles, filesToEntry, FILE_NAME, menuItems, menuTree, pushDocument, sameEntry, snapshotOf, SIDECARS } from '../lib/folder.mjs';
|
|
27
27
|
import { validateAll, indexSchema } from '../lib/validate.mjs';
|
|
28
28
|
import { cleanHtml, cssSummary, fetchPage, outline, outlineText } from '../lib/fetch.mjs';
|
|
29
|
+
import { writeBands } from '../lib/bands.mjs';
|
|
29
30
|
|
|
30
31
|
const META = '.tidysites';
|
|
31
32
|
const argv = process.argv.slice(2);
|
|
@@ -371,8 +372,15 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
|
|
|
371
372
|
images.forEach((img, i) => {
|
|
372
373
|
const v = named[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view === 'all' ? ['desktop', 'mobile', 'tablet', 'wide'][i] : view.split(',')[i] ?? view);
|
|
373
374
|
const p = join(outDir, `${base}-${v}-${stamp}.${img.mimeType.includes('png') ? 'png' : 'jpg'}`);
|
|
374
|
-
|
|
375
|
+
const buf = Buffer.from(img.base64, 'base64');
|
|
376
|
+
writeFileSync(p, buf);
|
|
375
377
|
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
|
+
}
|
|
376
384
|
});
|
|
377
385
|
if (!images.length) say(json ? JSON.stringify(json).slice(0, 500) : 'No image came back.');
|
|
378
386
|
}
|
package/lib/bands.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED