@tideworthy/tidysites 0.1.14 → 0.1.16

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
@@ -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" } }` (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) 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
@@ -87,11 +87,15 @@ function writeFiles(dir, files) {
87
87
  function readLocal(dir, only = null) {
88
88
  const local = {};
89
89
  const problems = [];
90
+ // Only the website's own collections are entries; a folder of the person's own (notes, assets, source) is left alone.
91
+ const schema = readJson(join(dir, META, 'schema.json'));
92
+ const known = schema ? new Set((schema.collections?.items ?? schema.collections ?? []).map((c) => c.slug).filter(Boolean)) : null;
90
93
  for (const name of readdirSync(dir)) {
91
94
  if (name.startsWith('.') || name === 'node_modules') continue;
92
95
  const colDir = join(dir, name);
93
96
  if (!statSync(colDir).isDirectory()) continue;
94
97
  if (!/^[a-z][a-z0-9_]*$/.test(name) || name === 'menus') continue;
98
+ if (known && known.size && !known.has(name)) continue;
95
99
  if (only && !only.includes(name)) continue;
96
100
  const sidecarExts = Object.keys(SIDECARS[name] ?? SIDECARS.default);
97
101
  for (const file of readdirSync(colDir)) {
@@ -369,7 +373,9 @@ async function push(ctx) {
369
373
  else if (menus.length) await pullMenus(ctx);
370
374
  }
371
375
 
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' };
376
+ 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' };
377
+ /** Pictures travel up to 15 MB; a video (a row's media list) up to 40 MB. */
378
+ const mediaCap = (type) => (type.startsWith('video/') ? 40 : 15);
373
379
  /** Every { "$media": { "file": ... } } in the document becomes { "$media": { "data", "type", "filename", "alt" } }, read from the folder. */
374
380
  function inlineMediaFiles(doc, dir) {
375
381
  const walk = (v) => {
@@ -381,9 +387,10 @@ function inlineMediaFiles(doc, dir) {
381
387
  if (!path.startsWith(resolve(dir))) fail(`$media.file ${m.file}: outside the folder`);
382
388
  if (!existsSync(path)) fail(`$media.file ${m.file}: no such file`);
383
389
  const bytes = readFileSync(path);
384
- if (bytes.length > 15 * 1024 * 1024) fail(`$media.file ${m.file}: ${(bytes.length / 1048576).toFixed(1)} MB; pictures up to 15 MB`);
385
390
  const ext = m.file.split('.').pop().toLowerCase();
386
- return { $media: { data: bytes.toString('base64'), type: MEDIA_TYPES[ext] ?? 'application/octet-stream', filename: m.file.split('/').pop(), alt: typeof m.alt === 'string' ? m.alt : '' } };
391
+ const type = MEDIA_TYPES[ext] ?? 'application/octet-stream';
392
+ 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'}`);
393
+ return { $media: { data: bytes.toString('base64'), type, filename: m.file.split('/').pop(), alt: typeof m.alt === 'string' ? m.alt : '' } };
387
394
  }
388
395
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
389
396
  }
@@ -675,7 +682,15 @@ async function main() {
675
682
  writeFileSync(join(dir, META, 'key'), `${key}\n`, { mode: 0o600 });
676
683
  writeFileSync(join(dir, META, '.gitignore'), 'key\nshots/\nsource/\npages/\n');
677
684
  say(`Connected ${relative(process.cwd(), dir) || '.'} to ${site} on ${host}.`);
678
- await pull(config());
685
+ // A website that is still being set up answers 404 on its content model for a few minutes; the first pull waits for it.
686
+ for (let attempt = 1; ; attempt++) {
687
+ try { await pull(config()); break; }
688
+ catch (e) {
689
+ if (e?.code !== 'website_unreachable' || attempt >= 20) throw e;
690
+ say(`The website is still being set up (${e.message.slice(0, 120)}); trying again in 30 seconds (${attempt}/20).`);
691
+ await new Promise((r) => setTimeout(r, 30_000));
692
+ }
693
+ }
679
694
  return;
680
695
  }
681
696
  case 'pull':
package/lib/fetch.mjs CHANGED
@@ -53,7 +53,7 @@ function abs(base, href) {
53
53
 
54
54
  /** What the page is made of, in order. */
55
55
  export function outline(html, base) {
56
- 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: [] };
57
57
  const t = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
58
58
  if (t) out.title = text(t[1]);
59
59
  const d = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i);
@@ -95,6 +95,11 @@ export function outline(html, base) {
95
95
  const height = tag.match(/\sheight=["']?(\d+)/i)?.[1];
96
96
  out.images.push({ src: abs(base, src), alt, ...(width ? { width: Number(width) } : {}), ...(height ? { height: Number(height) } : {}) });
97
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
+ }
98
103
  const body = text(html.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ''));
99
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);
100
105
  out.emails = [...new Set([...body.matchAll(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi)].map((m) => m[0]))].slice(0, 10);
@@ -165,6 +170,10 @@ export function outlineText(o) {
165
170
  lines.push('', `Images (${o.images.length}):`);
166
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}` : ''}`);
167
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
+ }
168
177
  if (o.frames?.length) {
169
178
  lines.push('', `Embedded frames (${o.frames.length}): a band of the page each; rebuild as the frame section or a row's <iframe>`);
170
179
  for (const f of o.frames) lines.push(` ${f.src}${f.title ? ` title: ${f.title}` : ''}`);
package/lib/validate.mjs CHANGED
@@ -53,7 +53,10 @@ function checkScalar(type, value, at, errors) {
53
53
  case 'media':
54
54
  case 'file':
55
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') { if (!/\.(png|jpe?g|gif|webp|avif|svg|ico)$/i.test(value.$media.file) || value.$media.file.startsWith('/') || value.$media.file.includes('..')) errors.push(`${at}: $media.file must be a picture file inside the folder (png, jpg, gif, webp, avif, svg, ico)`); }
56
+ else if (value.$media && typeof value.$media.file === 'string') {
57
+ const ok = 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 (${type === 'file' ? 'a picture, a video (mp4, webm), a font (woff2) or a pdf' : 'a picture: png, jpg, gif, webp, avif, svg, ico'})`);
59
+ }
57
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)`);
58
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)`);
59
62
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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": {