@tideworthy/tidysites 0.1.12 → 0.1.14

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
@@ -30,7 +30,7 @@ nothing else; it lives in `.tidysites/key`, which is kept out of git.
30
30
  | `tidysites page <path> [--html]` | the rendered page, section by section, with the key each section carries |
31
31
  | `tidysites schema [type or collection]` | the section types and layouts, or one type's or collection's fields |
32
32
  | `tidysites gallery [type]` | a screenshot of every section type and layout in this website's theme, or the first screen of one type |
33
- | `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 |
33
+ | `tidysites fetch <url> [--out dir]` | a page of the site being ported: cleaned HTML, its outline (headings, navigation, images, phones, fonts, an Adobe Fonts kit and the families it serves, the stylesheet's own variables and colours) and its stylesheets, inline style blocks included |
34
34
  | `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 |
35
35
  | `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 |
36
36
 
@@ -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) 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,29 @@ 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' };
373
+ /** Every { "$media": { "file": ... } } in the document becomes { "$media": { "data", "type", "filename", "alt" } }, read from the folder. */
374
+ function inlineMediaFiles(doc, dir) {
375
+ const walk = (v) => {
376
+ if (Array.isArray(v)) return v.map(walk);
377
+ if (v && typeof v === 'object') {
378
+ const m = v.$media;
379
+ if (m && typeof m === 'object' && typeof m.file === 'string') {
380
+ const path = resolve(dir, m.file);
381
+ if (!path.startsWith(resolve(dir))) fail(`$media.file ${m.file}: outside the folder`);
382
+ if (!existsSync(path)) fail(`$media.file ${m.file}: no such file`);
383
+ 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
+ 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 : '' } };
387
+ }
388
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
389
+ }
390
+ return v;
391
+ };
392
+ if (doc.content) doc.content = walk(doc.content);
393
+ }
394
+
369
395
  async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
370
396
  const isUrl = /^https?:\/\//.test(target);
371
397
  // --desktop, --mobile, --tablet, --wide, any of them together; --all for every width; none = desktop and mobile.
@@ -569,6 +595,8 @@ async function fetchCmd(ctx) {
569
595
  const css = await r.text();
570
596
  cssTexts.push(css);
571
597
  writeFileSync(join(outDir, `${base}-${cssTexts.length}.css`), css);
598
+ // The kit's CSS names the families it serves, which the page's HTML does not.
599
+ if (/use\.typekit\.net/i.test(href)) o.adobe_families = [...new Set([...o.adobe_families ?? [], ...[...css.matchAll(/font-family\s*:\s*["']([^"';]+)["']/gi)].map((m) => m[1])])];
572
600
  }
573
601
  } catch {
574
602
  /* a stylesheet that will not download is not the port's problem */
@@ -631,7 +659,7 @@ async function publish(ctx) {
631
659
 
632
660
  function help() {
633
661
  say(readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(2, 20).map((l) => l.replace(/^ \* ?/, '')).join('\n'));
634
- 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.\nReferences: a record names another by its id, or "$ref:<slug-or-id>" for one in the same push.`);
662
+ 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.`);
635
663
  }
636
664
 
637
665
  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|iframe)\b[\s\S]*?<\/\1>/gi, '')
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, '')
@@ -105,6 +107,10 @@ export function outline(html, base) {
105
107
  out.fonts.push(...fams);
106
108
  }
107
109
  out.fonts = [...new Set(out.fonts)];
110
+ // 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.
111
+ out.adobe_kits = [...new Set([...html.matchAll(/use\.typekit\.net\/([a-z0-9]+)\.css/gi)].map((m) => m[1].toLowerCase()))];
112
+ 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);
113
+ out.adobe_families = [];
108
114
  return out;
109
115
  }
110
116
 
@@ -159,9 +165,17 @@ export function outlineText(o) {
159
165
  lines.push('', `Images (${o.images.length}):`);
160
166
  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}` : ''}`);
161
167
  }
168
+ if (o.frames?.length) {
169
+ lines.push('', `Embedded frames (${o.frames.length}): a band of the page each; rebuild as the frame section or a row's <iframe>`);
170
+ for (const f of o.frames) lines.push(` ${f.src}${f.title ? ` title: ${f.title}` : ''}`);
171
+ }
162
172
  if (o.phones.length) lines.push('', `Phones: ${o.phones.join(', ')}`);
163
173
  if (o.emails.length) lines.push(`Emails: ${o.emails.join(', ')}`);
164
174
  if (o.fonts.length) lines.push(`Google fonts: ${o.fonts.join(', ')}`);
175
+ if (o.adobe_kits?.length) {
176
+ lines.push(`Adobe Fonts kit: ${o.adobe_kits.join(', ')} (the old site's own kit; this site cannot load it)${o.adobe_families?.length ? ` serving ${o.adobe_families.join(', ')}` : ''}.`);
177
+ lines.push(' Each family from it needs a Google Fonts stand-in on the Business record that matches its shape and weights as closely as possible, and the report must name the original and the stand-in. A font is never left unset.');
178
+ }
165
179
  if (o.css) {
166
180
  const vars = Object.entries(o.css.variables ?? {});
167
181
  if (vars.length) { lines.push('', 'Stylesheet variables (the site\'s own names; trust these over the counts below):'); for (const [k, v] of vars.slice(0, 40)) lines.push(` --${k}: ${v}`); }
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] !== null && was.data[k] !== '') data[k] = null;
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,9 @@ 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 && (typeof value.$media.url !== 'string' || !/^https?:\/\//.test(value.$media.url))) errors.push(`${at}: $media.url must be an http(s) address`);
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)`); }
57
+ 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
58
  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
59
  return;
59
60
  case 'json':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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": {