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
package/src/manifest.mjs
ADDED
|
@@ -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 };
|