hubspot-cms-sync 0.1.0 → 0.3.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 +1 -1
  2. package/README.md +52 -12
  3. package/bin/hubspot-cms-sync.mjs +123 -94
  4. package/docs/CONFIGURATION.md +5 -0
  5. package/docs/CONTENT_LAYOUT.md +146 -0
  6. package/docs/GITHUB_ACTIONS.md +3 -1
  7. package/docs/HUBSPOT-SYNC-NOTES.md +123 -0
  8. package/examples/hubspot-cms-sync.config.mjs +1 -0
  9. package/examples/minimal-site/content/assets/hero.svg +9 -0
  10. package/examples/minimal-site/content/blog/container.json +6 -0
  11. package/examples/minimal-site/content/blog/posts/blog__hello-world.json +13 -0
  12. package/examples/minimal-site/content/forms/contact.json +18 -0
  13. package/examples/minimal-site/content/forms/properties.json +7 -0
  14. package/examples/minimal-site/content/pages/about.json +9 -0
  15. package/examples/minimal-site/content/pages/home.json +9 -0
  16. package/examples/minimal-site/content/pages/home.widgets.json +17 -0
  17. package/examples/minimal-site/css/main.css +3 -0
  18. package/examples/minimal-site/fields.json +11 -0
  19. package/examples/minimal-site/hubspot-cms-sync.config.mjs +27 -0
  20. package/examples/minimal-site/js/hs-forms.js +1 -0
  21. package/examples/minimal-site/modules/hero.module/fields.json +14 -0
  22. package/examples/minimal-site/modules/hero.module/module.html +4 -0
  23. package/examples/minimal-site/site.manifest.json +29 -0
  24. package/examples/minimal-site/sync/accounts.json +10 -0
  25. package/examples/minimal-site/sync/redirects.csv +2 -0
  26. package/examples/minimal-site/templates/blog-post.html +8 -0
  27. package/examples/minimal-site/templates/blog.html +9 -0
  28. package/examples/minimal-site/templates/home.html +5 -0
  29. package/examples/minimal-site/templates/page.html +7 -0
  30. package/examples/minimal-site/theme.json +4 -0
  31. package/package.json +17 -3
  32. package/skill/references/commands.md +4 -0
  33. package/skill/references/config.md +6 -2
  34. package/src/config.mjs +5 -0
  35. package/src/index.mjs +1 -0
  36. package/src/lib/content-view.mjs +168 -0
  37. package/src/lib/posts-format.mjs +90 -0
  38. package/src/lib/render.mjs +153 -0
  39. package/src/preflight.mjs +97 -8
  40. package/src/redirects.mjs +283 -0
