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.
- package/LICENSE +1 -1
- package/README.md +52 -12
- package/bin/hubspot-cms-sync.mjs +123 -94
- package/docs/CONFIGURATION.md +5 -0
- package/docs/CONTENT_LAYOUT.md +146 -0
- package/docs/GITHUB_ACTIONS.md +3 -1
- package/docs/HUBSPOT-SYNC-NOTES.md +123 -0
- package/examples/hubspot-cms-sync.config.mjs +1 -0
- package/examples/minimal-site/content/assets/hero.svg +9 -0
- package/examples/minimal-site/content/blog/container.json +6 -0
- package/examples/minimal-site/content/blog/posts/blog__hello-world.json +13 -0
- package/examples/minimal-site/content/forms/contact.json +18 -0
- package/examples/minimal-site/content/forms/properties.json +7 -0
- package/examples/minimal-site/content/pages/about.json +9 -0
- package/examples/minimal-site/content/pages/home.json +9 -0
- package/examples/minimal-site/content/pages/home.widgets.json +17 -0
- package/examples/minimal-site/css/main.css +3 -0
- package/examples/minimal-site/fields.json +11 -0
- package/examples/minimal-site/hubspot-cms-sync.config.mjs +27 -0
- package/examples/minimal-site/js/hs-forms.js +1 -0
- package/examples/minimal-site/modules/hero.module/fields.json +14 -0
- package/examples/minimal-site/modules/hero.module/module.html +4 -0
- package/examples/minimal-site/site.manifest.json +29 -0
- package/examples/minimal-site/sync/accounts.json +10 -0
- package/examples/minimal-site/sync/redirects.csv +2 -0
- package/examples/minimal-site/templates/blog-post.html +8 -0
- package/examples/minimal-site/templates/blog.html +9 -0
- package/examples/minimal-site/templates/home.html +5 -0
- package/examples/minimal-site/templates/page.html +7 -0
- package/examples/minimal-site/theme.json +4 -0
- package/package.json +17 -3
- package/skill/references/commands.md +4 -0
- package/skill/references/config.md +6 -2
- package/src/config.mjs +5 -0
- package/src/index.mjs +1 -0
- package/src/lib/content-view.mjs +168 -0
- package/src/lib/posts-format.mjs +90 -0
- package/src/lib/render.mjs +153 -0
- package/src/preflight.mjs +97 -8
- 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
|
|
216
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|