@tideworthy/tidysites 0.1.13 → 0.1.15
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 +30 -1
- package/lib/fetch.mjs +18 -2
- package/lib/folder.mjs +4 -1
- package/lib/validate.mjs +6 -2
- 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" } }` and the
|
|
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 40 MB), 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
|
@@ -322,6 +322,9 @@ async function push(ctx) {
|
|
|
322
322
|
}
|
|
323
323
|
const doc = pushDocument(s.local, s.snap, { keys, removeKeys });
|
|
324
324
|
if (flags.force) doc.force = true;
|
|
325
|
+
// A picture that exists only as a file here (an illustration drawn from the old page's inline SVG, a rasterised
|
|
326
|
+
// icon) goes up with the push as its bytes; the website stores it in the library like a fetched one.
|
|
327
|
+
inlineMediaFiles(doc, ctx.dir);
|
|
325
328
|
if (flags.dry) {
|
|
326
329
|
say(`Would push ${keys.length} entr${keys.length === 1 ? 'y' : 'ies'}${removeKeys.length ? `, remove ${removeKeys.length}` : ''}${menus.length ? `, rewrite menu${menus.length === 1 ? '' : 's'} ${menus.join(', ')}` : ''}:`);
|
|
327
330
|
printStatus(s);
|
|
@@ -366,6 +369,32 @@ async function push(ctx) {
|
|
|
366
369
|
else if (menus.length) await pullMenus(ctx);
|
|
367
370
|
}
|
|
368
371
|
|
|
372
|
+
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' };
|
|
373
|
+
/** Pictures travel up to 15 MB; a video (a row's media list) up to 40 MB. */
|
|
374
|
+
const mediaCap = (type) => (type.startsWith('video/') ? 40 : 15);
|
|
375
|
+
/** Every { "$media": { "file": ... } } in the document becomes { "$media": { "data", "type", "filename", "alt" } }, read from the folder. */
|
|
376
|
+
function inlineMediaFiles(doc, dir) {
|
|
377
|
+
const walk = (v) => {
|
|
378
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
379
|
+
if (v && typeof v === 'object') {
|
|
380
|
+
const m = v.$media;
|
|
381
|
+
if (m && typeof m === 'object' && typeof m.file === 'string') {
|
|
382
|
+
const path = resolve(dir, m.file);
|
|
383
|
+
if (!path.startsWith(resolve(dir))) fail(`$media.file ${m.file}: outside the folder`);
|
|
384
|
+
if (!existsSync(path)) fail(`$media.file ${m.file}: no such file`);
|
|
385
|
+
const bytes = readFileSync(path);
|
|
386
|
+
const ext = m.file.split('.').pop().toLowerCase();
|
|
387
|
+
const type = MEDIA_TYPES[ext] ?? 'application/octet-stream';
|
|
388
|
+
if (bytes.length > mediaCap(type) * 1024 * 1024) fail(`$media.file ${m.file}: ${(bytes.length / 1048576).toFixed(1)} MB; ${type.startsWith('video/') ? 'videos up to 40 MB' : 'pictures up to 15 MB'}`);
|
|
389
|
+
return { $media: { data: bytes.toString('base64'), type, filename: m.file.split('/').pop(), alt: typeof m.alt === 'string' ? m.alt : '' } };
|
|
390
|
+
}
|
|
391
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
392
|
+
}
|
|
393
|
+
return v;
|
|
394
|
+
};
|
|
395
|
+
if (doc.content) doc.content = walk(doc.content);
|
|
396
|
+
}
|
|
397
|
+
|
|
369
398
|
async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
|
|
370
399
|
const isUrl = /^https?:\/\//.test(target);
|
|
371
400
|
// --desktop, --mobile, --tablet, --wide, any of them together; --all for every width; none = desktop and mobile.
|
|
@@ -633,7 +662,7 @@ async function publish(ctx) {
|
|
|
633
662
|
|
|
634
663
|
function help() {
|
|
635
664
|
say(readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(2, 20).map((l) => l.replace(/^ \* ?/, '')).join('\n'));
|
|
636
|
-
say(`\nFiles:\n <collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": {...} }; a new entry is a new file with "data"\n <collection>/<slug>.css data.custom_css as a file (rows: data.css); rows/<slug>.html is data.html\n menus/<name>.json a menu as a tree: { label, items: [{ label, url, children: [...] }] }\n ${META}/ config, key, the last pull, the content model, screenshots\n\nA field taken out of a file is cleared on the website at the next push; a file you changed here is never overwritten by a pull.\nImages: an image field or a Columns image slot takes { "$media": { "url": "https://...", "alt": "..." } }
|
|
665
|
+
say(`\nFiles:\n <collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": {...} }; a new entry is a new file with "data"\n <collection>/<slug>.css data.custom_css as a file (rows: data.css); rows/<slug>.html is data.html\n menus/<name>.json a menu as a tree: { label, items: [{ label, url, children: [...] }] }\n ${META}/ config, key, the last pull, the content model, screenshots\n\nA field taken out of a file is cleared on the website at the next push; a file you changed here is never overwritten by a pull.\nImages: an image field or a Columns image slot takes { "$media": { "url": "https://...", "alt": "..." } } (the push fetches it into the library) or { "$media": { "file": "assets/x.png", "alt": "..." } } for a picture that exists only as a file in this folder (the push carries its bytes).\nReferences: a record names another by its id, or "$ref:<slug-or-id>" for one in the same push.`);
|
|
637
666
|
}
|
|
638
667
|
|
|
639
668
|
async function main() {
|
package/lib/fetch.mjs
CHANGED
|
@@ -32,7 +32,9 @@ const text = (s) => decode(s.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim
|
|
|
32
32
|
export function cleanHtml(html) {
|
|
33
33
|
return html
|
|
34
34
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
35
|
-
.replace(/<(script|style|noscript|template
|
|
35
|
+
.replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1>/gi, '')
|
|
36
|
+
// A frame is a band of the page (a map, a report, a video): keep where it sits and what it shows, drop the rest of the tag.
|
|
37
|
+
.replace(/<iframe\b([^>]*)>[\s\S]*?<\/iframe>/gi, (m, attrs) => { const src = attrs.match(/\s(?:data-src|src)=["']([^"']+)["']/i)?.[1] ?? ''; const title = attrs.match(/\stitle=["']([^"']*)["']/i)?.[1] ?? ''; return `<iframe src="${src}"${title ? ` title="${title}"` : ''}></iframe>`; })
|
|
36
38
|
.replace(/<svg\b[\s\S]*?<\/svg>/gi, '<svg></svg>')
|
|
37
39
|
.replace(/\son[a-z]+="[^"]*"/gi, '')
|
|
38
40
|
.replace(/\sstyle="[^"]*"/gi, '')
|
|
@@ -51,7 +53,7 @@ function abs(base, href) {
|
|
|
51
53
|
|
|
52
54
|
/** What the page is made of, in order. */
|
|
53
55
|
export function outline(html, base) {
|
|
54
|
-
const out = { url: base, title: null, description: null, headings: [], nav: [], images: [], phones: [], emails: [], stylesheets: [], fonts: [], colors: [] };
|
|
56
|
+
const out = { url: base, title: null, description: null, headings: [], nav: [], images: [], videos: [], phones: [], emails: [], stylesheets: [], fonts: [], colors: [] };
|
|
55
57
|
const t = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
56
58
|
if (t) out.title = text(t[1]);
|
|
57
59
|
const d = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i);
|
|
@@ -93,6 +95,11 @@ export function outline(html, base) {
|
|
|
93
95
|
const height = tag.match(/\sheight=["']?(\d+)/i)?.[1];
|
|
94
96
|
out.images.push({ src: abs(base, src), alt, ...(width ? { width: Number(width) } : {}), ...(height ? { height: Number(height) } : {}) });
|
|
95
97
|
}
|
|
98
|
+
// A video on the page (a hero background, a <source> inside a <video>): a file to bring over, never to play from the old address.
|
|
99
|
+
for (const m of html.matchAll(/<(?:video|source)\b[^>]*\ssrc=["']([^"']+\.(?:mp4|webm|m4v|mov)(?:\?[^"']*)?)["'][^>]*>/gi)) {
|
|
100
|
+
const src = abs(base, m[1]);
|
|
101
|
+
if (!out.videos.some((v) => v.src === src)) out.videos.push({ src, poster: m[0].match(/\sposter=["']([^"']+)["']/i)?.[1] ? abs(base, m[0].match(/\sposter=["']([^"']+)["']/i)[1]) : null });
|
|
102
|
+
}
|
|
96
103
|
const body = text(html.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ''));
|
|
97
104
|
out.phones = [...new Set([...body.matchAll(/(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/g)].map((m) => m[0].trim()))].slice(0, 10);
|
|
98
105
|
out.emails = [...new Set([...body.matchAll(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi)].map((m) => m[0]))].slice(0, 10);
|
|
@@ -107,6 +114,7 @@ export function outline(html, base) {
|
|
|
107
114
|
out.fonts = [...new Set(out.fonts)];
|
|
108
115
|
// Adobe Fonts (Typekit) kits belong to the old site or its builder, so a port cannot load them; the families are named from the kit's CSS by the fetch command.
|
|
109
116
|
out.adobe_kits = [...new Set([...html.matchAll(/use\.typekit\.net\/([a-z0-9]+)\.css/gi)].map((m) => m[1].toLowerCase()))];
|
|
117
|
+
out.frames = [...html.matchAll(/<iframe\b([^>]*)>/gi)].map((m) => ({ src: abs(base, m[1].match(/\s(?:data-src|src)=["']([^"']+)["']/i)?.[1] ?? ''), title: decode(m[1].match(/\stitle=["']([^"']*)["']/i)?.[1] ?? '') })).filter((f) => f.src && !/^about:/.test(f.src)).slice(0, 20);
|
|
110
118
|
out.adobe_families = [];
|
|
111
119
|
return out;
|
|
112
120
|
}
|
|
@@ -162,6 +170,14 @@ export function outlineText(o) {
|
|
|
162
170
|
lines.push('', `Images (${o.images.length}):`);
|
|
163
171
|
for (const i of o.images.slice(0, 60)) lines.push(` ${i.src}${i.width ? ` (${i.width}x${i.height ?? '?'})` : ''}${i.alt ? ` alt: ${i.alt}` : ''}`);
|
|
164
172
|
}
|
|
173
|
+
if (o.videos?.length) {
|
|
174
|
+
lines.push('', `Videos (${o.videos.length}): download each (curl -L -o assets/<name>.mp4 <src>), list it in the row's "media" with a name, and write <video src="{{ media.<name>.url }}" autoplay muted loop playsinline>; a video is never played from the old site's address`);
|
|
175
|
+
for (const v of o.videos) lines.push(` ${v.src}${v.poster ? ` poster: ${v.poster}` : ''}`);
|
|
176
|
+
}
|
|
177
|
+
if (o.frames?.length) {
|
|
178
|
+
lines.push('', `Embedded frames (${o.frames.length}): a band of the page each; rebuild as the frame section or a row's <iframe>`);
|
|
179
|
+
for (const f of o.frames) lines.push(` ${f.src}${f.title ? ` title: ${f.title}` : ''}`);
|
|
180
|
+
}
|
|
165
181
|
if (o.phones.length) lines.push('', `Phones: ${o.phones.join(', ')}`);
|
|
166
182
|
if (o.emails.length) lines.push(`Emails: ${o.emails.join(', ')}`);
|
|
167
183
|
if (o.fonts.length) lines.push(`Google fonts: ${o.fonts.join(', ')}`);
|
package/lib/folder.mjs
CHANGED
|
@@ -144,7 +144,7 @@ export function pushDocument(local, snapshot, { keys, removeKeys = [], settings
|
|
|
144
144
|
// A field taken out of the file is cleared on the site: the site keeps what a push does not name,
|
|
145
145
|
// so the names the snapshot had and the file no longer has go as null.
|
|
146
146
|
const data = { ...e.data };
|
|
147
|
-
if (was?.data) for (const k of Object.keys(was.data)) if (!(k in data) && was.data[k]
|
|
147
|
+
if (was?.data) for (const k of Object.keys(was.data)) if (!(k in data) && !emptyValue(was.data[k])) data[k] = null;
|
|
148
148
|
(content[collection] ??= []).push({ id: e.id, slug, status: e.status, data });
|
|
149
149
|
if (was?.updated_at) expect[key] = was.updated_at;
|
|
150
150
|
}
|
|
@@ -163,6 +163,9 @@ export function pushDocument(local, snapshot, { keys, removeKeys = [], settings
|
|
|
163
163
|
return doc;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** Nothing to clear: null, an empty string, an empty list or an empty object (the website refuses null for a list it stores as []). */
|
|
167
|
+
const emptyValue = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
|
|
168
|
+
|
|
166
169
|
export function splitKey(key) {
|
|
167
170
|
const i = key.indexOf('/');
|
|
168
171
|
return [key.slice(0, i), key.slice(i + 1)];
|
package/lib/validate.mjs
CHANGED
|
@@ -52,8 +52,12 @@ function checkScalar(type, value, at, errors) {
|
|
|
52
52
|
case 'image':
|
|
53
53
|
case 'media':
|
|
54
54
|
case 'file':
|
|
55
|
-
if (!isMedia(value)) errors.push(`${at}: an image is the stored value (from the pull or add_website_image) or { "$media": { "url": "https://...", "alt": "..." } }`);
|
|
56
|
-
else if (value.$media &&
|
|
55
|
+
if (!isMedia(value)) errors.push(`${at}: an image is the stored value (from the pull or add_website_image) or { "$media": { "url": "https://..." | "file": "assets/x.png", "alt": "..." } }`);
|
|
56
|
+
else if (value.$media && typeof value.$media.file === 'string') {
|
|
57
|
+
const ok = field.type === 'file' ? /\.(png|jpe?g|gif|webp|avif|svg|ico|mp4|webm|m4v|mov|woff2?|ttf|otf|pdf)$/i : /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i;
|
|
58
|
+
if (!ok.test(value.$media.file) || value.$media.file.startsWith('/') || value.$media.file.includes('..')) errors.push(`${at}: $media.file must be a file inside the folder (${field.type === 'file' ? 'a picture, a video (mp4, webm), a font (woff2) or a pdf' : 'a picture: png, jpg, gif, webp, avif, svg, ico'})`);
|
|
59
|
+
}
|
|
60
|
+
else if (value.$media && (typeof value.$media.url !== 'string' || !/^https?:\/\//.test(value.$media.url))) errors.push(`${at}: $media.url must be an http(s) address (or $media.file for a picture in the folder)`);
|
|
57
61
|
else if (value.$media && typeof value.$media.alt !== 'string') errors.push(`${at}: $media.alt is required (what the image shows; "" for a decorative one)`);
|
|
58
62
|
return;
|
|
59
63
|
case 'json':
|
package/package.json
CHANGED