hubspot-cms-sync 0.2.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/README.md +9 -3
- package/bin/hubspot-cms-sync.mjs +5 -2
- package/docs/CONTENT_LAYOUT.md +146 -0
- package/docs/GITHUB_ACTIONS.md +3 -1
- 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 +4 -2
- package/skill/references/commands.md +4 -0
- package/skill/references/config.md +6 -2
- 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/README.md
CHANGED
|
@@ -9,6 +9,11 @@ The tool reads account and site settings from `hubspot-cms-sync.config.mjs`,
|
|
|
9
9
|
stores per-account identity in a gitignored `.sync-state/` directory, and
|
|
10
10
|
refuses to write to configured read-only portal ids.
|
|
11
11
|
|
|
12
|
+
See [`docs/CONTENT_LAYOUT.md`](docs/CONTENT_LAYOUT.md) for the repository
|
|
13
|
+
layout, supported push surfaces, and a minimal example tree. A runnable fixture
|
|
14
|
+
lives in [`examples/minimal-site/`](examples/minimal-site/) and is validated by
|
|
15
|
+
the unit tests.
|
|
16
|
+
|
|
12
17
|
## Install
|
|
13
18
|
|
|
14
19
|
```bash
|
|
@@ -29,15 +34,16 @@ hcms pull dev
|
|
|
29
34
|
hcms preflight dev
|
|
30
35
|
hcms push dev --dry-run
|
|
31
36
|
hcms push dev --publish
|
|
32
|
-
hcms redirects dev
|
|
33
|
-
hcms redirects dev --
|
|
37
|
+
hcms redirects dev
|
|
38
|
+
hcms redirects dev --apply
|
|
34
39
|
hcms republish dev --all --blog
|
|
35
40
|
hcms corpus
|
|
36
41
|
hcms manifest validate
|
|
37
42
|
```
|
|
38
43
|
|
|
39
44
|
`hcms redirects` is dry-run by default. Pass `--apply` to create or update
|
|
40
|
-
HubSpot URL redirects from
|
|
45
|
+
HubSpot URL redirects from the configured `redirectsFile`, or pass `--file` to
|
|
46
|
+
use a specific repo-stored CSV or JSON spec. Managed redirects
|
|
41
47
|
default to `301` and `isOnlyAfterNotFound: false`, so they can intentionally
|
|
42
48
|
take precedence over an existing live HubSpot page during a cutover.
|
|
43
49
|
|
package/bin/hubspot-cms-sync.mjs
CHANGED
|
@@ -74,9 +74,12 @@ async function main(argv = process.argv) {
|
|
|
74
74
|
.command('preflight')
|
|
75
75
|
.description('check account readiness before a push')
|
|
76
76
|
.argument('<account>')
|
|
77
|
-
.
|
|
77
|
+
.option('--allow-repairable', 'allow source-repairable portal drift before the push')
|
|
78
|
+
.action(async (account, options) => {
|
|
78
79
|
const config = await withConfig(program.opts());
|
|
79
|
-
const
|
|
80
|
+
const args = [account];
|
|
81
|
+
if (options.allowRepairable) args.push('--allow-repairable');
|
|
82
|
+
const code = await preflightMain(args, { config });
|
|
80
83
|
if (code) process.exitCode = code;
|
|
81
84
|
});
|
|
82
85
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Content Layout
|
|
2
|
+
|
|
3
|
+
`hcms` syncs a normal repository tree into HubSpot CMS records. The exact paths
|
|
4
|
+
are configurable in `hubspot-cms-sync.config.mjs`, but the default layout is:
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
my-site/
|
|
8
|
+
|-- hubspot-cms-sync.config.mjs
|
|
9
|
+
|-- site.manifest.json
|
|
10
|
+
|-- sync/
|
|
11
|
+
| |-- accounts.json
|
|
12
|
+
| `-- redirects.csv
|
|
13
|
+
|-- content/
|
|
14
|
+
| |-- pages/
|
|
15
|
+
| | |-- home.json
|
|
16
|
+
| | |-- home.widgets.json
|
|
17
|
+
| | `-- about.json
|
|
18
|
+
| |-- forms/
|
|
19
|
+
| | |-- contact.json
|
|
20
|
+
| | `-- properties.json
|
|
21
|
+
| |-- assets/
|
|
22
|
+
| | `-- hero.svg
|
|
23
|
+
| `-- blog/
|
|
24
|
+
| |-- container.json
|
|
25
|
+
| `-- posts/
|
|
26
|
+
| `-- blog__hello-world.json
|
|
27
|
+
|-- templates/
|
|
28
|
+
| |-- home.html
|
|
29
|
+
| |-- page.html
|
|
30
|
+
| |-- blog.html
|
|
31
|
+
| `-- blog-post.html
|
|
32
|
+
|-- modules/
|
|
33
|
+
| `-- hero.module/
|
|
34
|
+
| |-- fields.json
|
|
35
|
+
| `-- module.html
|
|
36
|
+
|-- css/
|
|
37
|
+
| `-- main.css
|
|
38
|
+
|-- js/
|
|
39
|
+
| `-- hs-forms.js
|
|
40
|
+
|-- theme.json
|
|
41
|
+
`-- fields.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A complete minimal fixture lives at [`examples/minimal-site/`](../examples/minimal-site/).
|
|
45
|
+
The unit test suite loads that fixture, validates its manifest and redirects,
|
|
46
|
+
runs the local push ref preflight, reads its canonical forms, and scans it with
|
|
47
|
+
the corpus scanner.
|
|
48
|
+
|
|
49
|
+
## What Can Be Pushed
|
|
50
|
+
|
|
51
|
+
`hcms push <account>` writes the content surfaces below. It is idempotent by
|
|
52
|
+
portable identity: page slug, form name/key, blog slug/post slug, theme path, and
|
|
53
|
+
redirect route.
|
|
54
|
+
|
|
55
|
+
| Surface | Repo source | HubSpot target |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| Theme code | `templates/`, `modules/`, `css/`, `js/`, `images/`, `theme.json`, `fields.json` | CMS Source Code API |
|
|
58
|
+
| Site pages | `content/pages/*.json` plus `site.manifest.json` | CMS Pages API |
|
|
59
|
+
| Page module values | `content/pages/*.widgets.json` | CMS Pages draft widget carrier |
|
|
60
|
+
| Forms | `content/forms/<key>.json` | Forms API |
|
|
61
|
+
| Contact properties | `content/forms/properties.json` | CRM Properties API |
|
|
62
|
+
| File assets | `content/assets/**` and `content/blog/assets/**` | File Manager API |
|
|
63
|
+
| Blog container and posts | `content/blog/container.json`, `content/blog/posts/*.json` | Blog APIs |
|
|
64
|
+
| URL redirects | `sync/redirects.csv` or configured `redirectsFile` | CMS URL Redirects API |
|
|
65
|
+
|
|
66
|
+
## Deployment Surface
|
|
67
|
+
|
|
68
|
+
`site.manifest.json` is the allowlist for pages, forms, blog, theme, and
|
|
69
|
+
UI-gated prerequisites. A page file under `content/pages/` is not enough by
|
|
70
|
+
itself; the page must also be listed in the manifest.
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"theme": { "name": "example-theme" },
|
|
75
|
+
"pages": [
|
|
76
|
+
{
|
|
77
|
+
"slug": "",
|
|
78
|
+
"templatePath": "example-theme/templates/home.html",
|
|
79
|
+
"desiredState": "publish"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"slug": "about",
|
|
83
|
+
"templatePath": "example-theme/templates/page.html",
|
|
84
|
+
"desiredState": "draft"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"blog": {
|
|
88
|
+
"slug": "blog",
|
|
89
|
+
"itemTemplate": "example-theme/templates/blog-post.html",
|
|
90
|
+
"listingTemplate": "example-theme/templates/blog.html"
|
|
91
|
+
},
|
|
92
|
+
"forms": ["contact"],
|
|
93
|
+
"uiGated": ["blogContainerCreate", "domainConnect"]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`desiredState` may be `publish`, `draft`, `archive`, or `ignore`.
|
|
98
|
+
|
|
99
|
+
## Portable References
|
|
100
|
+
|
|
101
|
+
Committed content should not contain raw portal IDs, form GUIDs, CTA GUIDs, or
|
|
102
|
+
HubSpot-hosted asset URLs. Use logical refs instead:
|
|
103
|
+
|
|
104
|
+
| Logical ref | Producer source |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `@portal` | The target account's portal ID |
|
|
107
|
+
| `@form:contact` | `content/forms/contact.json` or `content/forms/guids.json` |
|
|
108
|
+
| `@asset:hero.svg` | `content/assets/hero.svg` or `content/blog/assets/hero.svg` |
|
|
109
|
+
|
|
110
|
+
`hcms push` runs a local preflight before network writes. If content references
|
|
111
|
+
`@form:contact`, the form producer source must exist. If content references
|
|
112
|
+
`@asset:hero.png`, committed bytes must exist. `@cta:*` and `@menu:*` currently
|
|
113
|
+
fail closed because there are no producer adapters for them yet.
|
|
114
|
+
|
|
115
|
+
## Redirects
|
|
116
|
+
|
|
117
|
+
Redirects are separate from the page manifest. Configure their path with
|
|
118
|
+
`redirectsFile`, then run:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
hcms redirects dev # dry-run
|
|
122
|
+
hcms redirects dev --apply # create/update HubSpot redirects
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
CSV redirects need at least:
|
|
126
|
+
|
|
127
|
+
```csv
|
|
128
|
+
routePrefix,destination,redirectStyle,isOnlyAfterNotFound
|
|
129
|
+
/old-about,/about,301,false
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`isOnlyAfterNotFound=false` makes a redirect take precedence over an existing
|
|
133
|
+
live page at the same route. Use it deliberately during cutovers.
|
|
134
|
+
|
|
135
|
+
## Not Fully Automated
|
|
136
|
+
|
|
137
|
+
Some portal state is still UI-gated or depends on HubSpot account setup:
|
|
138
|
+
|
|
139
|
+
- connecting domains;
|
|
140
|
+
- choosing system pages such as the default 404;
|
|
141
|
+
- creating the initial blog container in some portals;
|
|
142
|
+
- native menus until a menu producer exists;
|
|
143
|
+
- theme settings values if the theme relies on HubSpot UI-managed settings.
|
|
144
|
+
|
|
145
|
+
Track those prerequisites in `uiGated` and check them with
|
|
146
|
+
`hcms preflight <account>` before writing content.
|
package/docs/GITHUB_ACTIONS.md
CHANGED
|
@@ -54,7 +54,9 @@ The examples prefer this sequence for write-capable operations:
|
|
|
54
54
|
3. `hcms preflight <target>`
|
|
55
55
|
4. `hcms push <target> --dry-run`
|
|
56
56
|
5. `hcms push <target> --publish`
|
|
57
|
-
6. `
|
|
57
|
+
6. `hcms redirects <target>`
|
|
58
|
+
7. `hcms redirects <target> --apply`
|
|
59
|
+
8. `the consuming repo verification commands`
|
|
58
60
|
|
|
59
61
|
For read-only CI, omit write steps and keep production credentials unavailable.
|
|
60
62
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">Example hero image</title>
|
|
3
|
+
<desc id="desc">A simple placeholder image for the minimal hcms site.</desc>
|
|
4
|
+
<rect width="1200" height="630" fill="#f5f7fb"/>
|
|
5
|
+
<rect x="96" y="96" width="1008" height="438" rx="24" fill="#ffffff" stroke="#d8dee9"/>
|
|
6
|
+
<circle cx="210" cy="220" r="58" fill="#1f7a8c"/>
|
|
7
|
+
<path d="M320 190h520v34H320zM320 252h420v28H320zM320 310h600v22H320zM320 352h480v22H320z" fill="#233142"/>
|
|
8
|
+
<path d="M160 430h220v58H160z" fill="#f25f5c"/>
|
|
9
|
+
</svg>
|
|
@@ -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,6 +1,6 @@
|
|
|
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",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"prepublishOnly": "npm test && npm run lint && npm pack --dry-run"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"commander": "^14.0.3"
|
|
51
|
+
"commander": "^14.0.3",
|
|
52
|
+
"js-yaml": "^4.2.0",
|
|
53
|
+
"nunjucks": "^3.2.4"
|
|
52
54
|
}
|
|
53
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.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// src/lib/render.mjs — HubL -> HTML rendering for the STATIC target.
|
|
2
|
+
//
|
|
3
|
+
// This is the deliberately HubSpot-FLAVORED part of the toolkit. HubSpot renders
|
|
4
|
+
// HubL server-side; a static target (Cloudflare Pages, plain dir) has no server,
|
|
5
|
+
// so the toolkit must render the same templates itself at build time. The engine
|
|
6
|
+
// is Nunjucks (Jinja2-flavored, like HubL) plus a small, finite compatibility
|
|
7
|
+
// layer for the handful of constructs HubL has that Nunjucks does not.
|
|
8
|
+
//
|
|
9
|
+
// Inputs are NEUTRAL views from lib/content-view.mjs — the renderer never reads a
|
|
10
|
+
// `widgets.x.body` carrier, a field GUID, or the string "PUBLISHED". It maps the
|
|
11
|
+
// neutral view onto the snake_case `content.*` variable contract the HubL
|
|
12
|
+
// templates reference (HubSpot exposes its model to HubL in snake_case), and
|
|
13
|
+
// shims the HubL-only globals/filters/tags.
|
|
14
|
+
//
|
|
15
|
+
// HubL constructs handled:
|
|
16
|
+
// - {{ get_asset_url('../css/main.css') }} -> root-relative "/css/main.css"
|
|
17
|
+
// - {% include "../templates/shared-nav.html" %} (path normalized in loader)
|
|
18
|
+
// - blog_recent_posts(group, n) -> recent neutral posts as content shims
|
|
19
|
+
// - standard_header_includes / standard_footer_includes -> injected strings
|
|
20
|
+
// - x[:2] / x[1:] / x[1:3] Python-style slices -> |hubslice() (preprocess)
|
|
21
|
+
// - {% module %} -> see module-tag extension (added for page rendering)
|
|
22
|
+
//
|
|
23
|
+
// Pure except for the loader's synchronous template file reads.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import nunjucks from 'nunjucks';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// HubL -> Nunjucks source preprocessing. Applied to every template the loader
|
|
31
|
+
// serves. Currently: Python/Jinja slice subscripts, which HubL supports and
|
|
32
|
+
// Nunjucks does not. `name[:2]` -> `name|hubslice(0,null)`-style rewrite. Only
|
|
33
|
+
// dotted identifier targets are matched (covers the corpus: author name initials).
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const SLICE_RE = /([A-Za-z_][\w.]*)\[(\d*):(\d*)\]/g;
|
|
36
|
+
|
|
37
|
+
export function preprocessHubl(src) {
|
|
38
|
+
return src.replace(SLICE_RE, (_m, expr, a, b) => {
|
|
39
|
+
const start = a === '' ? '0' : a;
|
|
40
|
+
const end = b === '' ? 'null' : b;
|
|
41
|
+
return `${expr}|hubslice(${start},${end})`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Loader: resolves HubSpot-style template-relative include paths against the
|
|
47
|
+
// theme root and preprocesses HubL source on the way out. "../templates/x.html"
|
|
48
|
+
// and "templates/x.html" both resolve to <siteDir>/templates/x.html — every
|
|
49
|
+
// include in the corpus is template-relative ("../templates/..."), so stripping
|
|
50
|
+
// leading ./ .. segments yields the theme-root-relative path.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
function makeLoader(siteDir) {
|
|
53
|
+
return {
|
|
54
|
+
async: false,
|
|
55
|
+
getSource(name) {
|
|
56
|
+
const rel = name.split('/').filter((p) => p && p !== '.' && p !== '..').join('/');
|
|
57
|
+
const full = join(siteDir, rel);
|
|
58
|
+
const raw = readFileSync(full, 'utf8');
|
|
59
|
+
return { src: preprocessHubl(raw), path: full, noCache: true };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Static ref resolution. The HubSpot target resolves @asset:/@portal to portal
|
|
66
|
+
// GUIDs + hosted hubfs URLs (lib/refs.mjs); the static target resolves them to
|
|
67
|
+
// local paths under the deployed site. Minimal for the spike: @asset:NAME ->
|
|
68
|
+
// /assets/NAME, applied to attribute values and rich-text bodies alike.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
export function resolveStaticRefs(value, { assetBase = '/assets' } = {}) {
|
|
71
|
+
if (value == null) return value;
|
|
72
|
+
return String(value).replace(/@asset:([^\s"'<>)]+)/g, (_m, nameRef) => `${assetBase}/${nameRef}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// get_asset_url('../css/main.css') -> "/css/main.css". Theme assets live at the
|
|
76
|
+
// repo root (css/ js/ images/); HubL refs them template-relative with leading ../.
|
|
77
|
+
function assetUrl(path) {
|
|
78
|
+
return '/' + String(path).replace(/^(\.\.\/)+/, '').replace(/^\/+/, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
82
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
83
|
+
|
|
84
|
+
function localizeDate(iso) {
|
|
85
|
+
if (!iso) return '';
|
|
86
|
+
const d = new Date(iso);
|
|
87
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
88
|
+
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Neutral post view -> HubL `content` shim (snake_case, refs resolved for the
|
|
93
|
+
// static target). Reused for the page being rendered AND for related-post cards
|
|
94
|
+
// returned by blog_recent_posts().
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
function postContent(post, { baseUrl = '', assetBase = '/assets' } = {}) {
|
|
97
|
+
const author = post.author || null;
|
|
98
|
+
return {
|
|
99
|
+
name: post.title,
|
|
100
|
+
html_title: post.htmlTitle,
|
|
101
|
+
meta_description: post.metaDescription,
|
|
102
|
+
post_body: resolveStaticRefs(post.body, { assetBase }),
|
|
103
|
+
post_summary: resolveStaticRefs(post.summary, { assetBase }),
|
|
104
|
+
publish_date_localized: localizeDate(post.publishDate),
|
|
105
|
+
featured_image: post.featuredImage ? resolveStaticRefs(post.featuredImage, { assetBase }) : '',
|
|
106
|
+
featured_image_alt_text: post.featuredImageAlt,
|
|
107
|
+
tag_list: post.tags.map((t) => ({ name: t })),
|
|
108
|
+
blog_post_author: author ? { display_name: author.name, bio: resolveStaticRefs(author.bio, { assetBase }) } : null,
|
|
109
|
+
absolute_url: baseUrl + post.route,
|
|
110
|
+
canonical_url: baseUrl + post.route,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Environment factory. One env per render call keeps globals (blog_recent_posts
|
|
116
|
+
// closure, header/footer includes) bound to the current site + options.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
function makeEnv(siteDir, { site, opts }) {
|
|
119
|
+
const env = new nunjucks.Environment(makeLoader(siteDir), { autoescape: false, throwOnUndefined: false });
|
|
120
|
+
|
|
121
|
+
env.addFilter('hubslice', (str, start, end) =>
|
|
122
|
+
end === null || end === undefined ? String(str ?? '').slice(start) : String(str ?? '').slice(start, end));
|
|
123
|
+
|
|
124
|
+
env.addGlobal('get_asset_url', assetUrl);
|
|
125
|
+
env.addGlobal('html_lang', opts.lang || 'en');
|
|
126
|
+
env.addGlobal('html_lang_dir', '');
|
|
127
|
+
env.addGlobal('standard_header_includes', opts.headerIncludes || '');
|
|
128
|
+
env.addGlobal('standard_footer_includes', opts.footerIncludes || '');
|
|
129
|
+
|
|
130
|
+
// blog_recent_posts('default', n) — HubL's recent-posts query. Backed by the
|
|
131
|
+
// build-time neutral corpus, newest-first, projected to content shims.
|
|
132
|
+
env.addGlobal('blog_recent_posts', (_group, count) =>
|
|
133
|
+
(site?.posts || []).slice(0, count || 5).map((p) => postContent(p, opts)));
|
|
134
|
+
|
|
135
|
+
return env;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Public: render one neutral post view to an HTML string.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
export function renderPost(post, { siteDir, site, baseUrl = '', assetBase = '/assets', lang = 'en',
|
|
142
|
+
headerIncludes = '', footerIncludes = '', template = 'templates/blog-post.html' } = {}) {
|
|
143
|
+
const opts = { baseUrl, assetBase, lang, headerIncludes, footerIncludes };
|
|
144
|
+
const env = makeEnv(siteDir, { site, opts });
|
|
145
|
+
const context = {
|
|
146
|
+
content: postContent(post, opts),
|
|
147
|
+
nav_active: null,
|
|
148
|
+
nav_hide_cta: false,
|
|
149
|
+
};
|
|
150
|
+
return env.render(template, context);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { postContent, assetUrl, localizeDate, makeEnv };
|
package/src/preflight.mjs
CHANGED
|
@@ -86,6 +86,62 @@ export function manifestBlogSlug(repoRoot = REPO_ROOT) {
|
|
|
86
86
|
return DEFAULT_BLOG_SLUG;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function readJsonIfPresent(path) {
|
|
90
|
+
if (!existsSync(path)) return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function containerFileFor(slug) {
|
|
99
|
+
if (slug === 'blog' || !slug) return 'container.json';
|
|
100
|
+
return `container.${String(slug).replace(/\//g, '__')}.json`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pointsAtTheme(path, themeName) {
|
|
104
|
+
const p = String(path || '');
|
|
105
|
+
return p.includes(`${themeName}/`) || p.includes(`${themeName}\\`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Inspect the local repo to decide which portal drift can be repaired by a
|
|
110
|
+
* normal push. This never claims HubSpot-created prerequisites are repairable:
|
|
111
|
+
* the blog container itself, service-key scopes, and domain connection remain
|
|
112
|
+
* external setup.
|
|
113
|
+
*/
|
|
114
|
+
export function sourceRepairability(repoRoot = REPO_ROOT, opts = {}) {
|
|
115
|
+
const themeName = opts.themeName ?? THEME_NAME;
|
|
116
|
+
const blogSlug = opts.blogSlug ?? manifestBlogSlug(repoRoot);
|
|
117
|
+
const contentDir = opts.contentDirPath || join(repoRoot, 'content');
|
|
118
|
+
const manifestPath = opts.manifestFilePath || join(repoRoot, 'site.manifest.json');
|
|
119
|
+
|
|
120
|
+
const container = readJsonIfPresent(join(contentDir, 'blog', containerFileFor(blogSlug)));
|
|
121
|
+
const itemTemplate = container?.itemTemplatePath || opts.blogItemTemplate || '';
|
|
122
|
+
const listingTemplate = container?.listingTemplatePath || opts.blogListingTemplate || '';
|
|
123
|
+
const blogTemplates =
|
|
124
|
+
!!container &&
|
|
125
|
+
String(container.slug ?? blogSlug) === String(blogSlug) &&
|
|
126
|
+
pointsAtTheme(itemTemplate, themeName) &&
|
|
127
|
+
pointsAtTheme(listingTemplate, themeName);
|
|
128
|
+
|
|
129
|
+
const manifest = readJsonIfPresent(manifestPath);
|
|
130
|
+
const rootEntry = (manifest?.pages || []).find((p) => String(p?.slug ?? '') === '');
|
|
131
|
+
const home = readJsonIfPresent(join(contentDir, 'pages', 'home.json'));
|
|
132
|
+
const manifestPublishesRoot = !!rootEntry && (rootEntry.desiredState || 'publish') === 'publish';
|
|
133
|
+
const homePublishesRoot =
|
|
134
|
+
!!home &&
|
|
135
|
+
String(home.slug ?? '') === '' &&
|
|
136
|
+
(home.desiredState || rootEntry?.desiredState || 'publish') === 'publish' &&
|
|
137
|
+
!!home.templatePath;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
blogTemplates,
|
|
141
|
+
homepage: manifestPublishesRoot && homePublishesRoot,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
89
145
|
// ---------------------------------------------------------------------------
|
|
90
146
|
// gatherProbes(acct, { blogSlug, hub, getAll, resolveBlogBySlug, resolvePageBySlug })
|
|
91
147
|
// -> probes object consumed by evaluateReadiness.
|
|
@@ -181,6 +237,8 @@ export async function gatherProbes(acct, opts = {}) {
|
|
|
181
237
|
export function evaluateReadiness(probes, opts = {}) {
|
|
182
238
|
const blogSlug = opts.blogSlug ?? probes.blogSlug ?? DEFAULT_BLOG_SLUG;
|
|
183
239
|
const themeName = opts.themeName ?? THEME_NAME;
|
|
240
|
+
const allowRepairable = !!opts.allowRepairable;
|
|
241
|
+
const repairable = opts.repairable || {};
|
|
184
242
|
const checks = [];
|
|
185
243
|
|
|
186
244
|
const add = (c) => checks.push({ reportOnly: false, ...c });
|
|
@@ -212,11 +270,22 @@ export function evaluateReadiness(probes, opts = {}) {
|
|
|
212
270
|
// Container exists — confirm it points at the theme's blog templates.
|
|
213
271
|
const item = blog.itemTemplatePath || '';
|
|
214
272
|
const listing = blog.listingTemplatePath || '';
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const listingOk = pointsAtTheme(listing);
|
|
273
|
+
const itemOk = pointsAtTheme(item, themeName);
|
|
274
|
+
const listingOk = pointsAtTheme(listing, themeName);
|
|
218
275
|
if (itemOk && listingOk) {
|
|
219
276
|
add({ id: 'blog-container', ok: true, detail: `blog "${blogSlug}" exists and uses ${themeName} templates` });
|
|
277
|
+
} else if (allowRepairable && repairable.blogTemplates) {
|
|
278
|
+
const wrong = [];
|
|
279
|
+
if (!itemOk) wrong.push(`post/item template "${item || '(unset)'}"`);
|
|
280
|
+
if (!listingOk) wrong.push(`listing template "${listing || '(unset)'}"`);
|
|
281
|
+
add({
|
|
282
|
+
id: 'blog-templates',
|
|
283
|
+
ok: true,
|
|
284
|
+
repairable: true,
|
|
285
|
+
detail:
|
|
286
|
+
`blog "${blogSlug}" exists but ${wrong.join(' and ')} not under ${themeName}; ` +
|
|
287
|
+
`source push will set the committed blog templates`,
|
|
288
|
+
});
|
|
220
289
|
} else {
|
|
221
290
|
const wrong = [];
|
|
222
291
|
if (!itemOk) wrong.push(`post/item template "${item || '(unset)'}"`);
|
|
@@ -241,6 +310,13 @@ export function evaluateReadiness(probes, opts = {}) {
|
|
|
241
310
|
detail: `cannot list site pages (HTTP ${homepage.status}${homepage.message ? `: ${homepage.message}` : ''})`,
|
|
242
311
|
remediation: `Grant the service key the "content" scope so the homepage can be confirmed.`,
|
|
243
312
|
});
|
|
313
|
+
} else if (!homepage.found && allowRepairable && repairable.homepage) {
|
|
314
|
+
add({
|
|
315
|
+
id: 'homepage',
|
|
316
|
+
ok: true,
|
|
317
|
+
repairable: true,
|
|
318
|
+
detail: `no site page resolves at the root slug ''; source push will create and publish the committed root page`,
|
|
319
|
+
});
|
|
244
320
|
} else if (!homepage.found) {
|
|
245
321
|
add({
|
|
246
322
|
id: 'homepage',
|
|
@@ -320,7 +396,7 @@ export function renderReport(evald, { account: acctName, portalId, blogSlug } =
|
|
|
320
396
|
const lines = [];
|
|
321
397
|
lines.push(`Bootstrap preflight — account "${acctName}" (portal ${portalId}), blog slug "${blogSlug}"`);
|
|
322
398
|
for (const c of evald.checks) {
|
|
323
|
-
const mark = c.ok ? 'PASS' : c.reportOnly ? 'NOTE' : 'FAIL';
|
|
399
|
+
const mark = c.repairable ? 'WARN' : c.ok ? 'PASS' : c.reportOnly ? 'NOTE' : 'FAIL';
|
|
324
400
|
lines.push(` [${mark}] ${c.id}: ${c.detail}`);
|
|
325
401
|
if (!c.ok && c.remediation) lines.push(` -> ${c.remediation}`);
|
|
326
402
|
}
|
|
@@ -338,9 +414,11 @@ export function renderReport(evald, { account: acctName, portalId, blogSlug } =
|
|
|
338
414
|
// ---------------------------------------------------------------------------
|
|
339
415
|
export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
340
416
|
const { config } = opts;
|
|
341
|
-
const
|
|
417
|
+
const allowRepairable = argv.includes('--allow-repairable');
|
|
418
|
+
const args = argv.filter((a) => a !== '--allow-repairable');
|
|
419
|
+
const acctName = args[0];
|
|
342
420
|
if (!acctName) {
|
|
343
|
-
process.stderr.write('usage: node sync/preflight.mjs <account
|
|
421
|
+
process.stderr.write('usage: node sync/preflight.mjs <account> [--allow-repairable]\n');
|
|
344
422
|
return 2;
|
|
345
423
|
}
|
|
346
424
|
|
|
@@ -371,7 +449,18 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
|
371
449
|
return 1;
|
|
372
450
|
}
|
|
373
451
|
|
|
374
|
-
const
|
|
452
|
+
const themeName = config?.theme?.name || THEME_NAME;
|
|
453
|
+
const repairable = allowRepairable
|
|
454
|
+
? sourceRepairability(config?.root || REPO_ROOT, {
|
|
455
|
+
blogSlug,
|
|
456
|
+
themeName,
|
|
457
|
+
contentDirPath: config?.contentDirPath,
|
|
458
|
+
manifestFilePath: config?.manifestFilePath,
|
|
459
|
+
blogItemTemplate: config?.blog?.itemTemplate,
|
|
460
|
+
blogListingTemplate: config?.blog?.listingTemplate,
|
|
461
|
+
})
|
|
462
|
+
: {};
|
|
463
|
+
const evald = evaluateReadiness(probes, { blogSlug, themeName, allowRepairable, repairable });
|
|
375
464
|
const report = renderReport(evald, { account: acctName, portalId: acct.portalId, blogSlug });
|
|
376
465
|
process.stdout.write(report + '\n');
|
|
377
466
|
return evald.ready ? 0 : 1;
|
|
@@ -382,4 +471,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
382
471
|
main().then((code) => process.exit(code));
|
|
383
472
|
}
|
|
384
473
|
|
|
385
|
-
export default { main, gatherProbes, evaluateReadiness, manifestBlogSlug, renderReport };
|
|
474
|
+
export default { main, gatherProbes, evaluateReadiness, manifestBlogSlug, sourceRepairability, renderReport };
|