@@ -0,0 +1,153 @@
1
+ // src/lib/render.mjs — HubL -> HTML rendering for the STATIC target.
2
+ //
3
+ // This is the deliberately HubSpot-FLAVORED part of the toolkit. HubSpot renders
4
+ // HubL server-side; a static target (Cloudflare Pages, plain dir) has no server,
5
+ // so the toolkit must render the same templates itself at build time. The engine
6
+ // is Nunjucks (Jinja2-flavored, like HubL) plus a small, finite compatibility
7
+ // layer for the handful of constructs HubL has that Nunjucks does not.
8
+ //
9
+ // Inputs are NEUTRAL views from lib/content-view.mjs — the renderer never reads a
10
+ // `widgets.x.body` carrier, a field GUID, or the string "PUBLISHED". It maps the
11
+ // neutral view onto the snake_case `content.*` variable contract the HubL
12
+ // templates reference (HubSpot exposes its model to HubL in snake_case), and
13
+ // shims the HubL-only globals/filters/tags.
14
+ //
15
+ // HubL constructs handled:
16
+ // - {{ get_asset_url('../css/main.css') }} -> root-relative "/css/main.css"
17
+ // - {% include "../templates/shared-nav.html" %} (path normalized in loader)
18
+ // - blog_recent_posts(group, n) -> recent neutral posts as content shims
19
+ // - standard_header_includes / standard_footer_includes -> injected strings
20
+ // - x[:2] / x[1:] / x[1:3] Python-style slices -> |hubslice() (preprocess)
21
+ // - {% module %} -> see module-tag extension (added for page rendering)
22
+ //
23
+ // Pure except for the loader's synchronous template file reads.
24
+
25
+ import { readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import nunjucks from 'nunjucks';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // HubL -> Nunjucks source preprocessing. Applied to every template the loader
31
+ // serves. Currently: Python/Jinja slice subscripts, which HubL supports and
32
+ // Nunjucks does not. `name[:2]` -> `name|hubslice(0,null)`-style rewrite. Only
33
+ // dotted identifier targets are matched (covers the corpus: author name initials).
34
+ // ---------------------------------------------------------------------------
35
+ const SLICE_RE = /([A-Za-z_][\w.]*)\[(\d*):(\d*)\]/g;
36
+
37
+ export function preprocessHubl(src) {
38
+ return src.replace(SLICE_RE, (_m, expr, a, b) => {
39
+ const start = a === '' ? '0' : a;
40
+ const end = b === '' ? 'null' : b;
41
+ return `${expr}|hubslice(${start},${end})`;
42
+ });
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Loader: resolves HubSpot-style template-relative include paths against the
47
+ // theme root and preprocesses HubL source on the way out. "../templates/x.html"
48
+ // and "templates/x.html" both resolve to <siteDir>/templates/x.html — every
49
+ // include in the corpus is template-relative ("../templates/..."), so stripping
50
+ // leading ./ .. segments yields the theme-root-relative path.
51
+ // ---------------------------------------------------------------------------
52
+ function makeLoader(siteDir) {
53
+ return {
54
+ async: false,
55
+ getSource(name) {
56
+ const rel = name.split('/').filter((p) => p && p !== '.' && p !== '..').join('/');
57
+ const full = join(siteDir, rel);
58
+ const raw = readFileSync(full, 'utf8');
59
+ return { src: preprocessHubl(raw), path: full, noCache: true };
60
+ },
61
+ };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Static ref resolution. The HubSpot target resolves @asset:/@portal to portal
66
+ // GUIDs + hosted hubfs URLs (lib/refs.mjs); the static target resolves them to
67
+ // local paths under the deployed site. Minimal for the spike: @asset:NAME ->
68
+ // /assets/NAME, applied to attribute values and rich-text bodies alike.
69
+ // ---------------------------------------------------------------------------
70
+ export function resolveStaticRefs(value, { assetBase = '/assets' } = {}) {
71
+ if (value == null) return value;
72
+ return String(value).replace(/@asset:([^\s"'<>)]+)/g, (_m, nameRef) => `${assetBase}/${nameRef}`);
73
+ }
74
+
75
+ // get_asset_url('../css/main.css') -> "/css/main.css". Theme assets live at the
76
+ // repo root (css/ js/ images/); HubL refs them template-relative with leading ../.
77
+ function assetUrl(path) {
78
+ return '/' + String(path).replace(/^(\.\.\/)+/, '').replace(/^\/+/, '');
79
+ }
80
+
81
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
82
+ 'July', 'August', 'September', 'October', 'November', 'December'];
83
+
84
+ function localizeDate(iso) {
85
+ if (!iso) return '';
86
+ const d = new Date(iso);
87
+ if (Number.isNaN(d.getTime())) return '';
88
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Neutral post view -> HubL `content` shim (snake_case, refs resolved for the
93
+ // static target). Reused for the page being rendered AND for related-post cards
94
+ // returned by blog_recent_posts().
95
+ // ---------------------------------------------------------------------------
96
+ function postContent(post, { baseUrl = '', assetBase = '/assets' } = {}) {
97
+ const author = post.author || null;
98
+ return {
99
+ name: post.title,
100
+ html_title: post.htmlTitle,
101
+ meta_description: post.metaDescription,
102
+ post_body: resolveStaticRefs(post.body, { assetBase }),
103
+ post_summary: resolveStaticRefs(post.summary, { assetBase }),
104
+ publish_date_localized: localizeDate(post.publishDate),
105
+ featured_image: post.featuredImage ? resolveStaticRefs(post.featuredImage, { assetBase }) : '',
106
+ featured_image_alt_text: post.featuredImageAlt,
107
+ tag_list: post.tags.map((t) => ({ name: t })),
108
+ blog_post_author: author ? { display_name: author.name, bio: resolveStaticRefs(author.bio, { assetBase }) } : null,
109
+ absolute_url: baseUrl + post.route,
110
+ canonical_url: baseUrl + post.route,
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Environment factory. One env per render call keeps globals (blog_recent_posts
116
+ // closure, header/footer includes) bound to the current site + options.
117
+ // ---------------------------------------------------------------------------
118
+ function makeEnv(siteDir, { site, opts }) {
119
+ const env = new nunjucks.Environment(makeLoader(siteDir), { autoescape: false, throwOnUndefined: false });
120
+
121
+ env.addFilter('hubslice', (str, start, end) =>
122
+ end === null || end === undefined ? String(str ?? '').slice(start) : String(str ?? '').slice(start, end));
123
+
124
+ env.addGlobal('get_asset_url', assetUrl);
125
+ env.addGlobal('html_lang', opts.lang || 'en');
126
+ env.addGlobal('html_lang_dir', '');
127
+ env.addGlobal('standard_header_includes', opts.headerIncludes || '');
128
+ env.addGlobal('standard_footer_includes', opts.footerIncludes || '');
129
+
130
+ // blog_recent_posts('default', n) — HubL's recent-posts query. Backed by the
131
+ // build-time neutral corpus, newest-first, projected to content shims.
132
+ env.addGlobal('blog_recent_posts', (_group, count) =>
133
+ (site?.posts || []).slice(0, count || 5).map((p) => postContent(p, opts)));
134
+
135
+ return env;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Public: render one neutral post view to an HTML string.
140
+ // ---------------------------------------------------------------------------
141
+ export function renderPost(post, { siteDir, site, baseUrl = '', assetBase = '/assets', lang = 'en',
142
+ headerIncludes = '', footerIncludes = '', template = 'templates/blog-post.html' } = {}) {
143
+ const opts = { baseUrl, assetBase, lang, headerIncludes, footerIncludes };
144
+ const env = makeEnv(siteDir, { site, opts });
145
+ const context = {
146
+ content: postContent(post, opts),
147
+ nav_active: null,
148
+ nav_hide_cta: false,
149
+ };
150
+ return env.render(template, context);
151
+ }
152
+
153
+ export { postContent, assetUrl, localizeDate, makeEnv };
package/src/preflight.mjs CHANGED
@@ -86,6 +86,62 @@ export function manifestBlogSlug(repoRoot = REPO_ROOT) {
86
86
  return DEFAULT_BLOG_SLUG;
87
87
  }
88
88
 
89
+ function readJsonIfPresent(path) {
90
+ if (!existsSync(path)) return null;
91
+ try {
92
+ return JSON.parse(readFileSync(path, 'utf8'));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function containerFileFor(slug) {
99
+ if (slug === 'blog' || !slug) return 'container.json';
100
+ return `container.${String(slug).replace(/\//g, '__')}.json`;
101
+ }
102
+
103
+ function pointsAtTheme(path, themeName) {
104
+ const p = String(path || '');
105
+ return p.includes(`${themeName}/`) || p.includes(`${themeName}\\`);
106
+ }
107
+
108
+ /**
109
+ * Inspect the local repo to decide which portal drift can be repaired by a
110
+ * normal push. This never claims HubSpot-created prerequisites are repairable:
111
+ * the blog container itself, service-key scopes, and domain connection remain
112
+ * external setup.
113
+ */
114
+ export function sourceRepairability(repoRoot = REPO_ROOT, opts = {}) {
115
+ const themeName = opts.themeName ?? THEME_NAME;
116
+ const blogSlug = opts.blogSlug ?? manifestBlogSlug(repoRoot);
117
+ const contentDir = opts.contentDirPath || join(repoRoot, 'content');
118
+ const manifestPath = opts.manifestFilePath || join(repoRoot, 'site.manifest.json');
119
+
120
+ const container = readJsonIfPresent(join(contentDir, 'blog', containerFileFor(blogSlug)));
121
+ const itemTemplate = container?.itemTemplatePath || opts.blogItemTemplate || '';
122
+ const listingTemplate = container?.listingTemplatePath || opts.blogListingTemplate || '';
123
+ const blogTemplates =
124
+ !!container &&
125
+ String(container.slug ?? blogSlug) === String(blogSlug) &&
126
+ pointsAtTheme(itemTemplate, themeName) &&
127
+ pointsAtTheme(listingTemplate, themeName);
128
+
129
+ const manifest = readJsonIfPresent(manifestPath);
130
+ const rootEntry = (manifest?.pages || []).find((p) => String(p?.slug ?? '') === '');
131
+ const home = readJsonIfPresent(join(contentDir, 'pages', 'home.json'));
132
+ const manifestPublishesRoot = !!rootEntry && (rootEntry.desiredState || 'publish') === 'publish';
133
+ const homePublishesRoot =
134
+ !!home &&
135
+ String(home.slug ?? '') === '' &&
136
+ (home.desiredState || rootEntry?.desiredState || 'publish') === 'publish' &&
137
+ !!home.templatePath;
138
+
139
+ return {
140
+ blogTemplates,
141
+ homepage: manifestPublishesRoot && homePublishesRoot,
142
+ };
143
+ }
144
+
89
145
  // ---------------------------------------------------------------------------
90
146
  // gatherProbes(acct, { blogSlug, hub, getAll, resolveBlogBySlug, resolvePageBySlug })
91
147
  // -> probes object consumed by evaluateReadiness.
@@ -181,6 +237,8 @@ export async function gatherProbes(acct, opts = {}) {
181
237
  export function evaluateReadiness(probes, opts = {}) {
182
238
  const blogSlug = opts.blogSlug ?? probes.blogSlug ?? DEFAULT_BLOG_SLUG;
183
239
  const themeName = opts.themeName ?? THEME_NAME;
240
+ const allowRepairable = !!opts.allowRepairable;
241
+ const repairable = opts.repairable || {};
184
242
  const checks = [];
185
243
 
186
244
  const add = (c) => checks.push({ reportOnly: false, ...c });
@@ -212,11 +270,22 @@ export function evaluateReadiness(probes, opts = {}) {
212
270
  // Container exists — confirm it points at the theme's blog templates.
213
271
  const item = blog.itemTemplatePath || '';
214
272
  const listing = blog.listingTemplatePath || '';
215
- const pointsAtTheme = (p) => p.includes(`${themeName}/`) || p.includes(`${themeName}\\`);
216
- const itemOk = pointsAtTheme(item);
217
- const listingOk = pointsAtTheme(listing);
273
+ const itemOk = pointsAtTheme(item, themeName);
274
+ const listingOk = pointsAtTheme(listing, themeName);
218
275
  if (itemOk && listingOk) {
219
276
  add({ id: 'blog-container', ok: true, detail: `blog "${blogSlug}" exists and uses ${themeName} templates` });
277
+ } else if (allowRepairable && repairable.blogTemplates) {
278
+ const wrong = [];
279
+ if (!itemOk) wrong.push(`post/item template "${item || '(unset)'}"`);
280
+ if (!listingOk) wrong.push(`listing template "${listing || '(unset)'}"`);
281
+ add({
282
+ id: 'blog-templates',
283
+ ok: true,
284
+ repairable: true,
285
+ detail:
286
+ `blog "${blogSlug}" exists but ${wrong.join(' and ')} not under ${themeName}; ` +
287
+ `source push will set the committed blog templates`,
288
+ });
220
289
  } else {
221
290
  const wrong = [];
222
291
  if (!itemOk) wrong.push(`post/item template "${item || '(unset)'}"`);
@@ -241,6 +310,13 @@ export function evaluateReadiness(probes, opts = {}) {
241
310
  detail: `cannot list site pages (HTTP ${homepage.status}${homepage.message ? `: ${homepage.message}` : ''})`,
242
311
  remediation: `Grant the service key the "content" scope so the homepage can be confirmed.`,
243
312
  });
313
+ } else if (!homepage.found && allowRepairable && repairable.homepage) {
314
+ add({
315
+ id: 'homepage',
316
+ ok: true,
317
+ repairable: true,
318
+ detail: `no site page resolves at the root slug ''; source push will create and publish the committed root page`,
319
+ });
244
320
  } else if (!homepage.found) {
245
321
  add({
246
322
  id: 'homepage',
@@ -320,7 +396,7 @@ export function renderReport(evald, { account: acctName, portalId, blogSlug } =
320
396
  const lines = [];
321
397
  lines.push(`Bootstrap preflight — account "${acctName}" (portal ${portalId}), blog slug "${blogSlug}"`);
322
398
  for (const c of evald.checks) {
323
- const mark = c.ok ? 'PASS' : c.reportOnly ? 'NOTE' : 'FAIL';
399
+ const mark = c.repairable ? 'WARN' : c.ok ? 'PASS' : c.reportOnly ? 'NOTE' : 'FAIL';
324
400
  lines.push(` [${mark}] ${c.id}: ${c.detail}`);
325
401
  if (!c.ok && c.remediation) lines.push(` -> ${c.remediation}`);
326
402
  }
@@ -338,9 +414,11 @@ export function renderReport(evald, { account: acctName, portalId, blogSlug } =
338
414
  // ---------------------------------------------------------------------------
339
415
  export async function main(argv = process.argv.slice(2), opts = {}) {
340
416
  const { config } = opts;
341
- const acctName = argv[0];
417
+ const allowRepairable = argv.includes('--allow-repairable');
418
+ const args = argv.filter((a) => a !== '--allow-repairable');
419
+ const acctName = args[0];
342
420
  if (!acctName) {
343
- process.stderr.write('usage: node sync/preflight.mjs <account>\n');
421
+ process.stderr.write('usage: node sync/preflight.mjs <account> [--allow-repairable]\n');
344
422
  return 2;
345
423
  }
346
424
 
@@ -371,7 +449,18 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
371
449
  return 1;
372
450
  }
373
451
 
374
- const evald = evaluateReadiness(probes, { blogSlug, themeName: config?.theme?.name || THEME_NAME });
452
+ const themeName = config?.theme?.name || THEME_NAME;
453
+ const repairable = allowRepairable
454
+ ? sourceRepairability(config?.root || REPO_ROOT, {
455
+ blogSlug,
456
+ themeName,
457
+ contentDirPath: config?.contentDirPath,
458
+ manifestFilePath: config?.manifestFilePath,
459
+ blogItemTemplate: config?.blog?.itemTemplate,
460
+ blogListingTemplate: config?.blog?.listingTemplate,
461
+ })
462
+ : {};
463
+ const evald = evaluateReadiness(probes, { blogSlug, themeName, allowRepairable, repairable });
375
464
  const report = renderReport(evald, { account: acctName, portalId: acct.portalId, blogSlug });
376
465
  process.stdout.write(report + '\n');
377
466
  return evald.ready ? 0 : 1;
@@ -382,4 +471,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
382
471
  main().then((code) => process.exit(code));
383
472
  }
384
473
 
385
- export default { main, gatherProbes, evaluateReadiness, manifestBlogSlug, renderReport };
474
+ export default { main, gatherProbes, evaluateReadiness, manifestBlogSlug, sourceRepairability, renderReport };
@@ -0,0 +1,283 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { account as realAccount, getAll as realGetAll, hub as realHub } from './lib/hub.mjs';
5
+ import { READ_ONLY_PORTAL } from './push.mjs';
6
+
7
+ const REQUIRED_FIELDS = ['routePrefix', 'destination'];
8
+ const BOOLEAN_FIELDS = [
9
+ 'isMatchFullUrl',
10
+ 'isMatchQueryString',
11
+ 'isOnlyAfterNotFound',
12
+ 'isPattern',
13
+ 'isProtocolAgnostic',
14
+ 'isTrailingSlashOptional',
15
+ ];
16
+ const INTEGER_FIELDS = ['redirectStyle', 'precedence'];
17
+
18
+ function parseBool(value, field, rowNumber) {
19
+ const raw = String(value).trim().toLowerCase();
20
+ if (raw === 'true' || raw === '1' || raw === 'yes') return true;
21
+ if (raw === 'false' || raw === '0' || raw === 'no') return false;
22
+ throw new Error(`redirects row ${rowNumber}: ${field} must be true/false`);
23
+ }
24
+
25
+ function parseInteger(value, field, rowNumber) {
26
+ const n = Number.parseInt(String(value).trim(), 10);
27
+ if (!Number.isFinite(n)) throw new Error(`redirects row ${rowNumber}: ${field} must be an integer`);
28
+ return n;
29
+ }
30
+
31
+ export function parseCsv(text) {
32
+ const rows = [];
33
+ let row = [];
34
+ let field = '';
35
+ let inQuote = false;
36
+
37
+ for (let i = 0; i < text.length; i += 1) {
38
+ const ch = text[i];
39
+ if (inQuote) {
40
+ if (ch === '"' && text[i + 1] === '"') {
41
+ field += '"';
42
+ i += 1;
43
+ } else if (ch === '"') {
44
+ inQuote = false;
45
+ } else {
46
+ field += ch;
47
+ }
48
+ continue;
49
+ }
50
+ if (ch === '"') {
51
+ inQuote = true;
52
+ } else if (ch === ',') {
53
+ row.push(field);
54
+ field = '';
55
+ } else if (ch === '\n') {
56
+ row.push(field);
57
+ rows.push(row);
58
+ row = [];
59
+ field = '';
60
+ } else if (ch !== '\r') {
61
+ field += ch;
62
+ }
63
+ }
64
+ if (field !== '' || row.length > 0) {
65
+ row.push(field);
66
+ rows.push(row);
67
+ }
68
+ return rows.filter((r) => r.some((v) => String(v).trim() !== ''));
69
+ }
70
+
71
+ export function parseRedirectCsv(text) {
72
+ const rows = parseCsv(text);
73
+ if (rows.length === 0) return [];
74
+ const header = rows[0].map((h) => h.trim());
75
+ for (const field of REQUIRED_FIELDS) {
76
+ if (!header.includes(field)) throw new Error(`redirects CSV is missing required column "${field}"`);
77
+ }
78
+ return rows.slice(1).map((row, idx) => {
79
+ const record = {};
80
+ for (let i = 0; i < header.length; i += 1) record[header[i]] = row[i] ?? '';
81
+ return normalizeRedirect(record, idx + 2);
82
+ });
83
+ }
84
+
85
+ export function normalizeRedirect(input, rowNumber = 1) {
86
+ const out = {};
87
+ for (const field of REQUIRED_FIELDS) {
88
+ const value = String(input[field] ?? '').trim();
89
+ if (!value) throw new Error(`redirects row ${rowNumber}: ${field} is required`);
90
+ out[field] = value;
91
+ }
92
+
93
+ out.redirectStyle = input.redirectStyle === undefined || String(input.redirectStyle).trim() === ''
94
+ ? 301
95
+ : parseInteger(input.redirectStyle, 'redirectStyle', rowNumber);
96
+
97
+ // We intentionally set this by default so managed redirects can replace an
98
+ // existing live HubSpot page without a UI archive/move step.
99
+ out.isOnlyAfterNotFound = input.isOnlyAfterNotFound === undefined || String(input.isOnlyAfterNotFound).trim() === ''
100
+ ? false
101
+ : parseBool(input.isOnlyAfterNotFound, 'isOnlyAfterNotFound', rowNumber);
102
+
103
+ for (const field of BOOLEAN_FIELDS) {
104
+ if (field === 'isOnlyAfterNotFound') continue;
105
+ if (input[field] !== undefined && String(input[field]).trim() !== '') {
106
+ out[field] = parseBool(input[field], field, rowNumber);
107
+ }
108
+ }
109
+ for (const field of INTEGER_FIELDS) {
110
+ if (field === 'redirectStyle') continue;
111
+ if (input[field] !== undefined && String(input[field]).trim() !== '') {
112
+ out[field] = parseInteger(input[field], field, rowNumber);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ export function readRedirectSpecs(file) {
119
+ const text = readFileSync(file, 'utf8');
120
+ if (file.endsWith('.json')) {
121
+ const raw = JSON.parse(text);
122
+ if (!Array.isArray(raw)) throw new Error('redirects JSON must be an array');
123
+ return raw.map((r, idx) => normalizeRedirect(r, idx + 1));
124
+ }
125
+ return parseRedirectCsv(text);
126
+ }
127
+
128
+ function payloadFor(spec) {
129
+ const out = {};
130
+ for (const field of ['routePrefix', 'destination', ...INTEGER_FIELDS, ...BOOLEAN_FIELDS]) {
131
+ if (spec[field] !== undefined) out[field] = spec[field];
132
+ }
133
+ return out;
134
+ }
135
+
136
+ export function planRedirects(specs, existing) {
137
+ const seenSpecs = new Set();
138
+ for (const spec of specs) {
139
+ if (seenSpecs.has(spec.routePrefix)) throw new Error(`duplicate managed redirect routePrefix "${spec.routePrefix}"`);
140
+ seenSpecs.add(spec.routePrefix);
141
+ }
142
+
143
+ const byRoute = new Map();
144
+ for (const redirect of existing) {
145
+ const route = String(redirect.routePrefix ?? '');
146
+ if (!route) continue;
147
+ if (byRoute.has(route)) throw new Error(`multiple existing HubSpot redirects share routePrefix "${route}"`);
148
+ byRoute.set(route, redirect);
149
+ }
150
+
151
+ return specs.map((spec) => {
152
+ const current = byRoute.get(spec.routePrefix);
153
+ if (!current) return { action: 'create', spec, body: payloadFor(spec) };
154
+
155
+ const body = payloadFor(spec);
156
+ const changes = {};
157
+ for (const [field, value] of Object.entries(body)) {
158
+ if (String(current[field]) !== String(value)) changes[field] = value;
159
+ }
160
+ if (Object.keys(changes).length === 0) {
161
+ return { action: 'unchanged', id: String(current.id), spec, current };
162
+ }
163
+ return {
164
+ action: 'update',
165
+ id: String(current.id),
166
+ spec,
167
+ current,
168
+ body: changes,
169
+ changedFields: Object.keys(changes),
170
+ };
171
+ });
172
+ }
173
+
174
+ function readOnlySet(config) {
175
+ return new Set((config?.readOnlyPortalIds?.length ? config.readOnlyPortalIds : [READ_ONLY_PORTAL]).map(String));
176
+ }
177
+
178
+ export async function syncRedirects(name, options = {}, deps = {}) {
179
+ const {
180
+ apply = false,
181
+ file,
182
+ config: optionConfig,
183
+ } = options;
184
+ const {
185
+ account = realAccount,
186
+ getAll = realGetAll,
187
+ hub = realHub,
188
+ readSpecs = readRedirectSpecs,
189
+ } = deps;
190
+ const config = deps.config || optionConfig;
191
+ const acct = account(name, config);
192
+
193
+ if (apply && readOnlySet(config).has(String(acct.portalId))) {
194
+ throw new Error(
195
+ `portal is read-only: account "${acct.name}" maps to portal ${acct.portalId}; redirects refuses to write`,
196
+ );
197
+ }
198
+
199
+ const sourceFile = file || config?.redirectsFilePath;
200
+ if (!sourceFile) {
201
+ throw new Error('redirects requires --file <path> or config.redirectsFile');
202
+ }
203
+ const specs = readSpecs(resolve(config?.root || process.cwd(), sourceFile));
204
+ const existing = await getAll(acct, '/cms/v3/url-redirects');
205
+ const plan = planRedirects(specs, existing);
206
+
207
+ if (apply) {
208
+ for (const item of plan) {
209
+ if (item.action === 'create') {
210
+ const r = await hub(acct, 'POST', '/cms/v3/url-redirects', item.body);
211
+ if (!r.ok) {
212
+ const msg = r.json?.message || r.json?.category || JSON.stringify(r.json).slice(0, 200);
213
+ throw new Error(`create redirect ${item.spec.routePrefix} -> ${r.status}: ${msg}`);
214
+ }
215
+ item.id = String(r.json?.id ?? '');
216
+ } else if (item.action === 'update') {
217
+ const r = await hub(acct, 'PATCH', `/cms/v3/url-redirects/${item.id}`, item.body);
218
+ if (!r.ok) {
219
+ const msg = r.json?.message || r.json?.category || JSON.stringify(r.json).slice(0, 200);
220
+ throw new Error(`update redirect ${item.spec.routePrefix} (${item.id}) -> ${r.status}: ${msg}`);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ return {
227
+ account: acct.name,
228
+ portalId: acct.portalId,
229
+ file: sourceFile,
230
+ apply,
231
+ plan,
232
+ counts: {
233
+ create: plan.filter((x) => x.action === 'create').length,
234
+ update: plan.filter((x) => x.action === 'update').length,
235
+ unchanged: plan.filter((x) => x.action === 'unchanged').length,
236
+ },
237
+ };
238
+ }
239
+
240
+ export function renderRedirectReport(result) {
241
+ const mode = result.apply ? 'applied' : 'dry-run';
242
+ const lines = [];
243
+ lines.push(`redirects ${mode} -> account "${result.account}" (portal ${result.portalId})`);
244
+ lines.push(`source: ${result.file}`);
245
+ lines.push(
246
+ `summary: ${result.counts.create} create, ${result.counts.update} update, ${result.counts.unchanged} unchanged`,
247
+ );
248
+ for (const item of result.plan) {
249
+ const arrow = `${item.spec.routePrefix} -> ${item.spec.destination}`;
250
+ if (item.action === 'update') {
251
+ lines.push(` [update] ${arrow} (${item.changedFields.join(', ')})`);
252
+ } else {
253
+ lines.push(` [${item.action}] ${arrow}`);
254
+ }
255
+ }
256
+ return lines.join('\n');
257
+ }
258
+
259
+ export async function main(argv = process.argv.slice(2), opts = {}) {
260
+ const accountName = argv.find((a) => !a.startsWith('--'));
261
+ if (!accountName) {
262
+ process.stderr.write('usage: node src/redirects.mjs <account> [--file <path>] [--apply]\n');
263
+ return 2;
264
+ }
265
+ let file;
266
+ let apply = false;
267
+ for (let i = 0; i < argv.length; i += 1) {
268
+ if (argv[i] === '--apply') apply = true;
269
+ if (argv[i] === '--file') file = argv[++i];
270
+ }
271
+ try {
272
+ const result = await syncRedirects(accountName, { file, apply, config: opts.config }, opts.deps);
273
+ process.stdout.write(renderRedirectReport(result) + '\n');
274
+ return 0;
275
+ } catch (e) {
276
+ process.stderr.write(`redirects failed: ${e.message}\n`);
277
+ return 1;
278
+ }
279
+ }
280
+
281
+ if (import.meta.url === `file://${process.argv[1]}`) {
282
+ main().then((code) => process.exit(code));
283
+ }