@tideworthy/tidysites 0.1.7 → 0.1.9
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 +3 -1
- package/bin/tidysites.mjs +71 -1
- package/lib/fetch.mjs +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,13 +23,15 @@ nothing else; it lives in `.tidysites/key`, which is kept out of git.
|
|
|
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
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 |
|
|
29
31
|
| `tidysites gallery [type]` | a screenshot of every section type and layout in this website's theme, or the first screen of one type |
|
|
30
32
|
| `tidysites fetch <url> [--out dir]` | a page of the site being ported: cleaned HTML, its outline (headings, navigation, images, phones, fonts, the stylesheet's own variables and colours) and its stylesheets, inline style blocks included |
|
|
31
33
|
| `tidysites rm <collection>/<slug> ...` | delete entry files with their sidecars (`services/*` for every entry of a collection); `push --prune` then removes them from the website |
|
|
32
|
-
| `tidysites publish --yes` | the live site becomes a copy of the preview site |
|
|
34
|
+
| `tidysites publish --yes` | the live site becomes a copy of the preview site. A website that is live on the client's own domain is never published from the command line: the website refuses and says so; the person publishes it (Publish now on the Website tab), or an assistant with them does through the connector after they confirm |
|
|
33
35
|
|
|
34
36
|
A section keeps its type with its key: to replace an intro with a row, give the row its own `_key`
|
|
35
37
|
(`validate` says so; the website would only say "internal error"). After a push the pushed files take the
|
package/bin/tidysites.mjs
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* validate check the files against the content model
|
|
9
9
|
* push [--prune] [--force] [--dry] write the changes back, then pull
|
|
10
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
|
|
14
16
|
* gallery [type] screenshot of every section type in this theme, or the first screen of one type
|
|
15
17
|
* fetch <url> [--out dir] a page of the site being ported: cleaned HTML and its outline
|
|
16
18
|
* rm <collection>/<slug> ... delete entry files (globs allowed: services/*); push --prune then removes them from the website
|
|
17
|
-
* publish --yes the live site becomes a copy of the preview site
|
|
19
|
+
* publish --yes the live site becomes a copy of the preview site (refused for a site live on the client's own domain: the person publishes that)
|
|
18
20
|
*
|
|
19
21
|
* The site is the remote: pull, edit, push. A push that would overwrite an
|
|
20
22
|
* entry changed on the website since the pull is refused until you pull
|
|
@@ -380,6 +382,68 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
|
|
|
380
382
|
if (!images.length) say(json ? JSON.stringify(json).slice(0, 500) : 'No image came back.');
|
|
381
383
|
}
|
|
382
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
|
+
|
|
383
447
|
async function pages(ctx) {
|
|
384
448
|
const { json } = await tool(ctx.cfg, 'list_website_pages', { site_id: ctx.cfg.site });
|
|
385
449
|
const list = json?.pages ?? json?.items ?? [];
|
|
@@ -500,6 +564,7 @@ function rmCmd({ dir }) {
|
|
|
500
564
|
|
|
501
565
|
async function publish(ctx) {
|
|
502
566
|
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
|
+
// A website live on the client's own domain is never published from here: the website refuses (409 live_domain) and says who may.
|
|
503
568
|
const { json, text } = await tool(ctx.cfg, 'publish_website', { site_id: ctx.cfg.site });
|
|
504
569
|
say(json ? JSON.stringify(json, null, 2) : text);
|
|
505
570
|
}
|
|
@@ -549,6 +614,11 @@ async function main() {
|
|
|
549
614
|
const type = positional[0];
|
|
550
615
|
return shot(config(), `/_tidy/gallery${type ? `#${type}` : ''}`, { view: 'desktop', name: type ? `gallery-${type}` : 'gallery', firstScreen: Boolean(type) });
|
|
551
616
|
}
|
|
617
|
+
case 'measure':
|
|
618
|
+
return measure(config());
|
|
619
|
+
case 'redirect':
|
|
620
|
+
case 'redirects':
|
|
621
|
+
return redirect(config());
|
|
552
622
|
case 'pages':
|
|
553
623
|
return pages(config());
|
|
554
624
|
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