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,503 @@
|
|
|
1
|
+
// sync/adapters/theme.mjs — theme code adapter (HubSpot CMS "seventh-sense-theme").
|
|
2
|
+
//
|
|
3
|
+
// The theme is the repo-root tree: templates/ modules/*.module/ css/ js/ images/
|
|
4
|
+
// plus theme.json and the root fields.json. Those files ALREADY live in git and ARE
|
|
5
|
+
// the canonical store — this adapter reconciles + canonicalizes them on pull and
|
|
6
|
+
// builds a target-specific copy on push. Identity is theme-name + path (never a
|
|
7
|
+
// per-account id), so nothing committed keys off a portal.
|
|
8
|
+
//
|
|
9
|
+
// Transport is the CMS Source Code REST API only — NO `hs` CLI. pull() walks the theme
|
|
10
|
+
// via the metadata endpoint and downloads each file's content; push() PUTs each built
|
|
11
|
+
// file. (The old `hs cms fetch`/`hs cms upload` calls were unreliable: the whole-tree
|
|
12
|
+
// upload silently no-op'd and mis-named the theme after the build dir.)
|
|
13
|
+
//
|
|
14
|
+
// PULL (codex canonicalization):
|
|
15
|
+
// GET /cms/v3/source-code/published/metadata|content/seventh-sense-theme/<path>
|
|
16
|
+
// (recursive walk, downloaded straight into the git paths — no staging dir), then
|
|
17
|
+
// for every fetched file:
|
|
18
|
+
// - meta.json: STRIP `module_id` (codex #1 diff-noise: a per-portal id),
|
|
19
|
+
// migrate `host_template_types` -> `content_types`, re-serialize with
|
|
20
|
+
// canon.stableStringify (sorted keys).
|
|
21
|
+
// - fields.json / theme.json: re-serialize with stableStringify.
|
|
22
|
+
// - any text file: normalize to LF, strip BOM.
|
|
23
|
+
// - portability: run refs.canonicalize over files that embed per-account refs
|
|
24
|
+
// (js/hs-forms.js hardcodes the portal id; module fields/html carry form_id
|
|
25
|
+
// GUIDs) so the committed bytes hold @portal / @form:<key> tokens, never the
|
|
26
|
+
// source portal/GUID. Newly-seen ids are registered into the registry.
|
|
27
|
+
// Reconciled bytes are written back into the git theme paths.
|
|
28
|
+
//
|
|
29
|
+
// PUSH (codex #2 — build target tree, THEN upload):
|
|
30
|
+
// Copy the theme into a TEMP BUILD tree, then refs.resolve every logical-ized file
|
|
31
|
+
// against the TARGET account's registry — injecting the target portal id into
|
|
32
|
+
// js/hs-forms.js and the target form GUIDs into module form_id fields — BEFORE
|
|
33
|
+
// uploading. `refs.resolve` HARD-FAILS if any @form/@portal token has no target
|
|
34
|
+
// mapping, so we never upload JS that still carries the source portal. Then PUT each
|
|
35
|
+
// built file to PUT /cms/v3/source-code/published/content/seventh-sense-theme/<path>.
|
|
36
|
+
// The orchestrator passes `acct`; we never hardcode a portal, so prod (529456) is
|
|
37
|
+
// never a target
|
|
38
|
+
// unless the orchestrator explicitly selects it (and it is read-only by policy).
|
|
39
|
+
//
|
|
40
|
+
// I/O lives in pull()/push(); the canonicalization and build-tree GUID injection are
|
|
41
|
+
// factored into the pure helpers canonicalizeMeta / canonicalizeThemeText /
|
|
42
|
+
// injectRefsIntoTree, which are exported for unit testing without a HubSpot account.
|
|
43
|
+
|
|
44
|
+
import { promises as fs } from 'node:fs';
|
|
45
|
+
import { existsSync } from 'node:fs';
|
|
46
|
+
import { join, dirname, relative, sep, basename } from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
49
|
+
|
|
50
|
+
import { stableStringify } from '../lib/canonical.mjs';
|
|
51
|
+
import { canonicalize as canonicalizeRefs, resolve as resolveRefs } from '../lib/refs.mjs';
|
|
52
|
+
|
|
53
|
+
export const name = 'theme';
|
|
54
|
+
|
|
55
|
+
// Theme push consumes form GUIDs (and the portal id) that the forms adapter
|
|
56
|
+
// populates into the registry on its push. No other adapter must run first.
|
|
57
|
+
export const dependsOn = ['forms'];
|
|
58
|
+
|
|
59
|
+
export const THEME_NAME = 'seventh-sense-theme';
|
|
60
|
+
|
|
61
|
+
// repo root = sync/adapters/theme.mjs -> ../../
|
|
62
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
const REPO_ROOT = join(__dirname, '..', '..');
|
|
64
|
+
|
|
65
|
+
// The theme tree, relative to whatever root we operate on (repo root on pull-write,
|
|
66
|
+
// a temp build dir on push). Directories are walked recursively; loose files copied
|
|
67
|
+
// as-is. images/ is binary and copied verbatim — never text-normalized.
|
|
68
|
+
const THEME_DIRS = ['templates', 'modules', 'css', 'js', 'images'];
|
|
69
|
+
const THEME_FILES = ['theme.json', 'fields.json'];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* isThemePath(relPath) -> boolean (codex #12 — scoped upload guard).
|
|
73
|
+
*
|
|
74
|
+
* Single source of truth for "does this repo-relative path belong in the theme upload?".
|
|
75
|
+
* A path qualifies iff its FIRST segment is one of THEME_DIRS, or the whole path is one
|
|
76
|
+
* of THEME_FILES. Everything else at the repo root — docs/ sync/ content/ node_modules/
|
|
77
|
+
* test/ .sync-state/ .git/ package.json README.md … — is explicitly EXCLUDED so the
|
|
78
|
+
* Design Manager theme never receives non-theme bytes. `listThemeFiles` only walks the
|
|
79
|
+
* theme roots, so this is a belt-and-suspenders guard, but exporting it makes the scope
|
|
80
|
+
* assertable and keeps the inclusion rule in exactly one place.
|
|
81
|
+
*/
|
|
82
|
+
export function isThemePath(relPath) {
|
|
83
|
+
const p = String(relPath).split(sep).join('/').replace(/^\.\//, '');
|
|
84
|
+
if (p === '' || p.startsWith('../')) return false; // never escape the theme root
|
|
85
|
+
if (THEME_FILES.includes(p)) return true;
|
|
86
|
+
const first = p.split('/')[0];
|
|
87
|
+
return THEME_DIRS.includes(first);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Files that may embed per-account references (portal id / form GUIDs) and therefore
|
|
91
|
+
// must round-trip through refs.canonicalize (pull) / refs.resolve (push). We match by
|
|
92
|
+
// path suffix so it is independent of the operating root.
|
|
93
|
+
const REF_BEARING = (relPath) => {
|
|
94
|
+
const p = relPath.split(sep).join('/');
|
|
95
|
+
return (
|
|
96
|
+
p === 'js/hs-forms.js' ||
|
|
97
|
+
p.endsWith('.module/fields.json') ||
|
|
98
|
+
p.endsWith('.module/module.html')
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// HubSpot's `hs cms fetch` ALWAYS emits a `module.css` and `module.js` for every
|
|
103
|
+
// `*.module/`, even when the module defines neither — they come down as empty (or
|
|
104
|
+
// whitespace-only) files. Committing those would add a churning empty file to git for
|
|
105
|
+
// every module on the first pull (and re-upload them on push). We therefore IGNORE an
|
|
106
|
+
// auto-created empty module.css/module.js on pull *unless that file already exists in
|
|
107
|
+
// the tree* (i.e. someone authored real CSS/JS for the module — then we round-trip it).
|
|
108
|
+
// Matched by path suffix so it is independent of the operating root.
|
|
109
|
+
const isModuleAsset = (relPath) => {
|
|
110
|
+
const p = relPath.split(sep).join('/');
|
|
111
|
+
return p.endsWith('.module/module.css') || p.endsWith('.module/module.js');
|
|
112
|
+
};
|
|
113
|
+
// Empty == nothing but whitespace once BOM/CRLF are normalized away.
|
|
114
|
+
const isBlank = (s) => normalizeText(s).trim() === '';
|
|
115
|
+
|
|
116
|
+
// Text vs binary: only these extensions are text-normalized / ref-processed. Anything
|
|
117
|
+
// under images/ (or any other extension) is treated as opaque bytes.
|
|
118
|
+
const TEXT_EXT = new Set(['.html', '.css', '.js', '.json', '.txt', '.csv', '.svg']);
|
|
119
|
+
const extOf = (p) => {
|
|
120
|
+
const i = p.lastIndexOf('.');
|
|
121
|
+
return i < 0 ? '' : p.slice(i).toLowerCase();
|
|
122
|
+
};
|
|
123
|
+
const isText = (relPath) => TEXT_EXT.has(extOf(relPath));
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// PURE canonicalization helpers (exported for unit tests)
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Strip BOM, normalize CRLF/CR -> LF. Pure string transform. */
|
|
130
|
+
export function normalizeText(s) {
|
|
131
|
+
let t = String(s);
|
|
132
|
+
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); // strip leading BOM
|
|
133
|
+
return t.replace(/\r\n?/g, '\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* canonicalizeMeta(rawMetaText | obj) -> stable meta.json TEXT.
|
|
138
|
+
*
|
|
139
|
+
* The two codex-mandated migrations for module meta.json:
|
|
140
|
+
* - DELETE `module_id` (a per-portal id — the #1 source of diff noise; every pull
|
|
141
|
+
* from a different account would otherwise rewrite it).
|
|
142
|
+
* - MIGRATE `host_template_types` -> `content_types` (never keep both). If the file
|
|
143
|
+
* already uses `content_types`, leave it; if both are present, the legacy key is
|
|
144
|
+
* dropped in favour of the already-migrated value.
|
|
145
|
+
* Then re-serialize with stableStringify (sorted keys, 2-space, LF, trailing NL).
|
|
146
|
+
*/
|
|
147
|
+
export function canonicalizeMeta(input) {
|
|
148
|
+
const meta = typeof input === 'string' ? JSON.parse(normalizeText(input)) : { ...input };
|
|
149
|
+
|
|
150
|
+
// Strip the per-portal id.
|
|
151
|
+
delete meta.module_id;
|
|
152
|
+
|
|
153
|
+
// Migrate host_template_types -> content_types (prefer an already-migrated value).
|
|
154
|
+
if ('host_template_types' in meta) {
|
|
155
|
+
if (!('content_types' in meta)) {
|
|
156
|
+
meta.content_types = meta.host_template_types;
|
|
157
|
+
}
|
|
158
|
+
delete meta.host_template_types;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return stableStringify(meta);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* canonicalizeJsonText(rawText) -> stable JSON text.
|
|
166
|
+
* For fields.json / theme.json: parse, re-serialize with stableStringify. Field UUIDs
|
|
167
|
+
* and every other value are preserved verbatim; only key order + formatting change.
|
|
168
|
+
*/
|
|
169
|
+
export function canonicalizeJsonText(rawText) {
|
|
170
|
+
return stableStringify(JSON.parse(normalizeText(rawText)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* canonicalizeThemeText(relPath, rawText, registry) -> canonical TEXT.
|
|
175
|
+
*
|
|
176
|
+
* The single pull-time text pipeline:
|
|
177
|
+
* 1. normalize to LF / strip BOM
|
|
178
|
+
* 2. shape-canonicalize JSON (meta.json migrations; fields/theme re-serialize)
|
|
179
|
+
* 3. portability: for ref-bearing files, logical-ize per-account refs
|
|
180
|
+
* (portal id -> @portal, form GUIDs -> @form:<key>) via refs.canonicalize,
|
|
181
|
+
* registering any newly-seen ids into `registry`.
|
|
182
|
+
* Non-JSON, non-ref-bearing text (template HubL, css, main.js) is returned LF-clean.
|
|
183
|
+
*/
|
|
184
|
+
export function canonicalizeThemeText(relPath, rawText, registry) {
|
|
185
|
+
const p = relPath.split(sep).join('/');
|
|
186
|
+
let text = normalizeText(rawText);
|
|
187
|
+
|
|
188
|
+
if (p.endsWith('.module/meta.json')) {
|
|
189
|
+
// meta.json: migrate, but it carries no portal/form refs, so no ref pass.
|
|
190
|
+
return canonicalizeMeta(text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (REF_BEARING(p)) {
|
|
194
|
+
// module fields.json is still JSON: shape-canonicalize first, then logical-ize the
|
|
195
|
+
// embedded form_id GUID. js/hs-forms.js and module.html are plain text: just
|
|
196
|
+
// logical-ize the portal id / GUIDs in place.
|
|
197
|
+
if (p.endsWith('.json')) text = canonicalizeJsonText(text);
|
|
198
|
+
if (registry) text = canonicalizeRefs(text, registry);
|
|
199
|
+
return text;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (p === 'theme.json' || p.endsWith('/fields.json') || p === 'fields.json') {
|
|
203
|
+
return canonicalizeJsonText(text);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return text;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* injectRefsIntoTree(files, registry) -> [{ relPath, text }]
|
|
211
|
+
*
|
|
212
|
+
* PUSH build step (pure): given the theme's ref-bearing text files as
|
|
213
|
+
* { relPath, text } (text already LF-clean and holding @portal / @form tokens),
|
|
214
|
+
* resolve each against the TARGET `registry`, returning the injected text. THROWS via
|
|
215
|
+
* refs.resolve if any logical token has no target mapping — so the caller never writes
|
|
216
|
+
* a build tree that still carries the source portal/GUID. Files without logical tokens
|
|
217
|
+
* pass through unchanged.
|
|
218
|
+
*/
|
|
219
|
+
export function injectRefsIntoTree(files, registry) {
|
|
220
|
+
return files.map(({ relPath, text }) => ({
|
|
221
|
+
relPath,
|
|
222
|
+
text: REF_BEARING(relPath) ? resolveRefs(text, registry) : text,
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// I/O helpers
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
// Recursively list files under `root` for the theme tree. Returns relPaths (POSIX-ish
|
|
231
|
+
// via the OS sep) relative to `root`.
|
|
232
|
+
//
|
|
233
|
+
// Scope (codex #12): we ONLY descend THEME_DIRS and ONLY add THEME_FILES, so non-theme
|
|
234
|
+
// roots (docs/ sync/ content/ node_modules/ test/ .sync-state/ …) are never visited.
|
|
235
|
+
// Every emitted path is additionally run through isThemePath() as a defensive guard, so
|
|
236
|
+
// the build tree provably contains only theme files even if the walk ever regressed.
|
|
237
|
+
async function listThemeFiles(root) {
|
|
238
|
+
const out = [];
|
|
239
|
+
async function walk(absDir) {
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return; // missing optional dir (e.g. images/) — skip
|
|
245
|
+
}
|
|
246
|
+
for (const e of entries) {
|
|
247
|
+
const abs = join(absDir, e.name);
|
|
248
|
+
if (e.isDirectory()) await walk(abs);
|
|
249
|
+
else if (e.isFile()) {
|
|
250
|
+
const rel = relative(root, abs);
|
|
251
|
+
if (isThemePath(rel)) out.push(rel); // belt-and-suspenders: never emit a non-theme path
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const d of THEME_DIRS) await walk(join(root, d));
|
|
256
|
+
for (const f of THEME_FILES) if (existsSync(join(root, f))) out.push(f);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const HUB_API = 'https://api.hubapi.com';
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* PUT one built file to the target account via the CMS Source Code API
|
|
264
|
+
* (create-or-replace by path). Used instead of `hs cms upload`, whose whole-tree
|
|
265
|
+
* form silently no-ops / mis-names the theme after the build dir, and whose per-dir
|
|
266
|
+
* form is flaky. The API PUT is deterministic and idempotent. `relPath` is an
|
|
267
|
+
* OS-path relative to the build root; the remote path always uses forward slashes.
|
|
268
|
+
*/
|
|
269
|
+
async function uploadSourceFile(acct, themeName, relPath, absPath, { tries = 4 } = {}) {
|
|
270
|
+
const remote = relPath.split(sep).join('/');
|
|
271
|
+
const buf = await fs.readFile(absPath);
|
|
272
|
+
let lastErr;
|
|
273
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
274
|
+
try {
|
|
275
|
+
const fd = new FormData();
|
|
276
|
+
fd.append('file', new Blob([buf]), basename(remote));
|
|
277
|
+
const res = await fetch(
|
|
278
|
+
`${HUB_API}/cms/v3/source-code/published/content/${themeName}/${remote}`,
|
|
279
|
+
{ method: 'PUT', headers: { Authorization: `Bearer ${acct.key}` }, body: fd },
|
|
280
|
+
);
|
|
281
|
+
if (res.ok) return;
|
|
282
|
+
// 429/5xx are transient — back off and retry; 4xx (except 429) are fatal.
|
|
283
|
+
const body = await res.text().catch(() => '');
|
|
284
|
+
if (res.status !== 429 && res.status < 500) {
|
|
285
|
+
throw new Error(`source-code PUT ${remote} -> ${res.status} ${body.slice(0, 200)}`);
|
|
286
|
+
}
|
|
287
|
+
lastErr = new Error(`source-code PUT ${remote} -> ${res.status}`);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
lastErr = e;
|
|
290
|
+
}
|
|
291
|
+
if (attempt < tries) await new Promise((r) => setTimeout(r, 400 * attempt));
|
|
292
|
+
}
|
|
293
|
+
throw lastErr;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* GET one Source Code API path with retry on 429/5xx. Returns the Response (callers
|
|
298
|
+
* handle 404 — a theme dir we ask for may simply not exist). Mirrors the upload helper.
|
|
299
|
+
*/
|
|
300
|
+
async function sourceGet(acct, kind, path, { tries = 4 } = {}) {
|
|
301
|
+
const url = `${HUB_API}/cms/v3/source-code/published/${kind}/${path}`;
|
|
302
|
+
let lastErr;
|
|
303
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${acct.key}` } });
|
|
306
|
+
if (res.ok || res.status === 404) return res;
|
|
307
|
+
if (res.status !== 429 && res.status < 500) {
|
|
308
|
+
const b = await res.text().catch(() => '');
|
|
309
|
+
throw new Error(`source-code GET ${kind}/${path} -> ${res.status} ${b.slice(0, 200)}`);
|
|
310
|
+
}
|
|
311
|
+
lastErr = new Error(`source-code GET ${kind}/${path} -> ${res.status}`);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
lastErr = e;
|
|
314
|
+
}
|
|
315
|
+
if (attempt < tries) await new Promise((r) => setTimeout(r, 400 * attempt));
|
|
316
|
+
}
|
|
317
|
+
throw lastErr;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Bounded-concurrency runner so the recursive tree walk doesn't fan out into hundreds
|
|
321
|
+
// of simultaneous requests (which would trip rate limits).
|
|
322
|
+
function makeLimiter(max) {
|
|
323
|
+
let active = 0;
|
|
324
|
+
const queue = [];
|
|
325
|
+
const pump = () => {
|
|
326
|
+
while (active < max && queue.length) {
|
|
327
|
+
active++;
|
|
328
|
+
const { fn, resolve, reject } = queue.shift();
|
|
329
|
+
fn().then(resolve, reject).finally(() => {
|
|
330
|
+
active--;
|
|
331
|
+
pump();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
return (fn) => new Promise((resolve, reject) => {
|
|
336
|
+
queue.push({ fn, resolve, reject });
|
|
337
|
+
pump();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Recursively download a theme subtree (the given top-level entries) from the CMS
|
|
343
|
+
* Source Code API, invoking `onFile(relPath, Buffer)` for each file. Folders are
|
|
344
|
+
* discovered via the metadata endpoint (`folder` + `children` names); missing entries
|
|
345
|
+
* (404) are skipped. No staging dir — callers reconcile each file straight to git.
|
|
346
|
+
*/
|
|
347
|
+
async function downloadThemeTree(acct, themeName, entries, onFile) {
|
|
348
|
+
const limit = makeLimiter(8);
|
|
349
|
+
async function walk(relPath) {
|
|
350
|
+
const metaRes = await limit(() => sourceGet(acct, 'metadata', `${themeName}/${relPath}`));
|
|
351
|
+
if (metaRes.status === 404) return;
|
|
352
|
+
const meta = await metaRes.json();
|
|
353
|
+
if (meta.folder) {
|
|
354
|
+
await Promise.all((meta.children || []).map((child) => walk(`${relPath}/${child}`)));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const res = await limit(() => sourceGet(acct, 'content', `${themeName}/${relPath}`));
|
|
358
|
+
if (res.status === 404) return;
|
|
359
|
+
await onFile(relPath, Buffer.from(await res.arrayBuffer()));
|
|
360
|
+
}
|
|
361
|
+
await Promise.all(entries.map((entry) => walk(entry)));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// pull
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* pull(acct, { contentDir, registry }) -> { pulled, notes }
|
|
370
|
+
*
|
|
371
|
+
* `contentDir` is the operating root for theme files. The theme tree lives at the REPO
|
|
372
|
+
* root (templates/ modules/ css/ js/ ...), so by default we write there; tests pass an
|
|
373
|
+
* explicit contentDir to redirect writes. Source portal is acct.portalId; nothing is
|
|
374
|
+
* written back (read-only). Files are downloaded directly via the CMS Source Code API
|
|
375
|
+
* and reconciled straight into the git paths — NO intermediate staging dir (the old
|
|
376
|
+
* `hs cms fetch` needed one because it wrote a whole tree at once).
|
|
377
|
+
*/
|
|
378
|
+
export async function pull(acct, { contentDir, registry, config } = {}) {
|
|
379
|
+
const root = config?.root || contentDir || REPO_ROOT;
|
|
380
|
+
const themeName = config?.theme?.name || THEME_NAME;
|
|
381
|
+
const notes = [];
|
|
382
|
+
let pulled = 0;
|
|
383
|
+
let skippedEmptyModuleAssets = 0;
|
|
384
|
+
|
|
385
|
+
// Download + canonicalize each theme file in place. Only the theme roots are walked
|
|
386
|
+
// (templates/ modules/ css/ js/ images/ + theme.json/fields.json), matching what the
|
|
387
|
+
// git tree tracks.
|
|
388
|
+
await downloadThemeTree(acct, themeName, [...THEME_DIRS, ...THEME_FILES], async (relPath, buf) => {
|
|
389
|
+
const dst = join(root, relPath.split('/').join(sep));
|
|
390
|
+
|
|
391
|
+
// HubSpot auto-creates empty module.css/module.js. If the module never had one in
|
|
392
|
+
// git, don't create churn by committing an empty file; only round-trip it when it
|
|
393
|
+
// already exists (real authored CSS/JS) — see isModuleAsset note above.
|
|
394
|
+
if (isModuleAsset(relPath) && !existsSync(dst) && isBlank(buf.toString('utf8'))) {
|
|
395
|
+
skippedEmptyModuleAssets++;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await fs.mkdir(dirname(dst), { recursive: true });
|
|
400
|
+
if (isText(relPath)) {
|
|
401
|
+
await fs.writeFile(dst, canonicalizeThemeText(relPath, buf.toString('utf8'), registry), 'utf8');
|
|
402
|
+
} else {
|
|
403
|
+
// binary (images/...) — write verbatim, git is source of truth for bytes.
|
|
404
|
+
await fs.writeFile(dst, buf);
|
|
405
|
+
}
|
|
406
|
+
pulled++;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
notes.push(
|
|
410
|
+
`fetched ${themeName} from portal ${acct.portalId} via source-code API; canonicalized ${pulled} file(s)`,
|
|
411
|
+
);
|
|
412
|
+
if (skippedEmptyModuleAssets) {
|
|
413
|
+
notes.push(`ignored ${skippedEmptyModuleAssets} auto-created empty module.css/module.js file(s)`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { pulled, notes };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// push
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* push(acct, { contentDir, registry }) -> { pushed, notes }
|
|
425
|
+
*
|
|
426
|
+
* Build a temp tree, inject the TARGET account's portal id + form GUIDs into the
|
|
427
|
+
* ref-bearing files (refs.resolve, which hard-fails on any unmapped token), then
|
|
428
|
+
* upload that tree. Idempotent: `hs cms upload` to the same theme name creates-or-
|
|
429
|
+
* updates by path. Never targets a hardcoded portal — `acct` is supplied by the
|
|
430
|
+
* orchestrator.
|
|
431
|
+
*/
|
|
432
|
+
export async function push(acct, { contentDir, registry, config } = {}) {
|
|
433
|
+
const root = config?.root || contentDir || REPO_ROOT;
|
|
434
|
+
const themeName = config?.theme?.name || THEME_NAME;
|
|
435
|
+
const notes = [];
|
|
436
|
+
|
|
437
|
+
if (!registry || registry.portalId == null) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`theme.push: target registry has no portalId — cannot inject @portal for account ${acct.name}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Build tree under the repo (same mount as cwd) so `hs cms upload` reads it
|
|
444
|
+
// reliably — os.tmpdir() may be a different mount (see pull()).
|
|
445
|
+
const buildBase = config?.syncStateDirPath || join(root, '.sync-state');
|
|
446
|
+
await fs.mkdir(buildBase, { recursive: true });
|
|
447
|
+
const build = await mkdtemp(join(buildBase, 'theme-build-'));
|
|
448
|
+
let pushed = 0;
|
|
449
|
+
try {
|
|
450
|
+
const files = await listThemeFiles(root);
|
|
451
|
+
|
|
452
|
+
for (const relPath of files) {
|
|
453
|
+
const src = join(root, relPath);
|
|
454
|
+
const dst = join(build, relPath);
|
|
455
|
+
await fs.mkdir(dirname(dst), { recursive: true });
|
|
456
|
+
|
|
457
|
+
if (REF_BEARING(relPath)) {
|
|
458
|
+
// Inject target refs BEFORE writing into the build tree.
|
|
459
|
+
const raw = normalizeText(await fs.readFile(src, 'utf8'));
|
|
460
|
+
const [injected] = injectRefsIntoTree([{ relPath, text: raw }], registry);
|
|
461
|
+
await fs.writeFile(dst, injected.text, 'utf8');
|
|
462
|
+
} else if (isText(relPath)) {
|
|
463
|
+
await fs.writeFile(dst, normalizeText(await fs.readFile(src, 'utf8')), 'utf8');
|
|
464
|
+
} else {
|
|
465
|
+
await fs.copyFile(src, dst);
|
|
466
|
+
}
|
|
467
|
+
pushed++;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
notes.push(
|
|
471
|
+
`built target theme for portal ${registry.portalId} (${pushed} files); injected refs into ref-bearing files`,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Upload the BUILT tree to the target account via the CMS Source Code API,
|
|
475
|
+
// PUTting each file directly. We do NOT use `hs cms upload`: its whole-tree form
|
|
476
|
+
// silently no-ops and mis-names the theme after the build dir, and the per-dir
|
|
477
|
+
// form is unreliable in this environment. The API PUT is deterministic and
|
|
478
|
+
// idempotent (create-or-replace by path). `files` is the list copied into the
|
|
479
|
+
// build tree, so ref-bearing files already carry the injected target tokens.
|
|
480
|
+
const CONCURRENCY = 8;
|
|
481
|
+
let next = 0;
|
|
482
|
+
let uploaded = 0;
|
|
483
|
+
async function worker() {
|
|
484
|
+
while (next < files.length) {
|
|
485
|
+
const relPath = files[next++];
|
|
486
|
+
await uploadSourceFile(acct, themeName, relPath, join(build, relPath));
|
|
487
|
+
uploaded++;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
await Promise.all(
|
|
491
|
+
Array.from({ length: Math.min(CONCURRENCY, files.length) }, () => worker()),
|
|
492
|
+
);
|
|
493
|
+
notes.push(
|
|
494
|
+
`uploaded ${uploaded} file(s) to ${themeName} via source-code API (account ${acct.name}, portal ${acct.portalId})`,
|
|
495
|
+
);
|
|
496
|
+
} finally {
|
|
497
|
+
await rm(build, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return { pushed, notes };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export default { name, dependsOn, pull, push };
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG_FILE = 'hubspot-cms-sync.config.mjs';
|
|
8
|
+
|
|
9
|
+
export function defaultConfig(root = process.cwd()) {
|
|
10
|
+
return {
|
|
11
|
+
root: resolve(root),
|
|
12
|
+
accountsFile: 'sync/accounts.json',
|
|
13
|
+
keyDirEnv: 'HUBSPOT_KEY_DIR',
|
|
14
|
+
contentDir: 'content',
|
|
15
|
+
syncStateDir: '.sync-state',
|
|
16
|
+
manifestPath: 'site.manifest.json',
|
|
17
|
+
readOnlyPortalIds: [],
|
|
18
|
+
knownPortalIds: [],
|
|
19
|
+
assetHosts: {
|
|
20
|
+
canonicalizeHostPatterns: ['hubfs', 'hubspotusercontent', 'cdn\\d*\\.hubspot\\.net'],
|
|
21
|
+
legacySiteHosts: [],
|
|
22
|
+
},
|
|
23
|
+
adapters: {
|
|
24
|
+
externalDirs: [],
|
|
25
|
+
},
|
|
26
|
+
theme: {
|
|
27
|
+
name: 'theme',
|
|
28
|
+
dirs: ['templates', 'modules', 'css', 'js', 'images'],
|
|
29
|
+
files: ['theme.json', 'fields.json'],
|
|
30
|
+
},
|
|
31
|
+
blog: {
|
|
32
|
+
slug: 'blog',
|
|
33
|
+
itemTemplate: '',
|
|
34
|
+
listingTemplate: '',
|
|
35
|
+
},
|
|
36
|
+
uiGated: [],
|
|
37
|
+
verification: {
|
|
38
|
+
baseUrlEnv: 'SITE_BASE_URL',
|
|
39
|
+
commands: {},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mergeConfig(base, override) {
|
|
45
|
+
const out = { ...base, ...override };
|
|
46
|
+
out.theme = { ...base.theme, ...(override.theme || {}) };
|
|
47
|
+
out.blog = { ...base.blog, ...(override.blog || {}) };
|
|
48
|
+
out.assetHosts = { ...base.assetHosts, ...(override.assetHosts || {}) };
|
|
49
|
+
out.adapters = { ...base.adapters, ...(override.adapters || {}) };
|
|
50
|
+
out.verification = { ...base.verification, ...(override.verification || {}) };
|
|
51
|
+
out.verification.commands = {
|
|
52
|
+
...(base.verification?.commands || {}),
|
|
53
|
+
...(override.verification?.commands || {}),
|
|
54
|
+
};
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveConfigPaths(cfg) {
|
|
59
|
+
const root = resolve(cfg.root || process.cwd());
|
|
60
|
+
const abs = (p) => resolve(root, p);
|
|
61
|
+
const keyDir = process.env[cfg.keyDirEnv || 'HUBSPOT_KEY_DIR'] || join(homedir(), '.hubspot');
|
|
62
|
+
return {
|
|
63
|
+
...cfg,
|
|
64
|
+
root,
|
|
65
|
+
keyDir,
|
|
66
|
+
accountsPath: abs(cfg.accountsFile),
|
|
67
|
+
contentDirPath: abs(cfg.contentDir),
|
|
68
|
+
syncStateDirPath: abs(cfg.syncStateDir),
|
|
69
|
+
manifestFilePath: abs(cfg.manifestPath),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loadConfig({ root = process.cwd(), configPath } = {}) {
|
|
74
|
+
const base = defaultConfig(root);
|
|
75
|
+
const file = resolve(root, configPath || DEFAULT_CONFIG_FILE);
|
|
76
|
+
let user = {};
|
|
77
|
+
if (existsSync(file)) {
|
|
78
|
+
const mod = await import(pathToFileURL(file).href);
|
|
79
|
+
user = mod.default || mod.config || {};
|
|
80
|
+
}
|
|
81
|
+
const cfg = resolveConfigPaths(mergeConfig(base, user));
|
|
82
|
+
validateConfig(cfg);
|
|
83
|
+
return cfg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function loadConfigSyncFallback({ root = process.cwd() } = {}) {
|
|
87
|
+
return resolveConfigPaths(defaultConfig(root));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validateConfig(cfg) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
if (!cfg.root) errors.push('missing root');
|
|
93
|
+
if (!cfg.accountsFile) errors.push('missing accountsFile');
|
|
94
|
+
if (!cfg.contentDir) errors.push('missing contentDir');
|
|
95
|
+
if (!cfg.syncStateDir) errors.push('missing syncStateDir');
|
|
96
|
+
if (!cfg.manifestPath) errors.push('missing manifestPath');
|
|
97
|
+
if (!Array.isArray(cfg.readOnlyPortalIds)) errors.push('readOnlyPortalIds must be an array');
|
|
98
|
+
if (!Array.isArray(cfg.knownPortalIds)) errors.push('knownPortalIds must be an array');
|
|
99
|
+
if (!cfg.theme?.name) errors.push('theme.name is required');
|
|
100
|
+
if (!Array.isArray(cfg.theme?.dirs)) errors.push('theme.dirs must be an array');
|
|
101
|
+
if (!Array.isArray(cfg.theme?.files)) errors.push('theme.files must be an array');
|
|
102
|
+
if (errors.length) {
|
|
103
|
+
throw new Error(`Invalid hubspot-cms-sync config:\n${errors.map((e) => `- ${e}`).join('\n')}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readJsonFile(file, label = file) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
110
|
+
} catch (e) {
|
|
111
|
+
throw new Error(`Cannot read ${label} at ${file}: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|