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,213 @@
|
|
|
1
|
+
// sync/adapters/content.mjs — page MODULE CONTENT (widgets) adapter.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS OWNS: the per-page-INSTANCE module field VALUES — HubSpot calls these
|
|
4
|
+
// `widgets` (a map keyed by module-instance name; each value is a "carrier"
|
|
5
|
+
// { body, name, type, label, css, child_css }). This is the only render path for
|
|
6
|
+
// content that a coded `{% module %}` template exposes for marketer editing and that
|
|
7
|
+
// won't serialize as HubL tag params (rich text / HTML). It is the canonicalized,
|
|
8
|
+
// account-agnostic successor to the proven sync/page-content.mjs one-shot script.
|
|
9
|
+
//
|
|
10
|
+
// SEPARATION OF CONCERNS:
|
|
11
|
+
// - The page DEFINITION (slug, name, htmlTitle, templatePath, ...) is the `pages`
|
|
12
|
+
// adapter's job. This adapter touches ONLY the widgets map on an existing page,
|
|
13
|
+
// identified by SLUG. It never creates pages (pages.push does) — it resolves a
|
|
14
|
+
// page id by slug at push time and PATCHes its draft.
|
|
15
|
+
// - Reference portability (form GUIDs, CTA guids, hosted asset URLs, bare portal
|
|
16
|
+
// ids embedded inside widget body strings) is delegated wholesale to
|
|
17
|
+
// sync/lib/refs.mjs. home.widgets.json:743 carries a raw `form_id` GUID; on pull
|
|
18
|
+
// that becomes `@form:<key>`, on push it is resolved to the TARGET account's GUID.
|
|
19
|
+
//
|
|
20
|
+
// CANONICAL CONTRACT (codex #8 — keep widget-carrier empties, replace-not-merge):
|
|
21
|
+
// normalizeWidgets() in canonical.mjs deliberately KEEPS empty css/child_css/label
|
|
22
|
+
// and passes `body` through verbatim (including empty-string body fields like
|
|
23
|
+
// section_id:''), because the push PATCH REPLACES the whole widget — a thinner
|
|
24
|
+
// payload would blank rendered styling. We therefore must NOT run a generic
|
|
25
|
+
// empty-omit over the carrier. stableStringify gives the diff-clean bytes; refs
|
|
26
|
+
// canonicalize/resolve only swap id substrings, so the JSON stays valid + stable.
|
|
27
|
+
//
|
|
28
|
+
// ROUND-TRIP (pull -> push -> pull converges): pull writes
|
|
29
|
+
// stableStringify({ widgets: normalizeWidgets(raw) }) then canonicalize(str, reg)
|
|
30
|
+
// push does the inverse: resolve(fileBytes, reg) -> parse -> PATCH draft -> schedule.
|
|
31
|
+
// Because canonicalize/resolve are exact string inverses for matched logical keys and
|
|
32
|
+
// stableStringify is idempotent, bytes converge.
|
|
33
|
+
//
|
|
34
|
+
// READ-ONLY PROD: this adapter never hardcodes a portal; the orchestrator passes
|
|
35
|
+
// `acct`. push() targets whatever `acct` it is given (prod is excluded upstream).
|
|
36
|
+
|
|
37
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { hub, getAll, resolvePageBySlug } from '../lib/hub.mjs';
|
|
41
|
+
import { stableStringify, normalizeWidgets, slugToFile, fileToSlug } from '../lib/canonical.mjs';
|
|
42
|
+
import { canonicalize, resolve } from '../lib/refs.mjs';
|
|
43
|
+
|
|
44
|
+
export const name = 'content';
|
|
45
|
+
|
|
46
|
+
// Page widgets embed form GUIDs and CTA refs whose TARGET ids/urls are populated by
|
|
47
|
+
// the forms and assets adapters' push. We CONSUME those registry entries at push
|
|
48
|
+
// time, so we depend on them. (The orchestrator runs dependsOn adapters' push first.)
|
|
49
|
+
export const dependsOn = ['forms', 'assets'];
|
|
50
|
+
|
|
51
|
+
// Where canonical widget files live, relative to contentDir.
|
|
52
|
+
const PAGES_SUBDIR = join('pages');
|
|
53
|
+
const WIDGETS_SUFFIX = '.widgets.json';
|
|
54
|
+
|
|
55
|
+
// Seconds in the future to schedule a draft publish. The schedule endpoint requires a
|
|
56
|
+
// FUTURE publishDate; the page goes live ~90s later. Mirrors page-content.mjs.
|
|
57
|
+
const PUBLISH_LEAD_MS = 90_000;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Page selection (pull): exclude AB variants and any page without a widgets map.
|
|
61
|
+
// A page with no instance-editable modules has nothing for THIS adapter to own.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function isABVariant(page) {
|
|
64
|
+
if (page.abTestId) return true;
|
|
65
|
+
const st = String(page.currentState || page.state || '');
|
|
66
|
+
return st === 'LOSER_AB_VARIANT' || st === 'DRAFT_AB' || st === 'AB';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hasWidgets(page) {
|
|
70
|
+
return page && page.widgets && typeof page.widgets === 'object' && Object.keys(page.widgets).length > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build the canonical, account-portable widgets file CONTENT (a string) for one page.
|
|
74
|
+
// Steps: project to carrier-only widgets (keeps empties), stable-stringify for a clean
|
|
75
|
+
// diff, then logical-ize embedded refs (form GUID -> @form:key, etc.) via the registry,
|
|
76
|
+
// which also REGISTERS any newly-seen ids so first pull is self-bootstrapping.
|
|
77
|
+
function canonicalWidgetsFile(rawPage, registry) {
|
|
78
|
+
const widgets = normalizeWidgets(rawPage.widgets);
|
|
79
|
+
const bytes = stableStringify({ widgets });
|
|
80
|
+
return canonicalize(bytes, registry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function widgetsPath(contentDir, slug) {
|
|
84
|
+
return join(contentDir, PAGES_SUBDIR, `${slugToFile(slug)}${WIDGETS_SUFFIX}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// pull(acct, { contentDir, registry }) -> { pulled, notes }
|
|
89
|
+
// For every live, non-AB site page that carries module-instance values, fetch the
|
|
90
|
+
// full page (the list endpoint omits widgets), canonicalize, and write
|
|
91
|
+
// content/pages/<slug>.widgets.json. Registers any embedded refs into `registry`.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
export async function pull(acct, { contentDir, registry }) {
|
|
94
|
+
const notes = [];
|
|
95
|
+
const outDir = join(contentDir, PAGES_SUBDIR);
|
|
96
|
+
mkdirSync(outDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
const list = await getAll(acct, '/cms/v3/pages/site-pages');
|
|
99
|
+
const candidates = list.filter((p) => !isABVariant(p));
|
|
100
|
+
|
|
101
|
+
let pulled = 0;
|
|
102
|
+
for (const summary of candidates) {
|
|
103
|
+
const id = String(summary.id);
|
|
104
|
+
// The list payload omits `widgets`; fetch the full page to get the carrier map.
|
|
105
|
+
const { ok, status, json } = await hub(acct, 'GET', `/cms/v3/pages/site-pages/${id}`);
|
|
106
|
+
if (!ok) {
|
|
107
|
+
notes.push(`skip page ${id} (${summary.slug ?? ''}): GET -> ${status}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!hasWidgets(json)) continue; // no instance-editable modules -> nothing to own
|
|
111
|
+
|
|
112
|
+
const slug = String(json.slug ?? summary.slug ?? '');
|
|
113
|
+
const file = canonicalWidgetsFile(json, registry);
|
|
114
|
+
writeFileSync(widgetsPath(contentDir, slug), file);
|
|
115
|
+
pulled += 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
notes.push(`pulled ${pulled} page widget file(s) from ${candidates.length} non-AB page(s)`);
|
|
119
|
+
return { pulled, notes };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// push(acct, { contentDir, registry }) -> { pushed, notes }
|
|
124
|
+
// For each content/pages/<slug>.widgets.json: resolve embedded logical refs to THIS
|
|
125
|
+
// account's ids/urls (HARD-FAILS via resolve() if any ref is unmapped), resolve the
|
|
126
|
+
// page id by slug, PATCH the page draft with the full widgets carrier (replace-not-
|
|
127
|
+
// merge), then schedule a near-future publish so the draft goes live.
|
|
128
|
+
// Idempotent: PATCH draft + schedule by stable identity (slug -> page id) — running
|
|
129
|
+
// twice converges to the same draft+publish.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
export async function push(acct, { contentDir, registry }) {
|
|
132
|
+
const notes = [];
|
|
133
|
+
const dir = join(contentDir, PAGES_SUBDIR);
|
|
134
|
+
if (!existsSync(dir)) {
|
|
135
|
+
notes.push(`no pages dir at ${dir}; nothing to push`);
|
|
136
|
+
return { pushed: 0, notes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(WIDGETS_SUFFIX));
|
|
140
|
+
let pushed = 0;
|
|
141
|
+
for (const fname of files) {
|
|
142
|
+
const stem = fname.slice(0, -WIDGETS_SUFFIX.length);
|
|
143
|
+
const slug = fileToSlug(stem);
|
|
144
|
+
|
|
145
|
+
// 1. Read canonical bytes and inject target ids. resolve() THROWS listing every
|
|
146
|
+
// unmapped logical ref — a missing form/cta/asset mapping must abort the push.
|
|
147
|
+
const raw = readFileSync(join(dir, fname), 'utf8');
|
|
148
|
+
const resolved = resolve(raw, registry); // throws on unmapped refs
|
|
149
|
+
let widgets;
|
|
150
|
+
try {
|
|
151
|
+
widgets = JSON.parse(resolved).widgets;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
throw new Error(`content.push: ${fname} is not valid JSON after ref-resolve: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
if (!widgets || typeof widgets !== 'object') {
|
|
156
|
+
notes.push(`skip ${fname}: no widgets object`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// DATA-LOSS GUARD (replace-not-merge): an empty widgets map would PATCH the
|
|
160
|
+
// draft with `widgets: {}`, and because HubSpot REPLACES the whole carrier
|
|
161
|
+
// that BLANKS every widget on the live page. A file with no widgets has
|
|
162
|
+
// nothing to own (mirrors pull's hasWidgets skip) — never emit an empty PATCH.
|
|
163
|
+
if (Object.keys(widgets).length === 0) {
|
|
164
|
+
notes.push(`skip ${fname}: widgets map is empty (refusing to blank the live page)`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2. Resolve the page id by slug. The page DEFINITION must already exist (pages
|
|
169
|
+
// adapter runs first). If it doesn't, this adapter can't place the widgets.
|
|
170
|
+
const pageId = await resolvePageBySlug(acct, slug);
|
|
171
|
+
if (!pageId) {
|
|
172
|
+
notes.push(`skip ${fname}: no page in account ${acct.portalId} for slug "${slug}" (pages.push must create it first)`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. PATCH the draft with the FULL carrier (replace-not-merge). We send the
|
|
177
|
+
// complete widgets map; HubSpot overwrites each widget wholesale, so the kept
|
|
178
|
+
// empties (css/child_css/label) are load-bearing.
|
|
179
|
+
const patch = await hub(acct, 'PATCH', `/cms/v3/pages/site-pages/${pageId}/draft`, { widgets });
|
|
180
|
+
if (!patch.ok) {
|
|
181
|
+
const msg = patch.json?.message || patch.json?.category || `HTTP ${patch.status}`;
|
|
182
|
+
throw new Error(`content.push: PATCH draft for slug "${slug}" (page ${pageId}) failed: ${msg}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 4. Schedule a near-future publish so the updated draft goes live. The schedule
|
|
186
|
+
// endpoint requires a FUTURE publishDate (.000Z form); never push-live/draft.
|
|
187
|
+
// codex #11: compute the publishDate FRESH here, immediately before the
|
|
188
|
+
// schedule request (per item — never one shared batch timestamp), so a
|
|
189
|
+
// large/slow batch can't push a later item's date into the past.
|
|
190
|
+
const publishDate = new Date(Date.now() + PUBLISH_LEAD_MS).toISOString().replace(/\.\d+Z$/, '.000Z');
|
|
191
|
+
const sch = await hub(acct, 'POST', '/cms/v3/pages/site-pages/schedule', {
|
|
192
|
+
id: String(pageId),
|
|
193
|
+
publishDate,
|
|
194
|
+
});
|
|
195
|
+
// codex #11: THROW on a schedule failure — never a soft note. The draft PATCH
|
|
196
|
+
// already landed, so a failed schedule leaves a live/draft DIVERGENCE (the
|
|
197
|
+
// draft has the new widgets but the live page does not). Swallowing that was
|
|
198
|
+
// silent data divergence; fail closed so the operator sees + retries it.
|
|
199
|
+
if (!sch.ok && sch.status !== 204) {
|
|
200
|
+
const msg = sch.json?.message || sch.json?.category || `HTTP ${sch.status}`;
|
|
201
|
+
throw new Error(
|
|
202
|
+
`content.push: schedule publish for slug "${slug}" (page ${pageId}) failed: ${msg} ` +
|
|
203
|
+
`(draft was PATCHed but is NOT live — re-run to converge)`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
pushed += 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
notes.push(`pushed ${pushed} page widget file(s) to portal ${acct.portalId}`);
|
|
210
|
+
return { pushed, notes };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default { name, dependsOn, pull, push };
|