hubspot-cms-sync 0.1.0

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/bin/hubspot-cms-sync.mjs +115 -0
  4. package/docs/CONFIGURATION.md +83 -0
  5. package/docs/GITHUB_ACTIONS.md +70 -0
  6. package/docs/MIGRATION_PLAN.md +361 -0
  7. package/docs/PLAN_REVIEW.md +42 -0
  8. package/docs/SKILL_DISTRIBUTION.md +79 -0
  9. package/examples/github-actions/ci.yml +56 -0
  10. package/examples/github-actions/preview.yml +71 -0
  11. package/examples/github-actions/publish.yml +82 -0
  12. package/examples/hubspot-cms-sync.config.mjs +45 -0
  13. package/examples/site.manifest.json +19 -0
  14. package/package.json +41 -0
  15. package/skill/SKILL.md +54 -0
  16. package/skill/references/commands.md +54 -0
  17. package/skill/references/config.md +25 -0
  18. package/skill/references/failures.md +58 -0
  19. package/skill/references/github-actions.md +56 -0
  20. package/skill/references/screenshots-and-fidelity.md +33 -0
  21. package/src/adapters/assets.mjs +576 -0
  22. package/src/adapters/blog.mjs +921 -0
  23. package/src/adapters/content.mjs +213 -0
  24. package/src/adapters/forms.mjs +569 -0
  25. package/src/adapters/pages.mjs +463 -0
  26. package/src/adapters/theme.mjs +503 -0
  27. package/src/config.mjs +113 -0
  28. package/src/corpus-scan.mjs +248 -0
  29. package/src/cta-inventory.mjs +352 -0
  30. package/src/index.mjs +3 -0
  31. package/src/lib/canonical.mjs +234 -0
  32. package/src/lib/hub.mjs +197 -0
  33. package/src/lib/orchestrate.mjs +141 -0
  34. package/src/lib/refs.mjs +398 -0
  35. package/src/lib/sync-state.mjs +86 -0
  36. package/src/manifest.mjs +353 -0
  37. package/src/preflight.mjs +385 -0
  38. package/src/pull.mjs +99 -0
  39. package/src/push.mjs +354 -0
  40. package/src/republish.mjs +102 -0
