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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/bin/hubspot-cms-sync.mjs +115 -0
- package/docs/CONFIGURATION.md +83 -0
- package/docs/GITHUB_ACTIONS.md +70 -0
- package/docs/MIGRATION_PLAN.md +361 -0
- package/docs/PLAN_REVIEW.md +42 -0
- package/docs/SKILL_DISTRIBUTION.md +79 -0
- package/examples/github-actions/ci.yml +56 -0
- package/examples/github-actions/preview.yml +71 -0
- package/examples/github-actions/publish.yml +82 -0
- package/examples/hubspot-cms-sync.config.mjs +45 -0
- package/examples/site.manifest.json +19 -0
- package/package.json +41 -0
- package/skill/SKILL.md +54 -0
- package/skill/references/commands.md +54 -0
- package/skill/references/config.md +25 -0
- package/skill/references/failures.md +58 -0
- package/skill/references/github-actions.md +56 -0
- package/skill/references/screenshots-and-fidelity.md +33 -0
- package/src/adapters/assets.mjs +576 -0
- package/src/adapters/blog.mjs +921 -0
- package/src/adapters/content.mjs +213 -0
- package/src/adapters/forms.mjs +569 -0
- package/src/adapters/pages.mjs +463 -0
- package/src/adapters/theme.mjs +503 -0
- package/src/config.mjs +113 -0
- package/src/corpus-scan.mjs +248 -0
- package/src/cta-inventory.mjs +352 -0
- package/src/index.mjs +3 -0
- package/src/lib/canonical.mjs +234 -0
- package/src/lib/hub.mjs +197 -0
- package/src/lib/orchestrate.mjs +141 -0
- package/src/lib/refs.mjs +398 -0
- package/src/lib/sync-state.mjs +86 -0
- package/src/manifest.mjs +353 -0
- package/src/preflight.mjs +385 -0
- package/src/pull.mjs +99 -0
- package/src/push.mjs +354 -0
- 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
|
+
}
|