@tideworthy/tidysites 0.1.0 → 0.1.2

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,7 +18,7 @@ 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 |
@@ -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`.
@@ -62,6 +66,15 @@ References: a record names another by its id (what the pull shows), or by
62
66
 
63
67
  Rich text (a section's `copy`, a post's `content`) is markdown.
64
68
 
69
+ Custom HTML (a row's `.html` file, a Columns HTML slot) reads the records:
70
+ `{{ business.phone }}`, `{{ page.title }}`, `{{#each services as s}}…{{ s.url }}…{{/each}}`,
71
+ `{{#if page.collection == "services"}}…{{/if}}`, and the drop-ins `{{> menu "primary"}}`,
72
+ `{{> logo}}`, `{{> phone}}`, `{{> address}}`, `{{> hours}}`, `{{> social}}`, `{{> year}}`,
73
+ `{{> button "Label" "/url" style=outline}}`, `{{> form "Name"}}`. A link to `#form:<Name>`
74
+ opens that form in a dialog. So a custom header is written once against `page.*` and
75
+ `business.*` and fits every page; `frame/frame.json` names it as `header_row`, or a page
76
+ names its own in `header_row`.
77
+
65
78
  ## Building a site from these files
66
79
 
67
80
  1. `tidysites schema` for the section types, `tidysites gallery` to see them in this theme.
package/bin/tidysites.mjs CHANGED
@@ -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,8 +349,10 @@ 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 = {}) {
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.0",
3
+ "version": "0.1.2",
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": {