@@ -0,0 +1,463 @@
1
+ // sync/adapters/pages.mjs — page DEFINITION adapter (slug, templatePath, name,
2
+ // htmlTitle, metaDescription, language, state) for the bidirectional sync.
3
+ //
4
+ // This adapter owns the page OBJECT/SEO definition, NOT the per-page module
5
+ // content (widgets/widgetContainers/layoutSections — that is the widgets
6
+ // adapter's job, codex §"Page MODULE CONTENT"). It deliberately projects only
7
+ // the portable definition fields via canonicalPage() and never commits a
8
+ // per-account id, url, domain host, currentState, publishDate, or AB metadata.
9
+ //
10
+ // PULL (acct -> canonical files):
11
+ // GET /cms/v3/pages/site-pages -> drop AB-variants/archived/temp junk
12
+ // (codex #9) -> keep ONLY pages the manifest lists (site.manifest.json is the
13
+ // single source of truth for what is push-able) -> canonicalPage() to project
14
+ // the definition -> canonicalize() the serialized JSON so embedded per-account
15
+ // refs (form/cta/menu guids, hubfs urls, portal ids) become @logical tokens
16
+ // (registering them into THIS account's registry) -> write
17
+ // content/pages/<slugToFile>.json carrying a `desiredState` field taken from
18
+ // the manifest (publish|draft|archive|ignore). Never infer publishability from
19
+ // files present on disk.
20
+ //
21
+ // PUSH (canonical files -> acct, idempotent by SLUG):
22
+ // For each page file: resolve() its @logical refs to the TARGET account's ids
23
+ // (hard-fails if any ref is unmapped — push must not proceed); resolvePageBySlug
24
+ // to decide create-vs-update; POST /cms/v3/pages/site-pages to CREATE or PATCH
25
+ // /cms/v3/pages/site-pages/{id}/draft to UPDATE with the definition fields
26
+ // (templatePath, name, htmlTitle, metaDescription, slug, language). Then PUBLISH
27
+ // the ones whose desiredState==='publish' via the schedule endpoint (the
28
+ // push-live-no-ops-on-first-publish workaround, reused from sync/republish.mjs).
29
+ // 'archive'/'ignore'/'draft' pages are written as drafts and NOT scheduled.
30
+ //
31
+ // PRODUCTION (529456) is never targeted here — the orchestrator passes `acct`;
32
+ // this adapter resolves nothing against a hardcoded portal.
33
+
34
+ import { readFile, readdir } from 'node:fs/promises';
35
+ import { existsSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+
38
+ import { hub, getAll, matchPageSlug } from '../lib/hub.mjs';
39
+ import { canonicalPage, stableStringify, slugToFile, fileToSlug } from '../lib/canonical.mjs';
40
+ import { canonicalize, resolve } from '../lib/refs.mjs';
41
+
42
+ export const name = 'pages';
43
+
44
+ // Pages depend on forms/ctas/menus/assets existing in the registry so resolve()
45
+ // can map @logical refs embedded in the definition (featuredImage hubfs urls,
46
+ // headHtml/footerHtml ctas/forms, portal ids) to target ids at PUSH time.
47
+ export const dependsOn = ['forms', 'assets'];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Junk filters (codex #9). These are DEFENSE IN DEPTH on top of the manifest:
51
+ // the manifest is the authoritative push list, but pull also hard-excludes
52
+ // records HubSpot marks as AB variants / archived / temp so they can never be
53
+ // minted into the canonical tree even if a manifest entry were stale.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const AB_STATES = new Set(['LOSER_AB_VARIANT', 'DRAFT_AB']);
57
+
58
+ /**
59
+ * isABVariant(page) -> boolean
60
+ * True for any A/B test variant: HubSpot tags them via currentState/state
61
+ * (LOSER_AB_VARIANT / DRAFT_AB) or carries an abTestId / abStatus. These are
62
+ * not portable page DEFINITIONS and must never enter the canonical tree.
63
+ */
64
+ export function isABVariant(page) {
65
+ if (!page || typeof page !== 'object') return false;
66
+ if (AB_STATES.has(page.currentState) || AB_STATES.has(page.state)) return true;
67
+ if (page.abTestId != null && String(page.abTestId) !== '') return true;
68
+ if (page.abStatus != null && String(page.abStatus) !== '' && page.abStatus !== 'master') {
69
+ // abStatus is set on variants ('loser_variant', etc.); a stand-alone page
70
+ // has no abStatus. ('master' would be the surviving page — but we exclude
71
+ // the whole AB apparatus from portable definitions, so treat any non-empty
72
+ // non-master abStatus as a variant.)
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * isArchived(page) -> boolean
80
+ * Archived/deleted pages (archivedInDashboard, or an archivedAt that is not the
81
+ * 1970 epoch sentinel HubSpot uses for "never archived").
82
+ */
83
+ export function isArchived(page) {
84
+ if (!page || typeof page !== 'object') return false;
85
+ if (page.archivedInDashboard === true) return true;
86
+ const at = page.archivedAt;
87
+ if (typeof at === 'string' && at && !at.startsWith('1970-01-01')) return true;
88
+ return false;
89
+ }
90
+
91
+ // HubSpot's auto-generated throwaway slugs for unsaved/temp pages.
92
+ const TEMP_SLUG_RE = /^-?temporary-slug/i;
93
+ // A slug that is just a leading-dash + bare guid (HubSpot's unnamed-page slug).
94
+ const GUID_SLUG_RE = /^-?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
95
+ // A slug that contains an explicit "-ab-variant-" / "-archived" / "-old" marker.
96
+ const JUNK_SLUG_RE = /-ab-variant-|(?:^|[/-])archived(?:-\d+)?$|(?:^|[/-])old\d*$/i;
97
+
98
+ /**
99
+ * isTempSlug(slug) -> boolean
100
+ * Throwaway / non-portable slugs that should never be committed.
101
+ */
102
+ export function isTempSlug(slug) {
103
+ const s = slug == null ? '' : String(slug);
104
+ if (s === '') return false; // '' is the homepage — explicitly NOT junk
105
+ return TEMP_SLUG_RE.test(s) || GUID_SLUG_RE.test(s) || JUNK_SLUG_RE.test(s);
106
+ }
107
+
108
+ /**
109
+ * isPortablePage(page) -> boolean
110
+ * The hard junk filter applied on pull regardless of the manifest.
111
+ */
112
+ export function isPortablePage(page) {
113
+ return !isABVariant(page) && !isArchived(page) && !isTempSlug(page?.slug);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Manifest. site.manifest.json (repo root) is the ONLY push list (codex #9).
118
+ // Shape used by this adapter:
119
+ // { "pages": [ { "slug": "", "desiredState": "publish" }, ... ] }
120
+ // `desiredState` ∈ publish|draft|archive|ignore. A page absent from the manifest
121
+ // is NOT pulled and NOT pushed. An 'ignore' entry is pulled (for reviewability)
122
+ // but never pushed/published.
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const VALID_DESIRED_STATES = new Set(['publish', 'draft', 'archive', 'ignore']);
126
+
127
+ /**
128
+ * loadManifestPages(contentDir) -> Map<slug, { slug, desiredState }>
129
+ * Reads <repoRoot>/site.manifest.json. contentDir is .../content; the manifest
130
+ * lives one level up at the repo root. Returns an empty map (not an error) when
131
+ * the manifest is absent so a first-ever pull can be bootstrapped, but push
132
+ * requires it (see push()).
133
+ */
134
+ export async function loadManifestPages(contentDir) {
135
+ const manifestPath = manifestPathFor(contentDir);
136
+ if (!existsSync(manifestPath)) return new Map();
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
140
+ } catch (e) {
141
+ throw new Error(`Invalid JSON in ${manifestPath}: ${e.message}`);
142
+ }
143
+ const map = new Map();
144
+ for (const entry of parsed.pages || []) {
145
+ if (!entry || typeof entry !== 'object') continue;
146
+ const slug = entry.slug == null ? '' : String(entry.slug);
147
+ const desiredState = entry.desiredState || 'publish';
148
+ if (!VALID_DESIRED_STATES.has(desiredState)) {
149
+ throw new Error(
150
+ `site.manifest.json: page "${slug || '(home)'}" has invalid desiredState ` +
151
+ `"${desiredState}" (expected ${[...VALID_DESIRED_STATES].join('|')})`,
152
+ );
153
+ }
154
+ map.set(slug, { slug, desiredState });
155
+ }
156
+ return map;
157
+ }
158
+
159
+ // contentDir is <repoRoot>/content; the manifest is at <repoRoot>/site.manifest.json.
160
+ function manifestPathFor(contentDir) {
161
+ return join(contentDir, '..', 'site.manifest.json');
162
+ }
163
+
164
+ function pagesDir(contentDir) {
165
+ return join(contentDir, 'pages');
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // PULL
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * pull(acct, { contentDir, registry, writeFile, getAll: getAllOverride })
174
+ * -> { pulled, notes }
175
+ *
176
+ * Tests inject a stubbed `getAll` and `writeFile` so no real network/disk is
177
+ * touched. In production the orchestrator passes the real fs writer.
178
+ */
179
+ export async function pull(acct, ctx) {
180
+ const { contentDir, registry } = ctx;
181
+ const getAllFn = ctx.getAll || getAll;
182
+ const writeFileFn = ctx.writeFile || defaultWriteFile;
183
+
184
+ const manifest = await loadManifestPages(contentDir);
185
+ const rawPages = await getAllFn(acct, '/cms/v3/pages/site-pages');
186
+
187
+ const notes = [];
188
+ let pulled = 0;
189
+
190
+ for (const raw of rawPages) {
191
+ // 1. Hard junk filter (AB/archived/temp) regardless of manifest.
192
+ if (!isPortablePage(raw)) continue;
193
+
194
+ const slug = raw.slug == null ? '' : String(raw.slug);
195
+
196
+ // 2. Manifest is the source of truth for what we keep. If a manifest exists,
197
+ // only pull pages it lists. (Empty manifest -> bootstrap: keep all
198
+ // portable pages so a first pull can seed site.manifest.json by hand.)
199
+ let desiredState = 'publish';
200
+ if (manifest.size > 0) {
201
+ const entry = manifest.get(slug);
202
+ if (!entry) {
203
+ notes.push(`skip (not in manifest): ${slug || '(home)'}`);
204
+ continue;
205
+ }
206
+ desiredState = entry.desiredState;
207
+ }
208
+
209
+ // 3. Project the portable definition (drops id/url/currentState/publishDate/
210
+ // domain-host/AB metadata; keeps slug/name/htmlTitle/metaDescription/
211
+ // language/templatePath/widgets).
212
+ const def = canonicalPage(raw);
213
+
214
+ // 4. Logical-ize embedded per-account refs (featuredImage hubfs urls inside
215
+ // widgets, cta/form guids in head/footer html, portal ids). Serialize,
216
+ // run through canonicalize() against THIS account's registry (which
217
+ // registers any new ids), then re-parse so the committed file is clean
218
+ // structured JSON, not a string.
219
+ const logicalized = JSON.parse(canonicalize(stableStringify(def), registry));
220
+
221
+ // 5. Attach the manifest-driven desiredState (codex #9: never inferred).
222
+ logicalized.desiredState = desiredState;
223
+
224
+ const file = join(pagesDir(contentDir), `${slugToFile(slug)}.json`);
225
+ await writeFileFn(file, stableStringify(logicalized));
226
+ pulled += 1;
227
+ }
228
+
229
+ return { pulled, notes };
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // PUSH
234
+ // ---------------------------------------------------------------------------
235
+
236
+ // The schedule publish workaround (sync/republish.mjs pattern): push-live
237
+ // no-ops on first publish, so we POST a near-future publishDate to the schedule
238
+ // endpoint. publishDate MUST be future and the title MUST be non-empty.
239
+ //
240
+ // codex #11 — per-item scheduling. The lead is computed FRESH for each item,
241
+ // immediately before that item's schedule call, off a `nowFn`. A single batch
242
+ // timestamp would be computed once at the start of the loop and could slip into
243
+ // the PAST for later items in a large/slow batch (HubSpot rejects a non-future
244
+ // publishDate), silently leaving a draft unpublished. A fresh date per item
245
+ // keeps every schedule request safely in the future.
246
+ const PUBLISH_LEAD_MS = 90_000;
247
+
248
+ function futurePublishDate(nowMs = Date.now()) {
249
+ return new Date(nowMs + PUBLISH_LEAD_MS).toISOString().replace(/\.\d+Z$/, '.000Z');
250
+ }
251
+
252
+ // The definition fields we create/update with. Intentionally a small allow-list
253
+ // — this adapter does NOT push widgets/layoutSections (widgets adapter owns
254
+ // those) nor any per-account/volatile field.
255
+ const PUSH_FIELDS = ['templatePath', 'name', 'htmlTitle', 'metaDescription', 'slug', 'language'];
256
+
257
+ /**
258
+ * buildPagePayload(def) -> { templatePath, name, htmlTitle, metaDescription, slug, language }
259
+ * Pure: project the create/update body from a canonical (refs-resolved) page
260
+ * definition. `domain` is intentionally omitted so the page publishes onto the
261
+ * target account's system domain (the prod host is never carried, codex §pages
262
+ * round-trip risks). templatePath must NOT begin with '/'.
263
+ */
264
+ export function buildPagePayload(def) {
265
+ const out = {};
266
+ for (const f of PUSH_FIELDS) {
267
+ if (f === 'slug') out.slug = def.slug ?? '';
268
+ else if (f === 'language') out.language = def.language ?? 'en';
269
+ else out[f] = def[f] ?? '';
270
+ }
271
+ if (typeof out.templatePath === 'string' && out.templatePath.startsWith('/')) {
272
+ out.templatePath = out.templatePath.replace(/^\/+/, '');
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * push(acct, { contentDir, registry, readDir, readFileText, resolvePageId, now })
279
+ * -> { pushed, notes }
280
+ *
281
+ * Idempotent by SLUG: resolvePageBySlug decides create (POST) vs update (PATCH
282
+ * /{id}/draft). Tests inject `readDir`, `readFileText`, a `resolvePageId(slug)`
283
+ * stub, and a `hub` stub so nothing real is touched.
284
+ */
285
+ export async function push(acct, ctx) {
286
+ const { contentDir, registry } = ctx;
287
+ const hubFn = ctx.hub || hub;
288
+ const readDirFn = ctx.readDir || defaultReadDir;
289
+ const readFileTextFn = ctx.readFileText || defaultReadFileText;
290
+ // codex #11: `nowFn()` is called FRESH for each scheduled item so the
291
+ // publishDate is recomputed immediately before each schedule request and can
292
+ // never slip into the past for a later item in a large batch. Tests may inject
293
+ // a `nowFn` (a function, called once per item) or a fixed `now` (a number,
294
+ // used as a constant base — for those tests there is exactly one item so it is
295
+ // equivalent). Default: real wall clock, read per item.
296
+ const nowFn = ctx.nowFn || (typeof ctx.now === 'number' ? () => ctx.now : Date.now);
297
+
298
+ // resolvePageId(slug) -> id|null. Default lists site-pages ONCE and matches by
299
+ // slug (so a write-only key still works via a single read; matchPageSlug is
300
+ // the same pure matcher resolvePageBySlug uses). Tests stub this.
301
+ const resolvePageId = ctx.resolvePageId || (await makeSlugResolver(acct, hubFn));
302
+
303
+ // PUSH requires the manifest — never infer the push set from files present.
304
+ const manifest = await loadManifestPages(contentDir);
305
+ if (manifest.size === 0) {
306
+ throw new Error(
307
+ `pages.push: site.manifest.json has no pages — it is the only push list ` +
308
+ `(refusing to infer publishable content from files in content/pages).`,
309
+ );
310
+ }
311
+
312
+ const dir = pagesDir(contentDir);
313
+ const files = (await readDirFn(dir)).filter((f) => f.endsWith('.json'));
314
+
315
+ const notes = [];
316
+ const toPublish = [];
317
+ let pushed = 0;
318
+
319
+ for (const fileName of files) {
320
+ const fileSlug = fileToSlug(fileName.replace(/\.json$/, ''));
321
+
322
+ // Honour the manifest: a file on disk not listed is NOT pushed.
323
+ const manifestEntry = manifest.get(fileSlug);
324
+ if (!manifestEntry) {
325
+ notes.push(`skip (not in manifest): ${fileName}`);
326
+ continue;
327
+ }
328
+
329
+ // An 'ignore' page is never pushed/published, so it must be skipped BEFORE
330
+ // we read or resolve it. Otherwise an unmapped @logical ref inside an
331
+ // ignored file would make resolve() throw and abort the ENTIRE push — an
332
+ // ignored page must never be able to block valid pages from syncing.
333
+ const desiredState = manifestEntry.desiredState;
334
+ if (desiredState === 'ignore') {
335
+ notes.push(`ignore (manifest): ${fileSlug || '(home)'}`);
336
+ continue;
337
+ }
338
+
339
+ const text = await readFileTextFn(join(dir, fileName));
340
+
341
+ // Resolve @logical refs to the TARGET account's ids. resolve() THROWS,
342
+ // listing every unmapped ref, so push hard-fails before any network write
343
+ // (codex #2). We resolve on the serialized string, then re-parse.
344
+ let def;
345
+ try {
346
+ def = JSON.parse(resolve(text, registry));
347
+ } catch (e) {
348
+ throw new Error(`pages.push: ${fileName}: ${e.message}`);
349
+ }
350
+
351
+ const slug = def.slug ?? '';
352
+ // INVARIANT: the filename slug (used for the manifest gate + resolve) and the
353
+ // body slug (used for the create-vs-update decision) MUST agree. If a hand-edit
354
+ // made them diverge, the manifest could gate on one slug while we create/update
355
+ // a different one — minting a DUPLICATE page. Fail closed on skew.
356
+ if (slug !== fileSlug) {
357
+ throw new Error(
358
+ `pages.push: ${fileName}: body slug "${slug}" != filename slug "${fileSlug}" — ` +
359
+ `refusing to push (would risk a duplicate page). Reconcile the filename and its slug.`,
360
+ );
361
+ }
362
+ const payload = buildPagePayload(def);
363
+
364
+ // CONTENT_TITLE_MISSING guard: schedule requires a non-empty title.
365
+ if (!payload.name && !payload.htmlTitle) {
366
+ throw new Error(
367
+ `pages.push: ${fileName}: name and htmlTitle are both empty — ` +
368
+ `HubSpot rejects publish with CONTENT_TITLE_MISSING.`,
369
+ );
370
+ }
371
+
372
+ // Create-vs-update decision is by SLUG identity.
373
+ const existingId = await resolvePageId(slug);
374
+
375
+ let pageId;
376
+ if (existingId) {
377
+ const res = await hubFn(acct, 'PATCH', `/cms/v3/pages/site-pages/${existingId}/draft`, payload);
378
+ if (!res.ok) throw new Error(httpErr('PATCH draft', slug, res));
379
+ pageId = existingId;
380
+ notes.push(`updated: ${slug || '(home)'} (#${pageId})`);
381
+ } else {
382
+ const res = await hubFn(acct, 'POST', '/cms/v3/pages/site-pages', payload);
383
+ if (!res.ok) throw new Error(httpErr('POST create', slug, res));
384
+ pageId = res.json?.id;
385
+ if (!pageId) throw new Error(`pages.push: create of "${slug}" returned no id`);
386
+ notes.push(`created: ${slug || '(home)'} (#${pageId})`);
387
+ }
388
+
389
+ pushed += 1;
390
+ if (desiredState === 'publish') toPublish.push({ slug, pageId });
391
+ // 'draft'/'archive' are written as drafts and intentionally NOT scheduled.
392
+ }
393
+
394
+ // PUBLISH last, via schedule (republish.mjs pattern). codex #11: compute a
395
+ // FRESH future publishDate for EACH item, immediately before its schedule
396
+ // request, so a large/slow batch can never push a later item's date into the
397
+ // past (which HubSpot would reject, silently leaving the draft unpublished).
398
+ for (const { slug, pageId } of toPublish) {
399
+ const publishDate = futurePublishDate(nowFn());
400
+ const res = await hubFn(acct, 'POST', '/cms/v3/pages/site-pages/schedule', {
401
+ id: String(pageId),
402
+ publishDate,
403
+ });
404
+ if (res.status === 204 || res.ok) {
405
+ notes.push(`scheduled publish: ${slug || '(home)'} @ ${publishDate}`);
406
+ } else {
407
+ throw new Error(httpErr('schedule', slug, res));
408
+ }
409
+ }
410
+
411
+ return { pushed, notes };
412
+ }
413
+
414
+ // Build a default slug->id resolver that lists site-pages once and reuses the
415
+ // pure matcher. Cached across the push run.
416
+ async function makeSlugResolver(acct, hubFn) {
417
+ let pages = null;
418
+ return async (slug) => {
419
+ if (pages === null) pages = await listSitePages(acct, hubFn);
420
+ return matchPageSlug(pages, slug);
421
+ };
422
+ }
423
+
424
+ async function listSitePages(acct, hubFn) {
425
+ const out = [];
426
+ let after;
427
+ do {
428
+ const path = `/cms/v3/pages/site-pages?limit=100${after ? `&after=${after}` : ''}`;
429
+ const res = await hubFn(acct, 'GET', path);
430
+ if (!res.ok) {
431
+ const msg = res.json?.message || res.json?.category || JSON.stringify(res.json).slice(0, 200);
432
+ throw new Error(`pages.push: GET site-pages -> ${res.status}: ${msg}`);
433
+ }
434
+ out.push(...(res.json?.results || []));
435
+ after = res.json?.paging?.next?.after;
436
+ } while (after);
437
+ return out;
438
+ }
439
+
440
+ function httpErr(op, slug, res) {
441
+ const msg = res.json?.message || res.json?.category || JSON.stringify(res.json).slice(0, 200);
442
+ return `pages.push: ${op} "${slug || '(home)'}" -> ${res.status}: ${msg}`;
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Default real-I/O hooks (overridable for unit tests).
447
+ // ---------------------------------------------------------------------------
448
+
449
+ async function defaultWriteFile(path, text) {
450
+ const { mkdir, writeFile } = await import('node:fs/promises');
451
+ const { dirname } = await import('node:path');
452
+ await mkdir(dirname(path), { recursive: true });
453
+ await writeFile(path, text);
454
+ }
455
+
456
+ async function defaultReadDir(dir) {
457
+ if (!existsSync(dir)) return [];
458
+ return readdir(dir);
459
+ }
460
+
461
+ async function defaultReadFileText(path) {
462
+ return readFile(path, 'utf8');
463
+ }