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,6 @@
1
+ {
2
+ "slug": "blog",
3
+ "name": "Example Blog",
4
+ "itemTemplatePath": "example-theme/templates/blog-post.html",
5
+ "listingTemplatePath": "example-theme/templates/blog.html"
6
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "slug": "blog/hello-world",
3
+ "blogSlug": "blog",
4
+ "name": "Hello World",
5
+ "htmlTitle": "Hello World",
6
+ "publishDate": "2026-01-01T12:00:00.000Z",
7
+ "postBody": "<p>Hello from git.</p><img src=\"@asset:hero.svg\" alt=\"Example\">",
8
+ "postSummary": "<p>A minimal post.</p>",
9
+ "authorSlug": null,
10
+ "authorName": null,
11
+ "tagSlugs": [],
12
+ "tagNames": []
13
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "key": "contact",
3
+ "name": "Website: Contact (general)",
4
+ "fields": [
5
+ {
6
+ "name": "firstname",
7
+ "label": "First name",
8
+ "fieldType": "text",
9
+ "required": false
10
+ },
11
+ {
12
+ "name": "email",
13
+ "label": "Work email",
14
+ "fieldType": "text",
15
+ "required": true
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,7 @@
1
+ [
2
+ {
3
+ "name": "topic",
4
+ "label": "Inquiry topic",
5
+ "fieldType": "text"
6
+ }
7
+ ]
@@ -0,0 +1,9 @@
1
+ {
2
+ "slug": "about",
3
+ "name": "About",
4
+ "htmlTitle": "About Example",
5
+ "metaDescription": "A minimal hcms about page.",
6
+ "language": "en",
7
+ "templatePath": "example-theme/templates/page.html",
8
+ "desiredState": "draft"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "slug": "",
3
+ "name": "Home",
4
+ "htmlTitle": "Example Home",
5
+ "metaDescription": "A minimal hcms homepage.",
6
+ "language": "en",
7
+ "templatePath": "example-theme/templates/home.html",
8
+ "desiredState": "publish"
9
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "slug": "",
3
+ "widgets": {
4
+ "hero": {
5
+ "body": {
6
+ "headline": "Ship HubSpot CMS from git",
7
+ "image": "@asset:hero.svg",
8
+ "form_id": "@form:contact"
9
+ },
10
+ "name": "hero",
11
+ "type": "module",
12
+ "label": "Hero",
13
+ "css": {},
14
+ "child_css": {}
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,3 @@
1
+ .hero {
2
+ padding: 3rem 0;
3
+ }
@@ -0,0 +1,11 @@
1
+ [
2
+ {
3
+ "name": "brand_color",
4
+ "label": "Brand color",
5
+ "type": "color",
6
+ "default": {
7
+ "color": "#2f6fed",
8
+ "opacity": 100
9
+ }
10
+ }
11
+ ]
@@ -0,0 +1,27 @@
1
+ export default {
2
+ accountsFile: 'sync/accounts.json',
3
+ keyDirEnv: 'HUBSPOT_KEY_DIR',
4
+ contentDir: 'content',
5
+ syncStateDir: '.sync-state',
6
+ manifestPath: 'site.manifest.json',
7
+ redirectsFile: 'sync/redirects.csv',
8
+ readOnlyPortalIds: ['999999'],
9
+ knownPortalIds: ['123456'],
10
+ theme: {
11
+ name: 'example-theme',
12
+ dirs: ['templates', 'modules', 'css', 'js', 'images'],
13
+ files: ['theme.json', 'fields.json']
14
+ },
15
+ blog: {
16
+ slug: 'blog',
17
+ itemTemplate: 'example-theme/templates/blog-post.html',
18
+ listingTemplate: 'example-theme/templates/blog.html'
19
+ },
20
+ uiGated: ['blogContainerCreate', 'domainConnect'],
21
+ verification: {
22
+ baseUrlEnv: 'SITE_BASE_URL',
23
+ commands: {
24
+ corpus: 'hcms corpus'
25
+ }
26
+ }
27
+ };
@@ -0,0 +1 @@
1
+ window.EXAMPLE_PORTAL_ID = '@portal';
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "name": "headline",
4
+ "label": "Headline",
5
+ "type": "text",
6
+ "default": "Ship HubSpot CMS from git"
7
+ },
8
+ {
9
+ "name": "form_id",
10
+ "label": "Contact form",
11
+ "type": "form",
12
+ "default": "@form:contact"
13
+ }
14
+ ]
@@ -0,0 +1,4 @@
1
+ <section class="hero">
2
+ <h1>{{ module.headline }}</h1>
3
+ {% form form_to_use="{{ module.form_id }}" %}
4
+ </section>
@@ -0,0 +1,29 @@
1
+ {
2
+ "theme": {
3
+ "name": "example-theme"
4
+ },
5
+ "pages": [
6
+ {
7
+ "slug": "",
8
+ "templatePath": "example-theme/templates/home.html",
9
+ "desiredState": "publish"
10
+ },
11
+ {
12
+ "slug": "about",
13
+ "templatePath": "example-theme/templates/page.html",
14
+ "desiredState": "draft"
15
+ }
16
+ ],
17
+ "blog": {
18
+ "slug": "blog",
19
+ "itemTemplate": "example-theme/templates/blog-post.html",
20
+ "listingTemplate": "example-theme/templates/blog.html"
21
+ },
22
+ "forms": [
23
+ "contact"
24
+ ],
25
+ "uiGated": [
26
+ "blogContainerCreate",
27
+ "domainConnect"
28
+ ]
29
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "dev": {
3
+ "portalId": "123456",
4
+ "label": "example dev portal"
5
+ },
6
+ "prod": {
7
+ "portalId": "999999",
8
+ "label": "example production portal"
9
+ }
10
+ }
@@ -0,0 +1,2 @@
1
+ routePrefix,destination,redirectStyle,isOnlyAfterNotFound
2
+ /old-about,/about,301,false
@@ -0,0 +1,8 @@
1
+ <!--
2
+ templateType: blog_post
3
+ label: Example Blog Post
4
+ -->
5
+ <article>
6
+ <h1>{{ content.name }}</h1>
7
+ {{ content.post_body }}
8
+ </article>
@@ -0,0 +1,9 @@
1
+ <!--
2
+ templateType: blog_listing
3
+ label: Example Blog Listing
4
+ -->
5
+ <main>
6
+ {% for content in contents %}
7
+ <article><a href="{{ content.absolute_url }}">{{ content.name }}</a></article>
8
+ {% endfor %}
9
+ </main>
@@ -0,0 +1,5 @@
1
+ <!--
2
+ templateType: page
3
+ label: Example Home
4
+ -->
5
+ {% module "hero" path="../modules/hero.module" %}
@@ -0,0 +1,7 @@
1
+ <!--
2
+ templateType: page
3
+ label: Example Page
4
+ -->
5
+ <main>
6
+ <h1>{{ content.name }}</h1>
7
+ </main>
@@ -0,0 +1,4 @@
1
+ {
2
+ "label": "Example Theme",
3
+ "preview_path": "./templates/home.html"
4
+ }
package/package.json CHANGED
@@ -1,16 +1,24 @@
1
1
  {
2
2
  "name": "hubspot-cms-sync",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/7thsense/hubspot-cms-sync.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/7thsense/hubspot-cms-sync/issues"
13
+ },
14
+ "homepage": "https://github.com/7thsense/hubspot-cms-sync#readme",
7
15
  "engines": {
8
16
  "node": ">=20.10.0",
9
17
  "npm": ">=10"
10
18
  },
11
19
  "bin": {
12
- "hubspot-cms-sync": "./bin/hubspot-cms-sync.mjs",
13
- "hcms": "./bin/hubspot-cms-sync.mjs"
20
+ "hubspot-cms-sync": "bin/hubspot-cms-sync.mjs",
21
+ "hcms": "bin/hubspot-cms-sync.mjs"
14
22
  },
15
23
  "exports": {
16
24
  ".": "./src/index.mjs",
@@ -18,6 +26,7 @@
18
26
  "./lib/*": "./src/lib/*.mjs",
19
27
  "./manifest": "./src/manifest.mjs",
20
28
  "./preflight": "./src/preflight.mjs",
29
+ "./redirects": "./src/redirects.mjs",
21
30
  "./cta-inventory": "./src/cta-inventory.mjs",
22
31
  "./corpus-scan": "./src/corpus-scan.mjs"
23
32
  },
@@ -37,5 +46,10 @@
37
46
  "lint": "node --check bin/hubspot-cms-sync.mjs && node bin/hubspot-cms-sync.mjs --help >/dev/null",
38
47
  "prepare": "npm run lint",
39
48
  "prepublishOnly": "npm test && npm run lint && npm pack --dry-run"
49
+ },
50
+ "dependencies": {
51
+ "commander": "^14.0.3",
52
+ "js-yaml": "^4.2.0",
53
+ "nunjucks": "^3.2.4"
40
54
  }
41
55
  }
@@ -35,11 +35,15 @@ hcms corpus
35
35
  hcms preflight <target>
36
36
  hcms push <target> --dry-run
37
37
  hcms push <target> --publish
38
+ hcms redirects <target>
39
+ hcms redirects <target> --apply
38
40
  the consuming repo verification commands
39
41
  ```
40
42
 
41
43
  Stop before `push` if preflight or plan output indicates a read-only portal,
42
44
  unresolved dependency, missing credential, or unexpected destructive change.
45
+ Run `hcms redirects` without `--apply` first when redirects are part of the
46
+ deployment surface.
43
47
 
44
48
  ## Republish
45
49
 
@@ -9,6 +9,7 @@ Key fields:
9
9
  - `contentDir`: local CMS content directory.
10
10
  - `syncStateDir`: local sync state directory; do not edit by hand.
11
11
  - `manifestPath`: path to `site.manifest.json`.
12
+ - `redirectsFile`: optional CSV or JSON file for repo-managed URL redirects.
12
13
  - `readOnlyPortalIds`: portals that must never receive writes.
13
14
  - `knownPortalIds`: expected portal allowlist.
14
15
  - `assetHosts`: host canonicalization policy for HubSpot assets.
@@ -19,7 +20,10 @@ Key fields:
19
20
  - `verification`: base URL environment variable and repo-specific test commands.
20
21
 
21
22
  `site.manifest.json` is the deployment surface for theme, blog, forms, pages,
22
- and UI-gated operations. If the manifest and config disagree, stop and ask for
23
- the intended source of truth.
23
+ and UI-gated operations. Redirects are managed separately through
24
+ `redirectsFile`. If the manifest and config disagree, stop and ask for the
25
+ intended source of truth.
24
26
 
25
27
  Use paths relative to the repo root unless the config explicitly says otherwise.
28
+ See `docs/CONTENT_LAYOUT.md` for the expected repository layout and minimal
29
+ sample tree.
package/src/config.mjs CHANGED
@@ -14,6 +14,7 @@ export function defaultConfig(root = process.cwd()) {
14
14
  contentDir: 'content',
15
15
  syncStateDir: '.sync-state',
16
16
  manifestPath: 'site.manifest.json',
17
+ redirectsFile: null,
17
18
  readOnlyPortalIds: [],
18
19
  knownPortalIds: [],
19
20
  assetHosts: {
@@ -67,6 +68,7 @@ export function resolveConfigPaths(cfg) {
67
68
  contentDirPath: abs(cfg.contentDir),
68
69
  syncStateDirPath: abs(cfg.syncStateDir),
69
70
  manifestFilePath: abs(cfg.manifestPath),
71
+ redirectsFilePath: cfg.redirectsFile ? abs(cfg.redirectsFile) : null,
70
72
  };
71
73
  }
72
74
 
@@ -94,6 +96,9 @@ export function validateConfig(cfg) {
94
96
  if (!cfg.contentDir) errors.push('missing contentDir');
95
97
  if (!cfg.syncStateDir) errors.push('missing syncStateDir');
96
98
  if (!cfg.manifestPath) errors.push('missing manifestPath');
99
+ if (cfg.redirectsFile != null && typeof cfg.redirectsFile !== 'string') {
100
+ errors.push('redirectsFile must be a string when set');
101
+ }
97
102
  if (!Array.isArray(cfg.readOnlyPortalIds)) errors.push('readOnlyPortalIds must be an array');
98
103
  if (!Array.isArray(cfg.knownPortalIds)) errors.push('knownPortalIds must be an array');
99
104
  if (!cfg.theme?.name) errors.push('theme.name is required');
package/src/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
1
  export { loadConfig } from './config.mjs';
2
2
  export { pull } from './pull.mjs';
3
3
  export { push, preflightRefs } from './push.mjs';
4
+ export { syncRedirects, planRedirects, parseRedirectCsv } from './redirects.mjs';
@@ -0,0 +1,168 @@
1
+ // src/lib/content-view.mjs — NEUTRAL content projection.
2
+ //
3
+ // THE BOUNDARY. The on-disk canonical store is HubSpot's CMS WIRE FORMAT: pages
4
+ // carry `widgets.<name>.body.<field>` editor carriers (with load-bearing empty
5
+ // `css`/`child_css`/`label` objects that HubSpot's replace-not-merge PATCH
6
+ // requires — see lib/canonical.mjs), blog posts use HubSpot Blog API field names
7
+ // (`postBody`, `useFeaturedImage`, `blogSlug`, `state:"PUBLISHED"`), and module
8
+ // `fields.json` files carry server-assigned GUIDs and `content_types` enums.
9
+ //
10
+ // None of that belongs in a GENERIC publishing toolkit. This module projects the
11
+ // wire format into a small, target-agnostic VIEW that the renderer and any future
12
+ // non-HubSpot target consume. The renderer NEVER reaches into `.widgets.x.body`,
13
+ // reads a field GUID, or knows the string "PUBLISHED" — it sees only this view.
14
+ //
15
+ // Identity portability (@asset:/@portal/@form: refs) was already solved by
16
+ // lib/refs.mjs. This is the same idea extended to SCHEMA portability: the HubSpot
17
+ // blob is one target's codec output, not the canonical truth. Today the view is
18
+ // derived from the blob on read; later the view's shape could become the on-disk
19
+ // format and a HubSpot codec could reconstruct the blob — the seam is the same.
20
+ //
21
+ // PURE except for the explicit fs reads in load*(). The project*() transforms are
22
+ // pure functions over parsed JSON so they unit-test without a fixture tree.
23
+
24
+ import { readFile, readdir } from 'node:fs/promises';
25
+ import { join, basename } from 'node:path';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Status vocabulary. HubSpot speaks `desiredState` (page) and `state` (post):
29
+ // publish|draft|archive|ignore / PUBLISHED|DRAFT. The view speaks one neutral
30
+ // enum so a target never branches on a HubSpot string.
31
+ // ---------------------------------------------------------------------------
32
+ const STATUS = { published: 'published', draft: 'draft', archived: 'archived' };
33
+
34
+ function neutralStatus(raw) {
35
+ if (!raw) return STATUS.draft;
36
+ const s = String(raw).toLowerCase();
37
+ if (s === 'publish' || s === 'published') return STATUS.published;
38
+ if (s === 'archive' || s === 'archived' || s === 'ignore') return STATUS.archived;
39
+ return STATUS.draft;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // projectPost(raw, authorsByName) -> neutral post view
44
+ //
45
+ // Maps HubSpot Blog API field names onto neutral names and JOINS the author
46
+ // record by name. `body`/`summary` stay as raw HTML strings (still carrying
47
+ // @asset: refs for the target to resolve). `featuredImage` is left as its
48
+ // logical ref — ref resolution is the TARGET's job, not the view's.
49
+ // ---------------------------------------------------------------------------
50
+ export function projectPost(raw, authorsByName = {}) {
51
+ const author = authorsByName[raw.authorName] || (raw.authorName ? { name: raw.authorName } : null);
52
+ return {
53
+ kind: 'post',
54
+ // Routing: HubSpot stores `slug` as "blog/<slug>"; the route is absolute.
55
+ slug: raw.slug,
56
+ route: '/' + String(raw.slug || '').replace(/^\/+/, ''),
57
+ status: neutralStatus(raw.state),
58
+ title: raw.name,
59
+ htmlTitle: raw.htmlTitle || raw.name,
60
+ metaDescription: raw.metaDescription || '',
61
+ body: raw.postBody || '',
62
+ summary: raw.postSummary || '',
63
+ publishDate: raw.publishDate || null,
64
+ tags: Array.isArray(raw.tagNames) ? raw.tagNames.slice() : [],
65
+ author,
66
+ featuredImage: raw.useFeaturedImage ? raw.featuredImage || null : null,
67
+ featuredImageAlt: raw.featuredImageAltText || '',
68
+ blogSlug: raw.blogSlug || 'blog',
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // projectPage(raw) -> neutral page view
74
+ //
75
+ // The crux of the schema-portability argument. HubSpot nests per-module field
76
+ // values under `widgets.<instanceName>.body.<field>`, wrapped in editor cruft
77
+ // (`css`, `child_css`, `label`, `type:"module"`) that is inert at render time.
78
+ // The view flattens each carrier to `modules[instanceName] = {<field>: value}`
79
+ // and drops the cruft. The renderer keys modules by instance name; it never sees
80
+ // `body` or the empty style objects the HubSpot target round-trips.
81
+ // ---------------------------------------------------------------------------
82
+ export function projectPage(raw) {
83
+ const modules = {};
84
+ const widgets = raw.widgets || {};
85
+ for (const [name, carrier] of Object.entries(widgets)) {
86
+ if (!carrier || carrier.type !== 'module') continue;
87
+ modules[name] = { ...(carrier.body || {}) };
88
+ }
89
+ // `templatePath` is "<theme>/templates/<file>.html"; the view keeps the file
90
+ // path relative to the theme so the renderer's loader resolves it.
91
+ const tpl = String(raw.templatePath || '');
92
+ const templateRel = tpl.replace(/^[^/]+\//, ''); // drop leading "<theme>/"
93
+ return {
94
+ kind: 'page',
95
+ slug: raw.slug ?? '',
96
+ route: '/' + String(raw.slug ?? '').replace(/^\/+/, ''),
97
+ status: neutralStatus(raw.desiredState),
98
+ title: raw.name,
99
+ htmlTitle: raw.htmlTitle || raw.name,
100
+ metaDescription: raw.metaDescription || '',
101
+ language: raw.language || 'en',
102
+ template: templateRel,
103
+ modules,
104
+ };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Loaders. The only I/O in this module.
109
+ // ---------------------------------------------------------------------------
110
+ async function readJson(file) {
111
+ return JSON.parse(await readFile(file, 'utf8'));
112
+ }
113
+
114
+ /** loadAuthors(blogDir) -> { [displayName|name]: neutralAuthor } */
115
+ export async function loadAuthors(blogDir) {
116
+ const byName = {};
117
+ try {
118
+ const authors = await readJson(join(blogDir, 'authors.json'));
119
+ for (const a of authors) {
120
+ const neutral = {
121
+ name: a.displayName || a.fullName || a.name,
122
+ bio: a.bio || '',
123
+ avatar: a.avatar || null,
124
+ slug: a.slug || null,
125
+ };
126
+ for (const key of [a.displayName, a.fullName, a.name]) {
127
+ if (key) byName[key] = neutral;
128
+ }
129
+ }
130
+ } catch {
131
+ /* authors.json optional */
132
+ }
133
+ return byName;
134
+ }
135
+
136
+ /** loadPosts(contentDir) -> neutralPostView[] (newest first) */
137
+ export async function loadPosts(contentDir) {
138
+ const blogDir = join(contentDir, 'blog');
139
+ const postsDir = join(blogDir, 'posts');
140
+ const authorsByName = await loadAuthors(blogDir);
141
+ const files = (await readdir(postsDir)).filter((f) => f.endsWith('.json'));
142
+ const posts = [];
143
+ for (const f of files.sort()) {
144
+ posts.push(projectPost(await readJson(join(postsDir, f)), authorsByName));
145
+ }
146
+ posts.sort((a, b) => String(b.publishDate || '').localeCompare(String(a.publishDate || '')));
147
+ return posts;
148
+ }
149
+
150
+ /** loadPages(contentDir) -> neutralPageView[] */
151
+ export async function loadPages(contentDir) {
152
+ const pagesDir = join(contentDir, 'pages');
153
+ const files = (await readdir(pagesDir)).filter((f) => f.endsWith('.json'));
154
+ const pages = [];
155
+ for (const f of files.sort()) {
156
+ pages.push(projectPage(await readJson(join(pagesDir, f))));
157
+ }
158
+ return pages;
159
+ }
160
+
161
+ /** loadSite(siteDir) -> { posts, pages } against a theme repo root. */
162
+ export async function loadSite(siteDir, { contentDir = 'content' } = {}) {
163
+ const cdir = join(siteDir, contentDir);
164
+ const [posts, pages] = await Promise.all([loadPosts(cdir), loadPages(cdir)]);
165
+ return { posts, pages };
166
+ }
167
+
168
+ export { basename, STATUS };
@@ -0,0 +1,90 @@
1
+ // src/lib/posts-format.mjs — lossless codec between a blog post's HubSpot WIRE
2
+ // JSON and a human-readable frontmatter+body FILE.
3
+ //
4
+ // HubSpot stays the PRIMARY target, so this reshaping must never lose a field the
5
+ // blog push depends on. The contract is strict: fileToWire(wireToFile(x)) deep-
6
+ // equals x for every post in the corpus (proven by scripts/posts-roundtrip.mjs and
7
+ // the unit test). Losslessness is by CONSTRUCTION — known fields get pretty,
8
+ // neutral names; every other field passes through verbatim, so nothing is ever
9
+ // silently dropped. The pretty layer is cosmetic; the passthrough layer is the
10
+ // safety net.
11
+ //
12
+ // Why frontmatter: every post in the corpus is flat scalars + one array + two HTML
13
+ // blobs, the textbook frontmatter shape. The body becomes readable HTML; the diff
14
+ // becomes prose instead of an escaped JSON string; and Git-CMS tools (Keystatic /
15
+ // Tina / Decap) can later edit it directly. Templates and the page format are
16
+ // untouched — this is the light-touch slice.
17
+ //
18
+ // YAML note: js-yaml's DEFAULT_SCHEMA coerces ISO timestamps to Date objects, which
19
+ // would corrupt `publishDate` on round-trip. CORE_SCHEMA has no timestamp type, so
20
+ // every scalar stays the string it was. lineWidth:-1 disables line folding so long
21
+ // HTML/strings survive byte-for-byte.
22
+
23
+ import yaml from 'js-yaml';
24
+
25
+ const SCHEMA = yaml.CORE_SCHEMA;
26
+ const DUMP_OPTS = { schema: SCHEMA, lineWidth: -1, noRefs: true, quotingType: '"' };
27
+
28
+ // Wire field -> pretty frontmatter key (bijective). `state` and `postBody` are
29
+ // handled specially (status transform / body extraction); everything NOT listed
30
+ // here passes through under its own wire name, so the codec can never drop a field.
31
+ const TO_PRETTY = {
32
+ name: 'title',
33
+ authorName: 'author',
34
+ tagNames: 'tags',
35
+ featuredImageAltText: 'featuredImageAlt',
36
+ postSummary: 'summary',
37
+ };
38
+ const TO_WIRE = Object.fromEntries(Object.entries(TO_PRETTY).map(([w, p]) => [p, w]));
39
+
40
+ const STATE_TO_STATUS = { PUBLISHED: 'published', DRAFT: 'draft' };
41
+ const STATUS_TO_STATE = Object.fromEntries(Object.entries(STATE_TO_STATUS).map(([s, t]) => [t, s]));
42
+
43
+ // Frontmatter key order: authored fields first (readability), passthrough/sync
44
+ // metadata last. Keys not listed keep insertion order after these.
45
+ const KEY_ORDER = ['title', 'htmlTitle', 'slug', 'status', 'publishDate', 'author',
46
+ 'tags', 'featuredImage', 'featuredImageAlt', 'useFeaturedImage', 'metaDescription', 'summary'];
47
+
48
+ function orderKeys(obj) {
49
+ const out = {};
50
+ for (const k of KEY_ORDER) if (k in obj) out[k] = obj[k];
51
+ for (const k of Object.keys(obj)) if (!(k in out)) out[k] = obj[k];
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * wireToFile(post) -> string (frontmatter + HTML body)
57
+ * `postBody` becomes the body; every other field becomes frontmatter, renamed to
58
+ * its pretty key when one exists, otherwise passed through verbatim.
59
+ */
60
+ export function wireToFile(post) {
61
+ const fm = {};
62
+ let body = '';
63
+ for (const [k, v] of Object.entries(post)) {
64
+ if (k === 'postBody') { body = v ?? ''; continue; }
65
+ if (k === 'state') { fm.status = STATE_TO_STATUS[v] ?? v; continue; }
66
+ fm[TO_PRETTY[k] ?? k] = v;
67
+ }
68
+ const front = yaml.dump(orderKeys(fm), DUMP_OPTS);
69
+ return `---\n${front}---\n${body}`;
70
+ }
71
+
72
+ const FENCE_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
73
+
74
+ /**
75
+ * fileToWire(str) -> post (inverse of wireToFile)
76
+ * Restores `postBody` from the body and inverts the pretty renames / status map;
77
+ * passthrough keys return under their original wire names unchanged.
78
+ */
79
+ export function fileToWire(str) {
80
+ const m = FENCE_RE.exec(str);
81
+ if (!m) throw new Error('posts-format: missing or malformed frontmatter fence');
82
+ const fm = yaml.load(m[1], { schema: SCHEMA }) || {};
83
+ const post = {};
84
+ for (const [k, v] of Object.entries(fm)) {
85
+ if (k === 'status') { post.state = STATUS_TO_STATE[v] ?? v; continue; }
86
+ post[TO_WIRE[k] ?? k] = v;
87
+ }
88
+ post.postBody = m[2];
89
+ return post;
90
+ }