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,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,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,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,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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hubspot-cms-sync",
|
|
3
|
-
"version": "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": "
|
|
13
|
-
"hcms": "
|
|
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.
|
|
23
|
-
the
|
|
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
|
@@ -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
|
+
}
|