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,234 @@
|
|
|
1
|
+
// sync/lib/canonical.mjs
|
|
2
|
+
//
|
|
3
|
+
// Canonicalization for clean, portable git diffs in the bidirectional
|
|
4
|
+
// git <-> HubSpot sync system.
|
|
5
|
+
//
|
|
6
|
+
// PURE: no I/O, no network, no `process`/`fs`. Every export is a pure
|
|
7
|
+
// function so it can be unit-tested without a HubSpot account (per the
|
|
8
|
+
// codex review tier-1 requirement). Higher layers (adapters) do the I/O
|
|
9
|
+
// and call into these helpers.
|
|
10
|
+
//
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// WHY canonicalization is RESOURCE/FIELD-SPECIFIC, not a blanket omit
|
|
13
|
+
// (codex finding #8, design §1.3 / §6.3):
|
|
14
|
+
//
|
|
15
|
+
// The naive approach — "recursively drop every null/'' and every empty
|
|
16
|
+
// object" — is WRONG for this corpus, and the codex review flagged it as a
|
|
17
|
+
// must-fix. Page MODULE CONTENT ("widget carriers": widgets /
|
|
18
|
+
// widgetContainers / layoutSections) is pushed back to HubSpot with a
|
|
19
|
+
// REPLACE-not-merge PATCH semantics: the complete carrier object is sent and
|
|
20
|
+
// HubSpot overwrites the whole widget, it does NOT merge field-by-field.
|
|
21
|
+
// That means a widget value of `css: {}`, `child_css: {}`, or `label: ""`
|
|
22
|
+
// is LOAD-BEARING — if canonicalization silently dropped it, the round-trip
|
|
23
|
+
// (pull -> canonical -> push) would send a thinner payload than HubSpot
|
|
24
|
+
// expects and blank-out / reset rendered styling. So inside widget carriers
|
|
25
|
+
// we KEEP empty css/child_css/label/body. The volatile-key stripping
|
|
26
|
+
// (stripVolatile) is therefore a *targeted* removal of per-account/volatile
|
|
27
|
+
// keys (ids, timestamps, urls, hashes, ...), NOT a "remove all empties" pass.
|
|
28
|
+
//
|
|
29
|
+
// Each exported transform documents exactly which fields it touches.
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
// Keys that are per-account or volatile and must NEVER land in a committed
|
|
33
|
+
// canonical file. Matched by exact name. *At / *ById are matched by suffix
|
|
34
|
+
// (see SUFFIX_AT / SUFFIX_BY_ID below) so new timestamp variants
|
|
35
|
+
// (publishDate, archivedAt, lastEditedAt, ...) are caught without an
|
|
36
|
+
// ever-growing list.
|
|
37
|
+
const VOLATILE_EXACT = new Set([
|
|
38
|
+
'id',
|
|
39
|
+
'currentState',
|
|
40
|
+
'url',
|
|
41
|
+
'hash',
|
|
42
|
+
'folder',
|
|
43
|
+
'children',
|
|
44
|
+
// publishDate is volatile per design §1.3 (HubSpot recomputes it on
|
|
45
|
+
// publish); it ends in "Date" not "At", so it is listed explicitly here.
|
|
46
|
+
'publishDate',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Suffix matchers for the families of volatile keys.
|
|
50
|
+
// *At -> createdAt, updatedAt, archivedAt, ...
|
|
51
|
+
// *ById -> createdById, updatedById, ...
|
|
52
|
+
function isVolatileKey(key, extra) {
|
|
53
|
+
if (VOLATILE_EXACT.has(key)) return true;
|
|
54
|
+
if (extra.has(key)) return true;
|
|
55
|
+
if (key.endsWith('ById')) return true;
|
|
56
|
+
// `...At` timestamp family (createdAt/updatedAt/archivedAt/...). Guard on
|
|
57
|
+
// length so a literal key named "At" (none in this corpus) doesn't trip,
|
|
58
|
+
// and avoid matching "...Cat"/"...Format" by requiring an uppercase boundary.
|
|
59
|
+
if (/[a-z0-9]At$/.test(key)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* stableStringify(obj) -> string
|
|
65
|
+
*
|
|
66
|
+
* Deterministic JSON serialization for diff-clean commits:
|
|
67
|
+
* - object keys recursively SORTED (lexicographic, default JS string sort)
|
|
68
|
+
* - 2-space indent
|
|
69
|
+
* - LF line endings (JSON.stringify never emits CRLF)
|
|
70
|
+
* - trailing newline
|
|
71
|
+
* - arrays keep their order (order is meaningful: layoutSection rows,
|
|
72
|
+
* stats, logos, ...)
|
|
73
|
+
*
|
|
74
|
+
* Does NOT strip or normalize values — pair it with stripVolatile /
|
|
75
|
+
* canonicalPage first. The only transform is key ordering + formatting, so
|
|
76
|
+
* two semantically-equal but differently-key-ordered inputs serialize byte
|
|
77
|
+
* identically.
|
|
78
|
+
*/
|
|
79
|
+
export function stableStringify(obj) {
|
|
80
|
+
return JSON.stringify(sortKeysDeep(obj), null, 2) + '\n';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Recursively return a structurally-equal value with all object keys sorted.
|
|
84
|
+
// Arrays are mapped in place (order preserved). Primitives pass through.
|
|
85
|
+
function sortKeysDeep(value) {
|
|
86
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
87
|
+
if (value && typeof value === 'object') {
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const key of Object.keys(value).sort()) {
|
|
90
|
+
out[key] = sortKeysDeep(value[key]);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* stripVolatile(obj, extraKeys=[]) -> obj
|
|
99
|
+
*
|
|
100
|
+
* Recursively removes per-account / volatile keys so the canonical tree
|
|
101
|
+
* carries no portal-specific identity. Returns a NEW value (does not mutate
|
|
102
|
+
* the input). Recurses through both objects and arrays.
|
|
103
|
+
*
|
|
104
|
+
* Removed: id, *At (createdAt/updatedAt/archivedAt/publishDate-family via
|
|
105
|
+
* explicit + suffix match), *ById, currentState, url, hash, folder, children,
|
|
106
|
+
* plus any names passed in `extraKeys`.
|
|
107
|
+
*
|
|
108
|
+
* This is a TARGETED key removal, NOT an empty/null omit (see the file
|
|
109
|
+
* header). Empty objects/strings that remain after stripping are preserved.
|
|
110
|
+
*/
|
|
111
|
+
export function stripVolatile(obj, extraKeys = []) {
|
|
112
|
+
const extra = extraKeys instanceof Set ? extraKeys : new Set(extraKeys);
|
|
113
|
+
return strip(obj, extra);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function strip(value, extra) {
|
|
117
|
+
if (Array.isArray(value)) return value.map((v) => strip(v, extra));
|
|
118
|
+
if (value && typeof value === 'object') {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [key, v] of Object.entries(value)) {
|
|
121
|
+
if (isVolatileKey(key, extra)) continue;
|
|
122
|
+
out[key] = strip(v, extra);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* slugToFile(slug) -> filename
|
|
131
|
+
*
|
|
132
|
+
* Maps a HubSpot page slug to a safe canonical filename stem.
|
|
133
|
+
* - '' (homepage) -> 'home'
|
|
134
|
+
* - '/' inside the slug -> '__' (e.g. 'blog/x' -> 'blog__x')
|
|
135
|
+
*
|
|
136
|
+
* The empty-slug homepage cannot be a filename, so it gets the 'home'
|
|
137
|
+
* sentinel. '/' is not legal in a path segment, so it is escaped to '__'.
|
|
138
|
+
*/
|
|
139
|
+
export function slugToFile(slug) {
|
|
140
|
+
if (slug === '' || slug == null) return 'home';
|
|
141
|
+
return String(slug).replace(/\//g, '__');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* fileToSlug(name) -> slug
|
|
146
|
+
*
|
|
147
|
+
* Inverse of slugToFile.
|
|
148
|
+
* - 'home' -> ''
|
|
149
|
+
* - 'blog__x' -> 'blog/x'
|
|
150
|
+
*
|
|
151
|
+
* A trailing '.json' (or any extension) should be stripped by the caller
|
|
152
|
+
* before calling; fileToSlug operates on the bare stem.
|
|
153
|
+
*/
|
|
154
|
+
export function fileToSlug(name) {
|
|
155
|
+
if (name === 'home' || name === '' || name == null) return '';
|
|
156
|
+
return String(name).replace(/__/g, '/');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fields kept on a canonical page widget carrier value. Per codex finding #8
|
|
160
|
+
// these are kept VERBATIM even when empty (css/child_css/label), because the
|
|
161
|
+
// push is replace-not-merge.
|
|
162
|
+
const WIDGET_KEEP = ['body', 'name', 'type', 'label', 'css', 'child_css'];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* canonicalPage(rawPage) -> canonical page definition
|
|
166
|
+
*
|
|
167
|
+
* Produces the portable, slug-keyed page DEFINITION object:
|
|
168
|
+
* { slug, name, htmlTitle, metaDescription, language, templatePath, widgets }
|
|
169
|
+
*
|
|
170
|
+
* - slug: raw slug, defaulting to '' (homepage). NOT the volatile id.
|
|
171
|
+
* - name/htmlTitle/metaDescription: SEO/definition fields, defaulted to ''.
|
|
172
|
+
* - language: defaults to 'en'.
|
|
173
|
+
* - templatePath: kept as-is (the manifest-driven rewrite of non-portable
|
|
174
|
+
* marketplace/generated paths is a separate adapter
|
|
175
|
+
* concern, intentionally NOT done here so this stays pure).
|
|
176
|
+
* - widgets: normalized widget carrier map (see normalizeWidgets).
|
|
177
|
+
*
|
|
178
|
+
* Volatile page-level keys (id, url, *At, currentState, publishDate, ...) are
|
|
179
|
+
* simply not projected here. We project an explicit allow-list of definition
|
|
180
|
+
* fields rather than strip-everything-else, so the schema is stable and
|
|
181
|
+
* documented.
|
|
182
|
+
*/
|
|
183
|
+
export function canonicalPage(rawPage) {
|
|
184
|
+
const raw = rawPage || {};
|
|
185
|
+
return {
|
|
186
|
+
slug: raw.slug ?? '',
|
|
187
|
+
name: raw.name ?? '',
|
|
188
|
+
htmlTitle: raw.htmlTitle ?? '',
|
|
189
|
+
metaDescription: raw.metaDescription ?? '',
|
|
190
|
+
language: raw.language ?? 'en',
|
|
191
|
+
templatePath: raw.templatePath ?? '',
|
|
192
|
+
widgets: normalizeWidgets(raw.widgets),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* normalizeWidgets(widgets) -> normalized widget carrier map
|
|
198
|
+
*
|
|
199
|
+
* For each widget instance (keyed by instance name), keep exactly the
|
|
200
|
+
* carrier fields HubSpot needs for a replace-not-merge PATCH:
|
|
201
|
+
* body, name, type, label, css, child_css
|
|
202
|
+
*
|
|
203
|
+
* CRITICAL (codex #8): empty css/child_css/label and body sub-fields are
|
|
204
|
+
* KEPT, not omitted. `body` is passed through unchanged (its per-field values
|
|
205
|
+
* — including empty strings like section_id:'' — are the actual content and
|
|
206
|
+
* must survive). Only keys OUTSIDE the WIDGET_KEEP set (e.g. a stray volatile
|
|
207
|
+
* `id` HubSpot might echo on a widget) are dropped.
|
|
208
|
+
*
|
|
209
|
+
* Default empties are supplied for any missing carrier field so the pushed
|
|
210
|
+
* payload is always complete: label -> '', css/child_css -> {}, type ->
|
|
211
|
+
* 'module', body -> {}.
|
|
212
|
+
*/
|
|
213
|
+
export function normalizeWidgets(widgets) {
|
|
214
|
+
if (!widgets || typeof widgets !== 'object') return {};
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [instanceName, raw] of Object.entries(widgets)) {
|
|
217
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
218
|
+
const w = {};
|
|
219
|
+
for (const field of WIDGET_KEEP) {
|
|
220
|
+
if (field in raw) {
|
|
221
|
+
w[field] = raw[field];
|
|
222
|
+
} else {
|
|
223
|
+
// Supply explicit empties so the carrier is complete for push.
|
|
224
|
+
if (field === 'css' || field === 'child_css') w[field] = {};
|
|
225
|
+
else if (field === 'label') w[field] = '';
|
|
226
|
+
else if (field === 'type') w[field] = 'module';
|
|
227
|
+
else if (field === 'name') w[field] = instanceName;
|
|
228
|
+
else if (field === 'body') w[field] = {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
out[instanceName] = w;
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
package/src/lib/hub.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// sync/lib/hub.mjs — account + HTTP layer for HubSpot bidirectional sync.
|
|
2
|
+
//
|
|
3
|
+
// The account/HTTP plumbing shared by every adapter and orchestrator. Pure,
|
|
4
|
+
// importable, unit-testable: account resolution and paging accumulation do not
|
|
5
|
+
// touch the network (paging is driven through an injectable hub function), and
|
|
6
|
+
// slug matching is a pure helper over a fetched list.
|
|
7
|
+
//
|
|
8
|
+
// Accounts: sync/accounts.json maps name -> { portalId, label }.
|
|
9
|
+
// Keys: per-portal service keys (Bearer) at $HUBSPOT_KEY_DIR/<portalId>.key
|
|
10
|
+
// (default ~/.hubspot/<portalId>.key). Never committed.
|
|
11
|
+
//
|
|
12
|
+
// Production (portalId 529456) is READ-ONLY. This module provides NO default
|
|
13
|
+
// that writes to prod — callers pass an explicit account object every time.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { loadConfigSyncFallback } from '../config.mjs';
|
|
20
|
+
|
|
21
|
+
const API = 'https://api.hubapi.com';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
function fallbackConfig() {
|
|
26
|
+
return loadConfigSyncFallback();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function keyDir(cfg = fallbackConfig()) {
|
|
30
|
+
return cfg.keyDir || process.env[cfg.keyDirEnv || 'HUBSPOT_KEY_DIR'] || join(homedir(), '.hubspot');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load and parse sync/accounts.json.
|
|
35
|
+
* @returns {object} parsed account registry (name -> { portalId, label })
|
|
36
|
+
*/
|
|
37
|
+
export function loadAccounts(cfg = fallbackConfig()) {
|
|
38
|
+
const accountsPath = cfg.accountsPath || join(cfg.root || process.cwd(), cfg.accountsFile || 'sync/accounts.json');
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(accountsPath, 'utf8');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new Error(`Cannot read accounts file at ${accountsPath}: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new Error(`Invalid JSON in ${accountsPath}: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve an account by name to { name, portalId, key }.
|
|
54
|
+
* Reads the per-portal service key from $HUBSPOT_KEY_DIR/<portalId>.key.
|
|
55
|
+
* @param {string} name account name as keyed in accounts.json
|
|
56
|
+
* @returns {{ name: string, portalId: string, key: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function account(name, cfg = fallbackConfig()) {
|
|
59
|
+
const accounts = loadAccounts(cfg);
|
|
60
|
+
const entry = accounts[name];
|
|
61
|
+
if (!entry || typeof entry !== 'object' || !entry.portalId) {
|
|
62
|
+
const known = Object.keys(accounts)
|
|
63
|
+
.filter((k) => !k.startsWith('_'))
|
|
64
|
+
.join(', ');
|
|
65
|
+
throw new Error(`Unknown account "${name}". Known accounts: ${known || '(none)'}`);
|
|
66
|
+
}
|
|
67
|
+
const portalId = String(entry.portalId);
|
|
68
|
+
const dir = keyDir(cfg);
|
|
69
|
+
const keyFile = join(dir, `${portalId}.key`);
|
|
70
|
+
if (!existsSync(keyFile)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`No key for account "${name}" (portal ${portalId}) at ${keyFile}\n` +
|
|
73
|
+
` Create it: printf '%s' 'pat-naX-...' > ${keyFile} && chmod 600 ${keyFile}\n` +
|
|
74
|
+
` (override the directory with $HUBSPOT_KEY_DIR)`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const key = readFileSync(keyFile, 'utf8').trim();
|
|
78
|
+
if (!key) {
|
|
79
|
+
throw new Error(`Key file ${keyFile} for account "${name}" is empty.`);
|
|
80
|
+
}
|
|
81
|
+
return { name, portalId, key };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Make an authenticated JSON request against the HubSpot API.
|
|
86
|
+
* Never throws on a non-2xx status — returns { ok, status, json } so callers
|
|
87
|
+
* decide how to react (some endpoints, e.g. slug lookups, treat 404 as "none").
|
|
88
|
+
* @param {{ key: string }} acct account object from account()
|
|
89
|
+
* @param {string} method HTTP method
|
|
90
|
+
* @param {string} path API path beginning with '/'
|
|
91
|
+
* @param {*} [body] optional JSON body
|
|
92
|
+
* @returns {Promise<{ ok: boolean, status: number, json: any }>}
|
|
93
|
+
*/
|
|
94
|
+
export async function hub(acct, method, path, body) {
|
|
95
|
+
if (!acct || !acct.key) {
|
|
96
|
+
throw new Error('hub() requires an account object with a key (from account()).');
|
|
97
|
+
}
|
|
98
|
+
const res = await fetch(API + path, {
|
|
99
|
+
method,
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${acct.key}`,
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
},
|
|
104
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
105
|
+
});
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
let json;
|
|
108
|
+
try {
|
|
109
|
+
json = text ? JSON.parse(text) : {};
|
|
110
|
+
} catch {
|
|
111
|
+
json = { raw: text };
|
|
112
|
+
}
|
|
113
|
+
return { ok: res.ok, status: res.status, json };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Follow v3 paging (paging.next.after), accumulating results[].
|
|
118
|
+
* Throws with a clear message on any non-ok page.
|
|
119
|
+
* @param {{ key: string }} acct account object
|
|
120
|
+
* @param {string} path API path (query string allowed)
|
|
121
|
+
* @returns {Promise<any[]>} concatenated results
|
|
122
|
+
*/
|
|
123
|
+
export async function getAll(acct, path) {
|
|
124
|
+
const out = [];
|
|
125
|
+
let after;
|
|
126
|
+
do {
|
|
127
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
128
|
+
const url = `${path}${sep}limit=100${after ? `&after=${after}` : ''}`;
|
|
129
|
+
const { ok, status, json } = await hub(acct, 'GET', url);
|
|
130
|
+
if (!ok) {
|
|
131
|
+
const msg = json?.message || json?.category || JSON.stringify(json).slice(0, 200);
|
|
132
|
+
throw new Error(`GET ${url} -> ${status}: ${msg}`);
|
|
133
|
+
}
|
|
134
|
+
out.push(...(json.results || []));
|
|
135
|
+
after = json.paging?.next?.after;
|
|
136
|
+
} while (after);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pure: pick the page id whose slug matches `slug` from a v3 page list.
|
|
142
|
+
* '' (empty string) addresses the homepage. Exposed for unit testing.
|
|
143
|
+
* @param {any[]} pages array of page objects ({ id, slug })
|
|
144
|
+
* @param {string} slug normalized slug ('' = homepage)
|
|
145
|
+
* @returns {string|null} matched page id or null
|
|
146
|
+
*/
|
|
147
|
+
export function matchPageSlug(pages, slug) {
|
|
148
|
+
const want = slug == null ? '' : String(slug);
|
|
149
|
+
for (const p of pages) {
|
|
150
|
+
if (String(p.slug ?? '') === want) return String(p.id);
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a CMS site page id by slug ('' = homepage). Read call; returns null
|
|
157
|
+
* when no page matches.
|
|
158
|
+
* @param {{ key: string }} acct account object
|
|
159
|
+
* @param {string} slug normalized slug
|
|
160
|
+
* @returns {Promise<string|null>}
|
|
161
|
+
*/
|
|
162
|
+
export async function resolvePageBySlug(acct, slug) {
|
|
163
|
+
const pages = await getAll(acct, '/cms/v3/pages/site-pages');
|
|
164
|
+
return matchPageSlug(pages, slug);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Pure: pick the legacy blog contentGroupId whose slug matches from the v2
|
|
169
|
+
* /content/api/v2/blogs `objects` array. Exposed for unit testing.
|
|
170
|
+
* @param {any[]} blogs array of blog containers ({ id, slug })
|
|
171
|
+
* @param {string} slug normalized blog slug
|
|
172
|
+
* @returns {string|null} matched contentGroupId or null
|
|
173
|
+
*/
|
|
174
|
+
export function matchBlogSlug(blogs, slug) {
|
|
175
|
+
const want = slug == null ? '' : String(slug);
|
|
176
|
+
for (const b of blogs) {
|
|
177
|
+
if (String(b.slug ?? '') === want) return String(b.id);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve a blog container (contentGroupId) by slug via the legacy
|
|
184
|
+
* /content/api/v2/blogs endpoint. Returns null when no blog matches.
|
|
185
|
+
* Matching is by slug — never objects[0] — so a stale "Old" blog cannot win.
|
|
186
|
+
* @param {{ key: string }} acct account object
|
|
187
|
+
* @param {string} slug normalized blog slug
|
|
188
|
+
* @returns {Promise<string|null>}
|
|
189
|
+
*/
|
|
190
|
+
export async function resolveBlogBySlug(acct, slug) {
|
|
191
|
+
const { ok, status, json } = await hub(acct, 'GET', '/content/api/v2/blogs?limit=100');
|
|
192
|
+
if (!ok) {
|
|
193
|
+
const msg = json?.message || json?.category || JSON.stringify(json).slice(0, 200);
|
|
194
|
+
throw new Error(`GET /content/api/v2/blogs -> ${status}: ${msg}`);
|
|
195
|
+
}
|
|
196
|
+
return matchBlogSlug(json.objects || [], slug);
|
|
197
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// sync/lib/orchestrate.mjs — shared adapter loading + dependency ordering.
|
|
2
|
+
//
|
|
3
|
+
// The pull/push orchestrators (sync/pull.mjs, sync/push.mjs) both need to:
|
|
4
|
+
// 1. discover every adapter under sync/adapters/*.mjs and key it by `name`, and
|
|
5
|
+
// 2. run those adapters in DEPENDENCY ORDER (an adapter's `dependsOn` names must
|
|
6
|
+
// have run first).
|
|
7
|
+
//
|
|
8
|
+
// On PUSH the order is load-bearing: the `forms` and `assets` adapters POPULATE the
|
|
9
|
+
// per-account registry (logical key -> target id / hosted url) that downstream
|
|
10
|
+
// adapters (theme, pages, content, blog) RESOLVE via refs.resolve. Run a consumer
|
|
11
|
+
// before its producer and resolve() hard-fails on an unmapped ref. topoSort encodes
|
|
12
|
+
// that contract from each adapter's declared `dependsOn`, so neither orchestrator
|
|
13
|
+
// hardcodes a sequence — add an adapter, declare its deps, and the order follows.
|
|
14
|
+
//
|
|
15
|
+
// On PULL order is not strictly required (pull is read-only and auto-registers refs),
|
|
16
|
+
// but we run the SAME topo order for determinism and so a producer's registry entries
|
|
17
|
+
// exist before a consumer pulls (e.g. forms register source GUIDs first).
|
|
18
|
+
//
|
|
19
|
+
// PURE except for loadAdapters' dynamic import: topoSort is a pure function over a
|
|
20
|
+
// {name: {dependsOn}} map, so it unit-tests with fake adapter modules and no fs.
|
|
21
|
+
|
|
22
|
+
import { readdir } from 'node:fs/promises';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
// sync/lib/orchestrate.mjs -> sync/adapters/
|
|
28
|
+
const ADAPTERS_DIR = join(__dirname, '..', 'adapters');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* loadAdapters(dir?) -> { [name]: module }
|
|
32
|
+
*
|
|
33
|
+
* Dynamically import every `*.mjs` under sync/adapters/ and key each module by its
|
|
34
|
+
* exported `name`. Each adapter module exports: name, dependsOn[], pull(), push().
|
|
35
|
+
* Throws if two adapters declare the same `name` (ambiguous graph) or a module is
|
|
36
|
+
* missing its `name`.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} [dir] override the adapters directory (used by tests)
|
|
39
|
+
* @returns {Promise<Record<string, any>>}
|
|
40
|
+
*/
|
|
41
|
+
export async function loadAdapters(dir = ADAPTERS_DIR) {
|
|
42
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
43
|
+
const files = entries
|
|
44
|
+
.filter((e) => e.isFile() && e.name.endsWith('.mjs'))
|
|
45
|
+
.map((e) => e.name)
|
|
46
|
+
.sort(); // deterministic import order
|
|
47
|
+
|
|
48
|
+
const adapters = {};
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const mod = await import(pathToFileURL(join(dir, file)).href);
|
|
51
|
+
const name = mod.name ?? mod.default?.name;
|
|
52
|
+
if (!name) {
|
|
53
|
+
throw new Error(`Adapter ${file} does not export a \`name\``);
|
|
54
|
+
}
|
|
55
|
+
if (adapters[name]) {
|
|
56
|
+
throw new Error(`Duplicate adapter name "${name}" (in ${file})`);
|
|
57
|
+
}
|
|
58
|
+
// Normalize to a single object carrying name/dependsOn/pull/push regardless of
|
|
59
|
+
// whether the adapter exported them individually or via `default`.
|
|
60
|
+
const def = mod.default ?? mod;
|
|
61
|
+
adapters[name] = {
|
|
62
|
+
name,
|
|
63
|
+
dependsOn: def.dependsOn ?? mod.dependsOn ?? [],
|
|
64
|
+
pull: def.pull ?? mod.pull,
|
|
65
|
+
push: def.push ?? mod.push,
|
|
66
|
+
module: mod,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return adapters;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* topoSort(adapters) -> string[]
|
|
74
|
+
*
|
|
75
|
+
* Kahn/DFS topological sort of adapter NAMES by their `dependsOn`. A name appears
|
|
76
|
+
* AFTER every name it depends on. Deterministic: ties are broken alphabetically so a
|
|
77
|
+
* given graph always yields the same order.
|
|
78
|
+
*
|
|
79
|
+
* Throws on:
|
|
80
|
+
* - an unknown dependency (a `dependsOn` entry with no matching adapter), and
|
|
81
|
+
* - a dependency cycle (naming the offending nodes).
|
|
82
|
+
*
|
|
83
|
+
* `adapters` is any map of `name -> { dependsOn: string[] }` (the real adapter
|
|
84
|
+
* registry, or a fake one in tests).
|
|
85
|
+
*
|
|
86
|
+
* @param {Record<string, { dependsOn?: string[] }>} adapters
|
|
87
|
+
* @returns {string[]} adapter names in dependency order
|
|
88
|
+
*/
|
|
89
|
+
export function topoSort(adapters) {
|
|
90
|
+
const names = Object.keys(adapters).sort(); // stable, alphabetical tie-break
|
|
91
|
+
|
|
92
|
+
// Validate dependencies up front so the error names the missing dep, not a
|
|
93
|
+
// confusing "cycle" later.
|
|
94
|
+
for (const name of names) {
|
|
95
|
+
for (const dep of adapters[name].dependsOn ?? []) {
|
|
96
|
+
if (!(dep in adapters)) {
|
|
97
|
+
throw new Error(`Adapter "${name}" dependsOn unknown adapter "${dep}"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Kahn's algorithm: repeatedly emit a node whose deps are all already emitted.
|
|
103
|
+
// Among the currently-ready nodes we pick the alphabetically-first, so roots come
|
|
104
|
+
// out first and the order is fully deterministic (e.g. assets, forms before their
|
|
105
|
+
// consumers). This keeps producers (forms/assets) ahead of consumers and reads
|
|
106
|
+
// naturally in the orchestrator's printed order.
|
|
107
|
+
const remaining = new Set(names);
|
|
108
|
+
const order = [];
|
|
109
|
+
|
|
110
|
+
while (remaining.size) {
|
|
111
|
+
const ready = [...remaining]
|
|
112
|
+
.filter((name) => (adapters[name].dependsOn ?? []).every((dep) => !remaining.has(dep)))
|
|
113
|
+
.sort();
|
|
114
|
+
if (ready.length === 0) {
|
|
115
|
+
// Everything left is part of (or blocked by) a cycle. Report the offenders.
|
|
116
|
+
const cycle = describeCycle(adapters, remaining);
|
|
117
|
+
throw new Error(`Dependency cycle detected: ${cycle}`);
|
|
118
|
+
}
|
|
119
|
+
const next = ready[0];
|
|
120
|
+
order.push(next);
|
|
121
|
+
remaining.delete(next);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return order;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Walk dependency edges among the still-unresolved nodes to surface a concrete cycle
|
|
128
|
+
// chain (a -> b -> c -> a) for the error message.
|
|
129
|
+
function describeCycle(adapters, remaining) {
|
|
130
|
+
const start = [...remaining].sort()[0];
|
|
131
|
+
const path = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
let node = start;
|
|
134
|
+
while (node != null && !seen.has(node)) {
|
|
135
|
+
seen.add(node);
|
|
136
|
+
path.push(node);
|
|
137
|
+
node = (adapters[node].dependsOn ?? []).find((dep) => remaining.has(dep));
|
|
138
|
+
}
|
|
139
|
+
if (node != null) path.push(node); // close the loop back to a repeated node
|
|
140
|
+
return path.join(' -> ');
|
|
141
|
+
}
|