hubspot-cms-sync 0.3.0 → 0.5.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/bin/hubspot-cms-sync.mjs +23 -0
- package/package.json +1 -1
- package/src/adapters/blog.mjs +34 -8
- package/src/build-static.mjs +119 -0
- package/src/lib/content-view.mjs +13 -3
- package/src/lib/render.mjs +170 -10
package/bin/hubspot-cms-sync.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { main as preflightMain } from '../src/preflight.mjs';
|
|
|
10
10
|
import { main as republishMain } from '../src/republish.mjs';
|
|
11
11
|
import { main as manifestMain } from '../src/manifest.mjs';
|
|
12
12
|
import { renderRedirectReport, syncRedirects } from '../src/redirects.mjs';
|
|
13
|
+
import { buildStatic } from '../src/build-static.mjs';
|
|
14
|
+
import { resolve as resolvePath } from 'node:path';
|
|
13
15
|
|
|
14
16
|
function runNodeScript(script, args, { cwd }) {
|
|
15
17
|
return new Promise((resolve) => {
|
|
@@ -45,6 +47,27 @@ async function main(argv = process.argv) {
|
|
|
45
47
|
console.log(`theme: ${config.theme.name}`);
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
program
|
|
51
|
+
.command('build')
|
|
52
|
+
.description('render the canonical site to a static directory (static target)')
|
|
53
|
+
.option('--out <dir>', 'output directory', 'dist')
|
|
54
|
+
.option('--base-url <url>', 'absolute base URL for canonical/og links', '')
|
|
55
|
+
.option('--tracking-portal <id>', 'HubSpot tracking-script portal id (keeps forms de-anonymizing)')
|
|
56
|
+
.action(async (options) => {
|
|
57
|
+
const config = await withConfig(program.opts());
|
|
58
|
+
const summary = await buildStatic({
|
|
59
|
+
siteDir: config.root,
|
|
60
|
+
outDir: resolvePath(config.root, options.out),
|
|
61
|
+
baseUrl: options.baseUrl,
|
|
62
|
+
trackingPortalId: options.trackingPortal,
|
|
63
|
+
});
|
|
64
|
+
console.log(`built static site -> ${options.out}`);
|
|
65
|
+
console.log(
|
|
66
|
+
` pages: ${summary.pages} | posts: ${summary.posts} | tag pages: ${summary.tags} | `
|
|
67
|
+
+ `html files: ${summary.files} | redirects: ${summary.redirects}`,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
48
71
|
program
|
|
49
72
|
.command('pull')
|
|
50
73
|
.description('pull HubSpot content into the repo')
|
package/package.json
CHANGED
package/src/adapters/blog.mjs
CHANGED
|
@@ -45,12 +45,14 @@ import {
|
|
|
45
45
|
mkdirSync,
|
|
46
46
|
readdirSync,
|
|
47
47
|
existsSync,
|
|
48
|
+
rmSync,
|
|
48
49
|
} from 'node:fs';
|
|
49
50
|
import { join, resolve as resolvePath, basename, extname } from 'node:path';
|
|
50
51
|
import { createHash } from 'node:crypto';
|
|
51
52
|
|
|
52
53
|
import { hub, getAll } from '../lib/hub.mjs';
|
|
53
54
|
import { stableStringify } from '../lib/canonical.mjs';
|
|
55
|
+
import { wireToFile, fileToWire } from '../lib/posts-format.mjs';
|
|
54
56
|
import { resolve as resolveRefs, canonicalize as canonicalizeRefs } from '../lib/refs.mjs';
|
|
55
57
|
import { resolveCtaEmbeds, loadInventory } from '../cta-inventory.mjs';
|
|
56
58
|
|
|
@@ -317,10 +319,13 @@ export async function pull(acct, { contentDir, registry }) {
|
|
|
317
319
|
// (codex #7). It is content here, not a volatile timestamp to strip.
|
|
318
320
|
publishDate: p.publishDate || null,
|
|
319
321
|
};
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
322
|
+
// Canonical post format is frontmatter + HTML body (.md). Reshaping is lossless
|
|
323
|
+
// to the wire object (lib/posts-format.mjs round-trip), so the push payload is
|
|
324
|
+
// byte-identical to the old .json path. Drop any stale sibling .json from the
|
|
325
|
+
// pre-frontmatter format so push never sees the same post twice.
|
|
326
|
+
const base = join(postsOut, postFileFor(p.slug));
|
|
327
|
+
writeFileSync(`${base}.md`, wireToFile(portable));
|
|
328
|
+
if (existsSync(`${base}.json`)) rmSync(`${base}.json`);
|
|
324
329
|
pulled++;
|
|
325
330
|
}
|
|
326
331
|
|
|
@@ -372,6 +377,10 @@ export async function push(
|
|
|
372
377
|
registry,
|
|
373
378
|
publish = false,
|
|
374
379
|
limit,
|
|
380
|
+
// only: restrict the push to specific posts by file base name (no extension),
|
|
381
|
+
// e.g. ['blog__hello']. Enables a scoped sample push without touching the rest
|
|
382
|
+
// of the blog — used by verification harnesses; undefined means "all posts".
|
|
383
|
+
only,
|
|
375
384
|
dryRun = false,
|
|
376
385
|
hubFn = hub,
|
|
377
386
|
// Injectable clock + sleep so the "wait past every scheduled publish" gate
|
|
@@ -393,12 +402,29 @@ export async function push(
|
|
|
393
402
|
// then replaces @asset tokens below. (The old blog-local rehostAssets path is
|
|
394
403
|
// retired: one upload location, no /blog-migrated vs /synced-assets split.)
|
|
395
404
|
|
|
396
|
-
|
|
397
|
-
|
|
405
|
+
// Accept the canonical frontmatter format (.md) and the legacy .json. If both
|
|
406
|
+
// exist for one post, .md wins; dedup by base name so a post is never pushed
|
|
407
|
+
// twice during the transition.
|
|
408
|
+
const byBase = new Map();
|
|
409
|
+
for (const f of readdirSync(postsDir)) {
|
|
410
|
+
const m = /^(.*)\.(md|json)$/.exec(f);
|
|
411
|
+
if (!m) continue;
|
|
412
|
+
const [, base, ext] = m;
|
|
413
|
+
if (ext === 'md' || !byBase.has(base)) byBase.set(base, f);
|
|
414
|
+
}
|
|
415
|
+
let files = [...byBase.values()].sort();
|
|
416
|
+
if (only) {
|
|
417
|
+
const want = new Set(only);
|
|
418
|
+
files = files.filter((f) => want.has(f.replace(/\.(md|json)$/, '')));
|
|
419
|
+
}
|
|
398
420
|
if (limit) files = files.slice(0, limit);
|
|
399
421
|
|
|
400
|
-
// Group posts by their blogSlug and resolve each container exactly once.
|
|
401
|
-
|
|
422
|
+
// Group posts by their blogSlug and resolve each container exactly once. The
|
|
423
|
+
// frontmatter codec yields the same wire object JSON.parse would have.
|
|
424
|
+
const posts = files.map((f) => {
|
|
425
|
+
const raw = readFileSync(join(postsDir, f), 'utf8');
|
|
426
|
+
return f.endsWith('.md') ? fileToWire(raw) : JSON.parse(raw);
|
|
427
|
+
});
|
|
402
428
|
const containerCache = new Map();
|
|
403
429
|
async function containerIdFor(blogSlug) {
|
|
404
430
|
if (containerCache.has(blogSlug)) return containerCache.get(blogSlug);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/build-static.mjs — STATIC TARGET build: render the whole canonical site to a
|
|
2
|
+
// directory of plain HTML + assets, deployable to any static host (Cloudflare Pages,
|
|
3
|
+
// etc.). The mirror of push.mjs (the HubSpot target): same canonical content, a
|
|
4
|
+
// different materialization. Output is build artifact — never committed; CI builds
|
|
5
|
+
// it fresh and deploys.
|
|
6
|
+
//
|
|
7
|
+
// Emits, under outDir:
|
|
8
|
+
// <route>/index.html for every published page (home slug "" -> index.html)
|
|
9
|
+
// blog/<slug>/index.html for every published post
|
|
10
|
+
// blog/index.html the blog listing (all posts)
|
|
11
|
+
// blog/tag/<slug>/index.html one listing per tag
|
|
12
|
+
// css/ js/ images/ assets/ copied theme + @asset bytes (assets/ <- content/assets)
|
|
13
|
+
// _redirects Cloudflare redirects from sync/redirects.csv
|
|
14
|
+
// _headers cache + basic security headers
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile, cp, readFile } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { loadSite } from './lib/content-view.mjs';
|
|
21
|
+
import { renderPage, renderPost, renderBlogListing, slugify } from './lib/render.mjs';
|
|
22
|
+
import { readRedirectSpecs } from './redirects.mjs';
|
|
23
|
+
|
|
24
|
+
async function loadTagSlugs(siteDir) {
|
|
25
|
+
const map = {};
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(await readFile(join(siteDir, 'content/blog/tags.json'), 'utf8'));
|
|
28
|
+
for (const t of Array.isArray(raw) ? raw : raw.tags || []) {
|
|
29
|
+
if (t?.name && t?.slug) map[t.name] = t.slug;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
/* tags.json optional — fall back to slugify */
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* buildStatic({ siteDir, outDir, baseUrl, assetBase, trackingPortalId }) -> summary
|
|
39
|
+
*
|
|
40
|
+
* baseUrl is used for canonical/og absolute URLs (e.g. https://www2.7thsense.io).
|
|
41
|
+
* trackingPortalId, if set, injects the HubSpot tracking script into the footer so
|
|
42
|
+
* forms keep de-anonymizing (`standard_footer_includes`). Returns counts.
|
|
43
|
+
*/
|
|
44
|
+
export async function buildStatic({ siteDir, outDir, baseUrl = '', assetBase = '/assets', trackingPortalId } = {}) {
|
|
45
|
+
const tagMap = await loadTagSlugs(siteDir);
|
|
46
|
+
const tagSlugFor = (name) => tagMap[name] || slugify(name);
|
|
47
|
+
const footerIncludes = trackingPortalId
|
|
48
|
+
? `<script type="text/javascript" id="hs-script-loader" async defer src="//js.hs-scripts.com/${trackingPortalId}.js"></script>`
|
|
49
|
+
: '';
|
|
50
|
+
|
|
51
|
+
const site = await loadSite(siteDir);
|
|
52
|
+
const pages = site.pages.filter((p) => p.status === 'published');
|
|
53
|
+
const posts = site.posts.filter((p) => p.status === 'published'); // already newest-first
|
|
54
|
+
const opts = { siteDir, site, baseUrl, assetBase, footerIncludes, tagSlugFor };
|
|
55
|
+
|
|
56
|
+
let fileCount = 0;
|
|
57
|
+
async function emit(route, html) {
|
|
58
|
+
const rel = route === '/' || route === '' ? 'index.html' : join(route.replace(/^\//, ''), 'index.html');
|
|
59
|
+
const file = join(outDir, rel);
|
|
60
|
+
await mkdir(dirname(file), { recursive: true });
|
|
61
|
+
await writeFile(file, html, 'utf8');
|
|
62
|
+
fileCount++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const page of pages) await emit(page.route, renderPage(page, opts));
|
|
66
|
+
for (const post of posts) await emit(post.route, renderPost(post, opts));
|
|
67
|
+
|
|
68
|
+
// Cloudflare Pages serves /404.html for any unmatched route; without it, an unknown
|
|
69
|
+
// path falls back to index.html. Render it flat at the output root.
|
|
70
|
+
if (existsSync(join(siteDir, 'templates/404.html'))) {
|
|
71
|
+
const html = renderPage(
|
|
72
|
+
{ template: 'templates/404.html', route: '/404', title: 'Page not found', htmlTitle: 'Page not found', metaDescription: '', modules: {} },
|
|
73
|
+
opts,
|
|
74
|
+
);
|
|
75
|
+
await writeFile(join(outDir, '404.html'), html, 'utf8');
|
|
76
|
+
fileCount++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await emit('/blog', renderBlogListing(posts, { ...opts, route: '/blog' }));
|
|
80
|
+
|
|
81
|
+
// One listing per tag, posts grouped by tag slug (preserves newest-first order).
|
|
82
|
+
const byTag = new Map();
|
|
83
|
+
for (const post of posts) {
|
|
84
|
+
for (const t of post.tags) {
|
|
85
|
+
const slug = tagSlugFor(t);
|
|
86
|
+
if (!byTag.has(slug)) byTag.set(slug, []);
|
|
87
|
+
byTag.get(slug).push(post);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [slug, tagPosts] of byTag) {
|
|
91
|
+
await emit(`/blog/tag/${slug}`, renderBlogListing(tagPosts, { ...opts, route: `/blog/tag/${slug}` }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Assets. get_asset_url maps ../css|js|images -> /css|js|images; @asset:<p> -> /assets/<p>.
|
|
95
|
+
for (const [src, dest] of [['css', 'css'], ['js', 'js'], ['images', 'images'],
|
|
96
|
+
['content/assets', 'assets'], ['content/blog/assets', 'assets']]) {
|
|
97
|
+
const from = join(siteDir, src);
|
|
98
|
+
if (existsSync(from)) await cp(from, join(outDir, dest), { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// _redirects (Cloudflare format: "<from> <to> <code>").
|
|
102
|
+
let redirectCount = 0;
|
|
103
|
+
const redirCsv = join(siteDir, 'sync/redirects.csv');
|
|
104
|
+
if (existsSync(redirCsv)) {
|
|
105
|
+
const specs = readRedirectSpecs(redirCsv);
|
|
106
|
+
const lines = specs.map((s) => `${s.routePrefix} ${s.destination} ${s.redirectStyle || 301}`);
|
|
107
|
+
await writeFile(join(outDir, '_redirects'), `${lines.join('\n')}\n`, 'utf8');
|
|
108
|
+
redirectCount = lines.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await writeFile(join(outDir, '_headers'),
|
|
112
|
+
'/assets/*\n Cache-Control: public, max-age=31536000, immutable\n'
|
|
113
|
+
+ '/css/*\n Cache-Control: public, max-age=31536000, immutable\n'
|
|
114
|
+
+ '/js/*\n Cache-Control: public, max-age=31536000, immutable\n'
|
|
115
|
+
+ '/*\n X-Content-Type-Options: nosniff\n X-Frame-Options: SAMEORIGIN\n Referrer-Policy: strict-origin-when-cross-origin\n',
|
|
116
|
+
'utf8');
|
|
117
|
+
|
|
118
|
+
return { pages: pages.length, posts: posts.length, tags: byTag.size, files: fileCount, redirects: redirectCount };
|
|
119
|
+
}
|
package/src/lib/content-view.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { readFile, readdir } from 'node:fs/promises';
|
|
25
25
|
import { join, basename } from 'node:path';
|
|
26
|
+
import { fileToWire } from './posts-format.mjs';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Status vocabulary. HubSpot speaks `desiredState` (page) and `state` (post):
|
|
@@ -138,10 +139,19 @@ export async function loadPosts(contentDir) {
|
|
|
138
139
|
const blogDir = join(contentDir, 'blog');
|
|
139
140
|
const postsDir = join(blogDir, 'posts');
|
|
140
141
|
const authorsByName = await loadAuthors(blogDir);
|
|
141
|
-
|
|
142
|
+
// Posts may be frontmatter (.md, canonical) or legacy .json. Dedup by base name,
|
|
143
|
+
// preferring .md, so a half-migrated tree never yields a post twice.
|
|
144
|
+
const byBase = new Map();
|
|
145
|
+
for (const f of await readdir(postsDir)) {
|
|
146
|
+
const m = /^(.*)\.(md|json)$/.exec(f);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
if (m[2] === 'md' || !byBase.has(m[1])) byBase.set(m[1], f);
|
|
149
|
+
}
|
|
142
150
|
const posts = [];
|
|
143
|
-
for (const f of
|
|
144
|
-
|
|
151
|
+
for (const f of [...byBase.values()].sort()) {
|
|
152
|
+
const raw = await readFile(join(postsDir, f), 'utf8');
|
|
153
|
+
const wire = f.endsWith('.md') ? fileToWire(raw) : JSON.parse(raw);
|
|
154
|
+
posts.push(projectPost(wire, authorsByName));
|
|
145
155
|
}
|
|
146
156
|
posts.sort((a, b) => String(b.publishDate || '').localeCompare(String(a.publishDate || '')));
|
|
147
157
|
return posts;
|
package/src/lib/render.mjs
CHANGED
|
@@ -35,11 +35,16 @@ import nunjucks from 'nunjucks';
|
|
|
35
35
|
const SLICE_RE = /([A-Za-z_][\w.]*)\[(\d*):(\d*)\]/g;
|
|
36
36
|
|
|
37
37
|
export function preprocessHubl(src) {
|
|
38
|
-
return src
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
return src
|
|
39
|
+
// HubL's `{% module "name" path=... label=... %}` space-separates the name from
|
|
40
|
+
// its kwargs; Nunjucks' parseSignature wants a comma there. Insert one after the
|
|
41
|
+
// name literal so the remaining `key=value, ...` parses as keyword args.
|
|
42
|
+
.replace(/(\{%-?\s*module\s+(?:"[^"]*"|'[^']*'))\s+(?=[A-Za-z_])/g, '$1, ')
|
|
43
|
+
.replace(SLICE_RE, (_m, expr, a, b) => {
|
|
44
|
+
const start = a === '' ? '0' : a;
|
|
45
|
+
const end = b === '' ? 'null' : b;
|
|
46
|
+
return `${expr}|hubslice(${start},${end})`;
|
|
47
|
+
});
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
// ---------------------------------------------------------------------------
|
|
@@ -72,6 +77,22 @@ export function resolveStaticRefs(value, { assetBase = '/assets' } = {}) {
|
|
|
72
77
|
return String(value).replace(/@asset:([^\s"'<>)]+)/g, (_m, nameRef) => `${assetBase}/${nameRef}`);
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
// HubSpot evaluates HubL functions embedded in rich-text bodies at render time; the
|
|
81
|
+
// static target passes bodies through as data, so any such macro must be resolved
|
|
82
|
+
// here. Currently the only one in the corpus is a Wistia video embed,
|
|
83
|
+
// `{{ script_embed('wistia', '<id>', ...) }}` -> Wistia's responsive inline embed.
|
|
84
|
+
const WISTIA_EMBED_RE = /\{\{\s*script_embed\(\s*['"]wistia['"]\s*,\s*['"]([A-Za-z0-9]+)['"][^}]*\)\s*\}\}/gi;
|
|
85
|
+
export function resolveHublEmbeds(value) {
|
|
86
|
+
if (value == null) return value;
|
|
87
|
+
return String(value).replace(WISTIA_EMBED_RE, (_m, id) =>
|
|
88
|
+
`<script src="https://fast.wistia.com/embed/medias/${id}.jsonp" async></script>`
|
|
89
|
+
+ '<script src="https://fast.wistia.com/assets/external/E-v1.js" async></script>'
|
|
90
|
+
+ '<div class="wistia_responsive_padding" style="padding:56.25% 0 0 0;position:relative;">'
|
|
91
|
+
+ '<div class="wistia_responsive_wrapper" style="height:100%;left:0;position:absolute;top:0;width:100%;">'
|
|
92
|
+
+ `<div class="wistia_embed wistia_async_${id} videoFoam=true" style="height:100%;position:relative;width:100%;"> </div>`
|
|
93
|
+
+ '</div></div>');
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
// get_asset_url('../css/main.css') -> "/css/main.css". Theme assets live at the
|
|
76
97
|
// repo root (css/ js/ images/); HubL refs them template-relative with leading ../.
|
|
77
98
|
function assetUrl(path) {
|
|
@@ -80,6 +101,8 @@ function assetUrl(path) {
|
|
|
80
101
|
|
|
81
102
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
82
103
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
104
|
+
const MONTHS_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
105
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
83
106
|
|
|
84
107
|
function localizeDate(iso) {
|
|
85
108
|
if (!iso) return '';
|
|
@@ -88,29 +111,111 @@ function localizeDate(iso) {
|
|
|
88
111
|
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
// Fallback tag slug when no authoritative tags.json mapping is supplied.
|
|
115
|
+
export function slugify(s) {
|
|
116
|
+
return String(s).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// HubL's format_date(value, style). Mirrors HubSpot's en-US styles: medium uses an
|
|
120
|
+
// abbreviated month ("Jun 6, 2026"), long/full the full month, short is numeric.
|
|
121
|
+
// UTC so the build is deterministic regardless of the runner's timezone.
|
|
122
|
+
function formatDate(value, style = 'medium') {
|
|
123
|
+
if (!value) return '';
|
|
124
|
+
const d = new Date(value);
|
|
125
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
126
|
+
const m = d.getUTCMonth();
|
|
127
|
+
const day = d.getUTCDate();
|
|
128
|
+
const y = d.getUTCFullYear();
|
|
129
|
+
if (style === 'short') return `${m + 1}/${day}/${String(y).slice(2)}`;
|
|
130
|
+
if (style === 'long' || style === 'full') return `${MONTHS[m]} ${day}, ${y}`;
|
|
131
|
+
return `${MONTHS_ABBR[m]} ${day}, ${y}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
91
134
|
// ---------------------------------------------------------------------------
|
|
92
135
|
// Neutral post view -> HubL `content` shim (snake_case, refs resolved for the
|
|
93
136
|
// static target). Reused for the page being rendered AND for related-post cards
|
|
94
137
|
// returned by blog_recent_posts().
|
|
95
138
|
// ---------------------------------------------------------------------------
|
|
96
|
-
function postContent(post, { baseUrl = '', assetBase = '/assets' } = {}) {
|
|
139
|
+
function postContent(post, { baseUrl = '', assetBase = '/assets', tagSlugFor } = {}) {
|
|
97
140
|
const author = post.author || null;
|
|
141
|
+
const tagSlug = tagSlugFor || slugify;
|
|
98
142
|
return {
|
|
99
143
|
name: post.title,
|
|
100
144
|
html_title: post.htmlTitle,
|
|
101
145
|
meta_description: post.metaDescription,
|
|
102
|
-
post_body: resolveStaticRefs(post.body, { assetBase }),
|
|
103
|
-
post_summary: resolveStaticRefs(post.summary, { assetBase }),
|
|
146
|
+
post_body: resolveHublEmbeds(resolveStaticRefs(post.body, { assetBase })),
|
|
147
|
+
post_summary: resolveHublEmbeds(resolveStaticRefs(post.summary, { assetBase })),
|
|
148
|
+
publish_date: post.publishDate,
|
|
104
149
|
publish_date_localized: localizeDate(post.publishDate),
|
|
105
150
|
featured_image: post.featuredImage ? resolveStaticRefs(post.featuredImage, { assetBase }) : '',
|
|
106
151
|
featured_image_alt_text: post.featuredImageAlt,
|
|
107
|
-
tag_list: post.tags.map((t) => ({ name: t })),
|
|
152
|
+
tag_list: post.tags.map((t) => ({ name: t, slug: tagSlug(t) })),
|
|
108
153
|
blog_post_author: author ? { display_name: author.name, bio: resolveStaticRefs(author.bio, { assetBase }) } : null,
|
|
109
154
|
absolute_url: baseUrl + post.route,
|
|
110
155
|
canonical_url: baseUrl + post.route,
|
|
111
156
|
};
|
|
112
157
|
}
|
|
113
158
|
|
|
159
|
+
// Recursively resolve @asset refs inside any string value of a field tree.
|
|
160
|
+
function resolveDeep(val, opts) {
|
|
161
|
+
if (typeof val === 'string') return resolveStaticRefs(val, opts);
|
|
162
|
+
if (Array.isArray(val)) return val.map((v) => resolveDeep(v, opts));
|
|
163
|
+
if (val && typeof val === 'object') {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [k, v] of Object.entries(val)) out[k] = resolveDeep(v, opts);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
return val;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// fields.json -> { fieldName: default }. A group field (with `children`) yields a
|
|
172
|
+
// nested object of its children's defaults; leaf fields yield their `default`.
|
|
173
|
+
function moduleFieldDefaults(fields) {
|
|
174
|
+
const out = {};
|
|
175
|
+
if (!Array.isArray(fields)) return out;
|
|
176
|
+
for (const f of fields) {
|
|
177
|
+
if (!f || !f.name) continue;
|
|
178
|
+
if (Array.isArray(f.children) && f.children.length) out[f.name] = moduleFieldDefaults(f.children);
|
|
179
|
+
else if ('default' in f) out[f.name] = f.default;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// {% module "name" path="../modules/x.module" label="..." field=val %} — HubL's
|
|
186
|
+
// module instantiation tag. HubSpot renders the module's module.html with a
|
|
187
|
+
// `module` variable whose fields resolve by precedence: fields.json defaults <
|
|
188
|
+
// inline template args < the page's stored widget body (content-view's modules map,
|
|
189
|
+
// passed in context as __page_modules). The module bytes are read relative to the
|
|
190
|
+
// theme root (path's leading ../ stripped). Returns SafeString (no double-escape).
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
function ModuleExtension(env, siteDir, opts) {
|
|
193
|
+
this.tags = ['module'];
|
|
194
|
+
this.parse = function parse(parser, nodes) {
|
|
195
|
+
const tok = parser.nextToken();
|
|
196
|
+
const args = parser.parseSignature(null, true);
|
|
197
|
+
parser.advanceAfterBlockEnd(tok.value);
|
|
198
|
+
return new nodes.CallExtension(this, 'run', args);
|
|
199
|
+
};
|
|
200
|
+
this.run = function run(context, name, kwargs) {
|
|
201
|
+
kwargs = kwargs && kwargs.__keywords ? kwargs : {};
|
|
202
|
+
const { path = '', label: _label, ...inlineFields } = kwargs;
|
|
203
|
+
const modDir = join(siteDir, String(path).replace(/^(\.\.\/)+/, ''));
|
|
204
|
+
let defaults = {};
|
|
205
|
+
try {
|
|
206
|
+
defaults = moduleFieldDefaults(JSON.parse(readFileSync(join(modDir, 'fields.json'), 'utf8')));
|
|
207
|
+
} catch {
|
|
208
|
+
/* a module may ship no fields.json */
|
|
209
|
+
}
|
|
210
|
+
const pageVals = (context.lookup('__page_modules') || {})[name] || {};
|
|
211
|
+
const merged = { ...defaults, ...inlineFields, ...pageVals };
|
|
212
|
+
const html = env.renderString(preprocessHubl(readFileSync(join(modDir, 'module.html'), 'utf8')), {
|
|
213
|
+
module: resolveDeep(merged, opts),
|
|
214
|
+
});
|
|
215
|
+
return new nunjucks.runtime.SafeString(html);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
114
219
|
// ---------------------------------------------------------------------------
|
|
115
220
|
// Environment factory. One env per render call keeps globals (blog_recent_posts
|
|
116
221
|
// closure, header/footer includes) bound to the current site + options.
|
|
@@ -120,6 +225,7 @@ function makeEnv(siteDir, { site, opts }) {
|
|
|
120
225
|
|
|
121
226
|
env.addFilter('hubslice', (str, start, end) =>
|
|
122
227
|
end === null || end === undefined ? String(str ?? '').slice(start) : String(str ?? '').slice(start, end));
|
|
228
|
+
env.addFilter('format_date', formatDate);
|
|
123
229
|
|
|
124
230
|
env.addGlobal('get_asset_url', assetUrl);
|
|
125
231
|
env.addGlobal('html_lang', opts.lang || 'en');
|
|
@@ -132,6 +238,8 @@ function makeEnv(siteDir, { site, opts }) {
|
|
|
132
238
|
env.addGlobal('blog_recent_posts', (_group, count) =>
|
|
133
239
|
(site?.posts || []).slice(0, count || 5).map((p) => postContent(p, opts)));
|
|
134
240
|
|
|
241
|
+
env.addExtension('ModuleExtension', new ModuleExtension(env, siteDir, opts));
|
|
242
|
+
|
|
135
243
|
return env;
|
|
136
244
|
}
|
|
137
245
|
|
|
@@ -150,4 +258,56 @@ export function renderPost(post, { siteDir, site, baseUrl = '', assetBase = '/as
|
|
|
150
258
|
return env.render(template, context);
|
|
151
259
|
}
|
|
152
260
|
|
|
153
|
-
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Neutral page view -> HubL `content` shim. Pages reference content.* far less
|
|
263
|
+
// than posts (most copy lives in modules), but templates use name/title/meta and
|
|
264
|
+
// the canonical/absolute URL for SEO + social tags.
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
function pageContent(page, { baseUrl = '' } = {}) {
|
|
267
|
+
return {
|
|
268
|
+
name: page.title,
|
|
269
|
+
html_title: page.htmlTitle,
|
|
270
|
+
meta_description: page.metaDescription,
|
|
271
|
+
absolute_url: baseUrl + page.route,
|
|
272
|
+
canonical_url: baseUrl + page.route,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Public: render one neutral page view (with its module map) to an HTML string.
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
export function renderPage(page, { siteDir, site, baseUrl = '', assetBase = '/assets', lang = 'en',
|
|
280
|
+
headerIncludes = '', footerIncludes = '' } = {}) {
|
|
281
|
+
const opts = { baseUrl, assetBase, lang, headerIncludes, footerIncludes };
|
|
282
|
+
const env = makeEnv(siteDir, { site, opts });
|
|
283
|
+
const context = {
|
|
284
|
+
content: pageContent(page, opts),
|
|
285
|
+
__page_modules: page.modules || {},
|
|
286
|
+
nav_active: null,
|
|
287
|
+
nav_hide_cta: false,
|
|
288
|
+
};
|
|
289
|
+
return env.render(page.template, context);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Public: render the blog LISTING (templates/blog.html) for a set of posts —
|
|
294
|
+
// the main /blog index or a /blog/tag/<slug> page. HubSpot exposes the page's
|
|
295
|
+
// posts as `contents` (a list of content objects) plus pagination vars; we pass
|
|
296
|
+
// all posts on one page (no pagination), so the template's paginate block (guarded
|
|
297
|
+
// by contents.total_page_count > 1) is inert.
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
export function renderBlogListing(posts, { siteDir, site, baseUrl = '', assetBase = '/assets', lang = 'en',
|
|
300
|
+
headerIncludes = '', footerIncludes = '', tagSlugFor, route = '/blog' } = {}) {
|
|
301
|
+
const opts = { baseUrl, assetBase, lang, headerIncludes, footerIncludes, tagSlugFor };
|
|
302
|
+
const env = makeEnv(siteDir, { site, opts });
|
|
303
|
+
const context = {
|
|
304
|
+
contents: posts.map((p) => postContent(p, opts)),
|
|
305
|
+
content: { absolute_url: baseUrl + route, canonical_url: baseUrl + route },
|
|
306
|
+
current_page_num: 1,
|
|
307
|
+
nav_active: null,
|
|
308
|
+
nav_hide_cta: false,
|
|
309
|
+
};
|
|
310
|
+
return env.render('templates/blog.html', context);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { postContent, pageContent, assetUrl, localizeDate, makeEnv };
|