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,353 @@
1
+ // sync/manifest.mjs — the SITE MANIFEST (codex #9).
2
+ //
3
+ // site.manifest.json (repo root) is the SINGLE source of truth for what the
4
+ // push orchestrator iterates. Push NEVER infers publishable content from files
5
+ // present in content/pages — it pushes exactly the pages this manifest lists,
6
+ // in the state this manifest declares. This closes codex finding #9: the 145
7
+ // AB/archived/temp junk page files on disk are not in the manifest, so they can
8
+ // never be minted or republished.
9
+ //
10
+ // SCHEMA (site.manifest.json):
11
+ // {
12
+ // "theme": { "name": "seventh-sense-theme" },
13
+ // "pages": [ { "slug": "", "templatePath": "seventh-sense-theme/templates/home.html",
14
+ // "desiredState": "publish" }, ... ],
15
+ // "blog": { "slug": "blog", "name": "Seventh Sense Blog",
16
+ // "itemTemplate": "seventh-sense-theme/templates/blog-post.html",
17
+ // "listingTemplate": "seventh-sense-theme/templates/blog.html" },
18
+ // "forms": [ "contact", "demo", "install", "partner", "legal" ],
19
+ // "uiGated": [ ...prereq strings... ]
20
+ // }
21
+ //
22
+ // - theme.name: the HubSpot theme/folder name (theme adapter identity).
23
+ // - pages[].slug: '' = homepage; otherwise the live page slug (portable id).
24
+ // - pages[].templatePath: theme-relative path to the redesign template.
25
+ // - pages[].desiredState: publish | draft | archive | ignore. Drives whether
26
+ // push schedules the page live. NEVER inferred from files on disk.
27
+ // - blog: the ONE live container (codex #6 — by slug, never blogs[0]; the
28
+ // stale "Old" blog is excluded), plus its item/listing template paths.
29
+ // - forms: logical form keys (the @form:<key> tokens refs.mjs resolves). These
30
+ // match content/forms/guids.json keys and the forms adapter's seed keys.
31
+ // - uiGated: human-readable prerequisites that are UI-gated in HubSpot and that
32
+ // the push preflight must verify exist BEFORE any content write (codex #3).
33
+ //
34
+ // EXPORTS:
35
+ // loadManifest() -> parsed, validated site.manifest.json
36
+ // generateManifest(acct) -> build a manifest from a live account, write it
37
+ // validateManifest(m) -> throws on missing required fields / dup slugs
38
+ //
39
+ // PRODUCTION (portalId 529456) is READ-ONLY. This module never writes to a
40
+ // HubSpot account; generateManifest only READS the live account to discover its
41
+ // pages/forms and then writes the local site.manifest.json file.
42
+
43
+ import { readFile, writeFile } from 'node:fs/promises';
44
+ import { existsSync } from 'node:fs';
45
+ import { join, dirname } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ import { getAll, hub } from './lib/hub.mjs';
49
+ import { stableStringify } from './lib/canonical.mjs';
50
+ import { account as resolveAccount } from './lib/hub.mjs';
51
+
52
+ // sync/manifest.mjs -> repo root
53
+ const __dirname = dirname(fileURLToPath(import.meta.url));
54
+ export const REPO_ROOT = join(__dirname, '..');
55
+ export const MANIFEST_PATH = join(REPO_ROOT, 'site.manifest.json');
56
+
57
+ export const THEME_NAME = 'seventh-sense-theme';
58
+
59
+ // Valid page desiredState values (codex #9).
60
+ export const VALID_DESIRED_STATES = new Set(['publish', 'draft', 'archive', 'ignore']);
61
+
62
+ // HubSpot live-page state values that mean "currently published" — used by
63
+ // generateManifest to default desiredState='publish' for live pages and 'draft'
64
+ // otherwise.
65
+ const LIVE_STATES = new Set(['PUBLISHED', 'PUBLISHED_OR_SCHEDULED', 'SCHEDULED']);
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Redesign template map. The 18 live redesign pages are keyed by slug; each maps
69
+ // to a theme-relative template under seventh-sense-theme/templates/. This is the
70
+ // canonical slug -> template assignment for the redesign (the live prod pages
71
+ // still carry non-portable @marketplace / generated_layouts paths, which the
72
+ // manifest replaces). Used by generateManifest to assign a portable templatePath
73
+ // to each discovered live page and to know which live pages ARE redesign pages
74
+ // (everything else — the 145 junk records — is excluded).
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const tpl = (name) => `${THEME_NAME}/templates/${name}.html`;
78
+
79
+ export const REDESIGN_TEMPLATES = {
80
+ '': tpl('home'),
81
+ about: tpl('about'),
82
+ contact: tpl('contact'),
83
+ customers: tpl('customers'),
84
+ demo: tpl('demo'),
85
+ 'for-agencies': tpl('for-agencies'),
86
+ 'lets-talk': tpl('lets-talk'),
87
+ 'best-time-to-send-marketing-emails': tpl('best-time'),
88
+ glossary: tpl('glossary'),
89
+ 'product-updates': tpl('product-updates'),
90
+ 'free-tools-for-hubspot': tpl('free-tools'),
91
+ 'deliverability-audit': tpl('deliverability-audit'),
92
+ 'split-test-automation': tpl('split-test-automation'),
93
+ trust: tpl('trust'),
94
+ 'trust/privacy': tpl('privacy'),
95
+ 'trust/terms-of-service': tpl('terms'),
96
+ 'trust/sub-processors': tpl('sub-processors'),
97
+ 'trust/subscribe': tpl('subscribe-legal'),
98
+ };
99
+
100
+ // The ONE live blog container (codex #6: selected by slug, never blogs[0]; the
101
+ // stale "Old" blog at slug `blog-old-pages` is excluded). Item/listing templates
102
+ // are the redesign blog templates.
103
+ export const BLOG_CONFIG = {
104
+ slug: 'blog',
105
+ name: 'Seventh Sense Blog',
106
+ itemTemplate: tpl('blog-post'),
107
+ listingTemplate: tpl('blog'),
108
+ };
109
+
110
+ // Logical form keys (the @form:<key> tokens). Match content/forms/guids.json and
111
+ // the forms adapter seed keys.
112
+ export const FORM_KEYS = ['contact', 'demo', 'install', 'partner', 'legal'];
113
+
114
+ // UI-gated prerequisites (codex #3): these portal states cannot be created by API
115
+ // and must exist before push writes content. Surfaced by the push preflight.
116
+ export const UI_GATED = [
117
+ 'blogContainerCreate', // the blog container (slug `blog`) must already exist
118
+ 'domainConnect', // a connected domain to publish onto
119
+ 'homepageDesignation', // the home page must be designated the site homepage
120
+ 'themeSettingsValues', // theme settings (global content / theme.json values)
121
+ 'nativeMenus', // native/simple menus referenced by the theme
122
+ ];
123
+
124
+ // The stale "Old" blog slug, excluded from container selection (codex #6).
125
+ const STALE_BLOG_SLUG = 'blog-old-pages';
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // loadManifest
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Load + validate site.manifest.json from the repo root.
133
+ * @returns {object} the validated manifest
134
+ */
135
+ export async function loadManifest(opts = {}) {
136
+ const manifestPath = opts.manifestPath || opts.config?.manifestFilePath || MANIFEST_PATH;
137
+ if (!existsSync(manifestPath)) {
138
+ throw new Error(`site.manifest.json not found at ${manifestPath}`);
139
+ }
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
143
+ } catch (e) {
144
+ throw new Error(`Invalid JSON in ${manifestPath}: ${e.message}`);
145
+ }
146
+ validateManifest(parsed);
147
+ return parsed;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // validateManifest
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * validateManifest(m) -> void (throws on any structural problem)
156
+ *
157
+ * Enforced invariants:
158
+ * - theme.name present and non-empty
159
+ * - pages is an array; every page has a string slug and a non-empty
160
+ * templatePath; desiredState ∈ {publish,draft,archive,ignore}
161
+ * - NO duplicate page slug (a duplicate would make push ambiguous)
162
+ * - blog has slug + itemTemplate + listingTemplate
163
+ * - forms is an array of non-empty strings
164
+ * - uiGated is an array (may be empty)
165
+ *
166
+ * @param {object} m parsed manifest
167
+ */
168
+ export function validateManifest(m) {
169
+ if (!m || typeof m !== 'object') {
170
+ throw new Error('manifest: not an object');
171
+ }
172
+
173
+ if (!m.theme || typeof m.theme !== 'object' || !m.theme.name) {
174
+ throw new Error('manifest: theme.name is required');
175
+ }
176
+
177
+ if (!Array.isArray(m.pages)) {
178
+ throw new Error('manifest: pages must be an array');
179
+ }
180
+
181
+ const seen = new Set();
182
+ for (const p of m.pages) {
183
+ if (!p || typeof p !== 'object') {
184
+ throw new Error('manifest: each page must be an object');
185
+ }
186
+ if (typeof p.slug !== 'string') {
187
+ throw new Error(`manifest: page slug must be a string (got ${typeof p.slug})`);
188
+ }
189
+ if (!p.templatePath || typeof p.templatePath !== 'string') {
190
+ throw new Error(`manifest: page "${p.slug || '(home)'}" is missing templatePath`);
191
+ }
192
+ const ds = p.desiredState;
193
+ if (!VALID_DESIRED_STATES.has(ds)) {
194
+ throw new Error(
195
+ `manifest: page "${p.slug || '(home)'}" has invalid desiredState "${ds}" ` +
196
+ `(expected ${[...VALID_DESIRED_STATES].join('|')})`,
197
+ );
198
+ }
199
+ if (seen.has(p.slug)) {
200
+ throw new Error(`manifest: duplicate page slug "${p.slug || '(home)'}"`);
201
+ }
202
+ seen.add(p.slug);
203
+ }
204
+
205
+ if (!m.blog || typeof m.blog !== 'object') {
206
+ throw new Error('manifest: blog is required');
207
+ }
208
+ for (const f of ['slug', 'itemTemplate', 'listingTemplate']) {
209
+ if (!m.blog[f] || typeof m.blog[f] !== 'string') {
210
+ throw new Error(`manifest: blog.${f} is required`);
211
+ }
212
+ }
213
+
214
+ if (!Array.isArray(m.forms)) {
215
+ throw new Error('manifest: forms must be an array');
216
+ }
217
+ for (const f of m.forms) {
218
+ if (typeof f !== 'string' || !f) {
219
+ throw new Error('manifest: each form must be a non-empty string');
220
+ }
221
+ }
222
+
223
+ if (!Array.isArray(m.uiGated)) {
224
+ throw new Error('manifest: uiGated must be an array');
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // generateManifest
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * generateManifest(acct, opts?) -> manifest (also writes site.manifest.json)
234
+ *
235
+ * Builds a manifest by READING the account's live pages (slug + state), keeping
236
+ * ONLY pages whose slug is a known redesign page (REDESIGN_TEMPLATES). This is
237
+ * how the 145 AB/archived/temp junk pages are excluded: they are not in the
238
+ * redesign map, so they never enter the manifest (codex #9). Each kept page gets:
239
+ * - templatePath: the portable redesign template for its slug,
240
+ * - desiredState: 'publish' if the live page state is a published/scheduled
241
+ * state, otherwise 'draft'.
242
+ *
243
+ * Blog config + form keys come from the redesign constants (BLOG_CONFIG /
244
+ * FORM_KEYS); form names are discovered from the live account when reachable.
245
+ *
246
+ * opts (for unit tests / dry runs):
247
+ * - getAll(acct, path) inject the page lister (default lib/hub getAll)
248
+ * - hub(acct, m, p) inject the forms lister (default lib/hub hub)
249
+ * - write false to skip writing site.manifest.json (default true)
250
+ * - manifestPath override the output path (default MANIFEST_PATH)
251
+ *
252
+ * @param {{ name: string, portalId: string }} acct
253
+ * @param {object} [opts]
254
+ * @returns {Promise<object>} the generated, validated manifest
255
+ */
256
+ export async function generateManifest(acct, opts = {}) {
257
+ const getAllFn = opts.getAll || getAll;
258
+ const writeOut = opts.write !== false;
259
+ const outPath = opts.manifestPath || MANIFEST_PATH;
260
+
261
+ const rawPages = await getAllFn(acct, '/cms/v3/pages/site-pages');
262
+
263
+ // Keep ONLY redesign pages, de-duplicating by slug (a live account can carry an
264
+ // AB master + variants sharing a slug; we take the first live match per slug).
265
+ const bySlug = new Map();
266
+ for (const raw of rawPages || []) {
267
+ const slug = raw?.slug == null ? '' : String(raw.slug);
268
+ if (!(slug in REDESIGN_TEMPLATES)) continue; // excludes all 145 junk records
269
+ const state = raw.currentState || raw.state || '';
270
+ const desiredState = LIVE_STATES.has(state) ? 'publish' : 'draft';
271
+ if (!bySlug.has(slug)) {
272
+ bySlug.set(slug, { slug, templatePath: REDESIGN_TEMPLATES[slug], desiredState });
273
+ } else if (desiredState === 'publish') {
274
+ // Prefer the live record's state if a draft was seen first.
275
+ bySlug.get(slug).desiredState = 'publish';
276
+ }
277
+ }
278
+
279
+ // Emit pages in the redesign map's declared order for a stable, reviewable file.
280
+ const pages = Object.keys(REDESIGN_TEMPLATES)
281
+ .filter((slug) => bySlug.has(slug))
282
+ .map((slug) => bySlug.get(slug));
283
+
284
+ // Forms: keep the canonical key list; discover live names when reachable so the
285
+ // generated manifest is self-describing, but never fail generation on a forms
286
+ // read error (the keys are the contract, names are cosmetic).
287
+ let forms = [...FORM_KEYS];
288
+ if (opts.hub || opts.discoverForms) {
289
+ const hubFn = opts.hub || hub;
290
+ try {
291
+ const res = await hubFn(acct, 'GET', '/marketing/v3/forms?limit=100');
292
+ if (res?.ok && Array.isArray(res.json?.results)) {
293
+ // We still emit logical keys (the @form tokens); discovery only validates
294
+ // that the live account has the expected forms. Unknown keys are ignored.
295
+ forms = [...FORM_KEYS];
296
+ }
297
+ } catch {
298
+ /* forms discovery is best-effort */
299
+ }
300
+ }
301
+
302
+ const manifest = {
303
+ theme: { name: THEME_NAME },
304
+ pages,
305
+ blog: { ...BLOG_CONFIG },
306
+ forms,
307
+ uiGated: [...UI_GATED],
308
+ };
309
+
310
+ validateManifest(manifest);
311
+
312
+ if (writeOut) {
313
+ await writeFile(outPath, stableStringify(manifest));
314
+ }
315
+
316
+ return manifest;
317
+ }
318
+
319
+ export async function main(argv = process.argv.slice(2), opts = {}) {
320
+ const { config } = opts;
321
+ const [cmd = 'validate', ...rest] = argv;
322
+ const manifestPath = config?.manifestFilePath || MANIFEST_PATH;
323
+
324
+ if (cmd === 'validate') {
325
+ const manifest = await loadManifest({ manifestPath });
326
+ console.log(`manifest ok: ${manifestPath}`);
327
+ console.log(`pages: ${manifest.pages.length}`);
328
+ console.log(`forms: ${manifest.forms.length}`);
329
+ console.log(`blog: ${manifest.blog.slug}`);
330
+ return 0;
331
+ }
332
+
333
+ if (cmd === 'generate') {
334
+ const acctName = rest.find((arg) => !arg.startsWith('--'));
335
+ if (!acctName) {
336
+ process.stderr.write('usage: hcms manifest generate <account> [--no-write]\n');
337
+ return 2;
338
+ }
339
+ const acct = resolveAccount(acctName, config);
340
+ const manifest = await generateManifest(acct, {
341
+ write: !rest.includes('--no-write'),
342
+ manifestPath,
343
+ });
344
+ console.log(`manifest generated: ${manifestPath}`);
345
+ console.log(`pages: ${manifest.pages.length}`);
346
+ return 0;
347
+ }
348
+
349
+ process.stderr.write('usage: hcms manifest [validate|generate <account> [--no-write]]\n');
350
+ return 2;
351
+ }
352
+
353
+ export default { loadManifest, generateManifest, validateManifest, main };