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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hubspot-cms-sync",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Git-backed bidirectional sync for HubSpot CMS themes, content, blogs, forms, and assets.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- writeFileSync(
321
- join(postsOut, `${postFileFor(p.slug)}.json`),
322
- stableStringify(portable),
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
- let files = readdirSync(postsDir).filter((f) => f.endsWith('.json'));
397
- files.sort();
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
- const posts = files.map((f) => JSON.parse(readFileSync(join(postsDir, f), 'utf8')));
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
+ }
@@ -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
- const files = (await readdir(postsDir)).filter((f) => f.endsWith('.json'));
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 files.sort()) {
144
- posts.push(projectPost(await readJson(join(postsDir, f)), authorsByName));
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;
@@ -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.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
- });
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%;">&nbsp;</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
- export { postContent, assetUrl, localizeDate, makeEnv };
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 };