@tideworthy/tidysites 0.1.1 → 0.1.3

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
@@ -18,11 +18,11 @@ nothing else; it lives in `.tidysites/key`, which is kept out of git.
18
18
  | | |
19
19
  |---|---|
20
20
  | `tidysites init <site_id> --host <url> --key <key>` | connect this folder to a website and pull it |
21
- | `tidysites pull [--collections pages,services]` | the website's content to files; files for entries the website no longer has are removed |
21
+ | `tidysites pull [--collections pages,services,menus]` | the website's content to files; files for entries the website no longer has are removed |
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] [--desktop] [--first-screen] [--live]` | a screenshot of a page of the preview site, the live site, or any address, saved under `.tidysites/shots/` |
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 |
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 |
@@ -30,6 +30,10 @@ nothing else; it lives in `.tidysites/key`, which is kept out of git.
30
30
  | `tidysites fetch <url> [--out dir]` | a page of the site being ported: cleaned HTML, its outline (headings, navigation, images, phones, fonts, colours) and its stylesheets |
31
31
  | `tidysites publish --yes` | the live site becomes a copy of the preview site |
32
32
 
33
+ A section keeps its type with its key: to replace an intro with a row, give the row its own `_key`
34
+ (`validate` says so; the website would only say "internal error"). After a push the pushed files take the
35
+ website's version (a `$media` becomes the stored image, `_version` appears); every other file stays as it is.
36
+
33
37
  A push that would overwrite an entry changed on the website since the pull
34
38
  is refused with the list; pull again (your files are overwritten, so copy
35
39
  what you need first) or push `--force`.
@@ -78,4 +82,4 @@ names its own in `header_row`.
78
82
  3. `frame/frame.json` for the top bar, the call bar, the footer headings, the wording; `menus/primary.json` and `menus/footer.json` for the menus (an item with `children` is a dropdown).
79
83
  4. Pages as sections: `pages/<slug>.json`, each a list in `data.sections`. Columns is the section for a band with no record behind it.
80
84
  5. The look: brand colours and fonts on the Business record, `business/business.css` for what the tokens cannot say. The theme's own rules are written as `.ts.ts-accent` and `.ts.ts-ink .ts-prose a`, so a rule of yours needs the same weight (`.ts.ts-accent …`) to win.
81
- 6. `tidysites validate`, `tidysites push`, `tidysites shot /` and `tidysites shot / --mobile`, and look.
85
+ 6. `tidysites validate`, `tidysites push`, `tidysites shot / --all`, and look at every width: a design often shows a different header above 1440px.
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|--desktop] [--first-screen] screenshot to .tidysites/shots
10
+ * shot <path|url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--first-screen] screenshot to .tidysites/shots (none = desktop and mobile)
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
@@ -165,8 +165,15 @@ function readLocalMenus(dir) {
165
165
  return out;
166
166
  }
167
167
 
168
- async function pull({ dir, cfg }, collections = null) {
169
- const want = collections ?? (flags.collections ? String(flags.collections).split(',').map((s) => s.trim()).filter(Boolean) : null);
168
+ async function pull({ dir, cfg }, collections = null, { overwrite = new Set(), menus: wantMenus = null } = {}) {
169
+ let want = collections ?? (flags.collections ? String(flags.collections).split(',').map((s) => s.trim()).filter(Boolean) : null);
170
+ // `menus` is not a collection on the website; it is pulled through the site's own menu tools.
171
+ let refreshMenus = wantMenus ?? (want === null || want.includes('menus'));
172
+ if (want) want = want.filter((c) => c !== 'menus');
173
+ if (want && !want.length) {
174
+ await pullMenus({ dir, cfg });
175
+ return;
176
+ }
170
177
  const [{ json: doc }, { json: schema }] = await Promise.all([
171
178
  tool(cfg, 'pull_website', { site_id: cfg.site, ...(want ? { collections: want.join(',') } : {}) }),
172
179
  tool(cfg, 'get_website_schema', { site_id: cfg.site })
@@ -184,7 +191,9 @@ async function pull({ dir, cfg }, collections = null) {
184
191
  const key = `${collection}/${e.slug ?? e.id}`;
185
192
  const mine = local[key];
186
193
  const was = prev?.entries?.[key];
187
- if (mine && was && !sameEntry(mine, was) && !sameEntry(mine, { status: e.status, data: e.data })) {
194
+ // An entry this push just wrote is the website's now: its file takes the website's version ($media
195
+ // becomes the stored image, _version appears), whatever the file said a moment ago.
196
+ if (mine && was && !overwrite.has(key) && !sameEntry(mine, was) && !sameEntry(mine, { status: e.status, data: e.data })) {
188
197
  kept.push(key);
189
198
  continue;
190
199
  }
@@ -205,14 +214,14 @@ async function pull({ dir, cfg }, collections = null) {
205
214
  }
206
215
  writeFiles(dir, files);
207
216
  const snap = snapshotOf(doc);
208
- if (!want) {
217
+ if (refreshMenus) {
209
218
  const menus = await readMenus(cfg);
210
219
  snap.menus = menus;
211
220
  const localMenus = readLocalMenus(dir);
212
221
  for (const [name, menu] of Object.entries(menus)) {
213
222
  const mine = localMenus[name];
214
223
  const wasMenu = prev?.menus?.[name];
215
- if (mine && !mine.error && wasMenu && JSON.stringify(mine.items) !== JSON.stringify(wasMenu.items) && JSON.stringify(mine.items) !== JSON.stringify(menu.items)) {
224
+ if (mine && !mine.error && wasMenu && !overwrite.has(`menus/${name}`) && JSON.stringify(mine.items) !== JSON.stringify(wasMenu.items) && JSON.stringify(mine.items) !== JSON.stringify(menu.items)) {
216
225
  kept.push(`menus/${name}`);
217
226
  continue;
218
227
  }
@@ -234,6 +243,17 @@ async function pull({ dir, cfg }, collections = null) {
234
243
  for (const k of kept) say(` kept your unpushed changes in ${k}`);
235
244
  }
236
245
 
246
+ /** The menus alone: files and the snapshot's menus, the entries untouched. */
247
+ async function pullMenus({ dir, cfg }) {
248
+ const snap = readJson(join(dir, META, 'snapshot.json'));
249
+ if (!snap) fail('Nothing pulled yet. Run `tidysites pull`.');
250
+ const menus = await readMenus(cfg);
251
+ for (const [name, menu] of Object.entries(menus)) writeFiles(dir, { [`menus/${name}.json`]: `${JSON.stringify({ label: menu.label, items: menu.items }, null, '\t')}\n` });
252
+ snap.menus = menus;
253
+ writeFileSync(join(dir, META, 'snapshot.json'), JSON.stringify(snap, null, '\t'));
254
+ say(`Pulled ${Object.keys(menus).length} menu${Object.keys(menus).length === 1 ? '' : 's'}.`);
255
+ }
256
+
237
257
  function status({ dir }) {
238
258
  const snap = readJson(join(dir, META, 'snapshot.json'));
239
259
  if (!snap) fail('Nothing pulled yet. Run `tidysites pull`.');
@@ -264,6 +284,15 @@ function validate({ dir }, local = null) {
264
284
  if (!schema) fail('No content model here yet. Run `tidysites pull`.');
265
285
  const read = local ? { local, problems: [] } : readLocal(dir);
266
286
  const errors = [...read.problems, ...validateAll(read.local, schema)];
287
+ // The website keeps a section's type with its key: a key that changes type is refused with a bare "internal error", so say it here.
288
+ const snap = readJson(join(dir, META, 'snapshot.json'));
289
+ for (const [key, entry] of Object.entries(read.local)) {
290
+ const before = snap?.entries?.[key]?.data?.sections;
291
+ const after = entry.data?.sections;
292
+ if (!Array.isArray(before) || !Array.isArray(after)) continue;
293
+ const types = new Map(before.filter((b) => b && b._key).map((b) => [b._key, b._type]));
294
+ for (const b of after) if (b && b._key && types.has(b._key) && types.get(b._key) !== b._type) errors.push(`${key}.json: section "${b._key}" was a ${types.get(b._key)} and is now a ${b._type}; a section keeps its type, so give the new one its own _key`);
295
+ }
267
296
  return errors;
268
297
  }
269
298
 
@@ -320,13 +349,17 @@ async function push(ctx) {
320
349
  await tool(ctx.cfg, 'call_website_site_tool', { site_id: ctx.cfg.site, tool: 'menu_set_items', arguments: { name, items } });
321
350
  say(`Menu ${name}: ${items.length} item${items.length === 1 ? '' : 's'} written.`);
322
351
  }
323
- // Refresh only what went: a partial push leaves the other collections' files exactly as they are.
324
- await pull(ctx, menus.length && !touched.length ? null : touched.length ? touched : null);
352
+ // Refresh only what went, and take the website's version of exactly those entries; every other file stays as it is.
353
+ const pushed = new Set([...keys, ...menus.map((m) => `menus/${m}`)]);
354
+ if (touched.length) await pull(ctx, touched, { overwrite: pushed, menus: menus.length > 0 });
355
+ else if (menus.length) await pullMenus(ctx);
325
356
  }
326
357
 
327
358
  async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
328
359
  const isUrl = /^https?:\/\//.test(target);
329
- const view = flags.mobile ? 'mobile' : flags.desktop ? 'desktop' : (opts.view ?? 'both');
360
+ // --desktop, --mobile, --tablet, --wide, any of them together; --all for every width; none = desktop and mobile.
361
+ const picked = ['desktop', 'mobile', 'tablet', 'wide'].filter((v) => flags[v]);
362
+ const view = flags.all ? 'all' : picked.length ? picked.join(',') : (opts.view ?? 'both');
330
363
  const args = { site_id: ctx.cfg.site, view, full_page: !(flags['first-screen'] || opts.firstScreen), ...(isUrl ? { url: target } : { path: target, site: flags.live ? 'live' : 'preview' }) };
331
364
  const { json, images } = await tool(ctx.cfg, 'screenshot_website', args);
332
365
  const outDir = join(ctx.dir, META, 'shots');
@@ -335,7 +368,7 @@ async function shot(ctx, target = positional[0] ?? '/', opts = {}) {
335
368
  const base = (opts.name ?? (isUrl ? new URL(target).hostname + new URL(target).pathname : target)).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home';
336
369
  const named = Array.isArray(json?.captures) ? json.captures : [];
337
370
  images.forEach((img, i) => {
338
- const v = named[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view);
371
+ const v = named[i]?.view ?? (view === 'both' ? ['desktop', 'mobile'][i] : view === 'all' ? ['desktop', 'mobile', 'tablet', 'wide'][i] : view.split(',')[i] ?? view);
339
372
  const p = join(outDir, `${base}-${v}-${stamp}.${img.mimeType.includes('png') ? 'png' : 'jpg'}`);
340
373
  writeFileSync(p, Buffer.from(img.base64, 'base64'));
341
374
  say(relative(process.cwd(), p));
package/lib/fetch.mjs CHANGED
@@ -108,8 +108,14 @@ export function cssSummary(css) {
108
108
  const f = m[1].split(',')[0].replace(/["']/g, '').trim();
109
109
  if (f && !/^(inherit|initial|unset|var\()/.test(f)) fonts.set(f, (fonts.get(f) ?? 0) + 1);
110
110
  }
111
+ // The site's own names for its colours and fonts, when its stylesheet defines them; a framework's defaults sit below these.
112
+ const variables = {};
113
+ for (const m of css.matchAll(/--([a-zA-Z][\w-]*)\s*:\s*([^;}]+)/g)) {
114
+ const v = m[2].trim();
115
+ if (/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|font|["'][A-Z][\w ]+["']/i.test(v) && !/^var\(/.test(v)) variables[m[1]] = v;
116
+ }
111
117
  const top = (map, n) => [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
112
- return { colors: top(colors, 12), fonts: top(fonts, 6) };
118
+ return { colors: top(colors, 12), fonts: top(fonts, 6), variables };
113
119
  }
114
120
 
115
121
  export function outlineText(o) {
@@ -133,6 +139,8 @@ export function outlineText(o) {
133
139
  if (o.emails.length) lines.push(`Emails: ${o.emails.join(', ')}`);
134
140
  if (o.fonts.length) lines.push(`Google fonts: ${o.fonts.join(', ')}`);
135
141
  if (o.css) {
142
+ const vars = Object.entries(o.css.variables ?? {});
143
+ 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}`); }
136
144
  lines.push('', 'Stylesheet colours (most used first):', ` ${o.css.colors.map((c) => `${c.value} ×${c.count}`).join(' ')}`);
137
145
  lines.push('Stylesheet fonts:', ` ${o.css.fonts.map((c) => `${c.value} ×${c.count}`).join(' ')}`);
138
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {