hubspot-cms-sync 0.2.0 → 0.4.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 (34) hide show
  1. package/README.md +9 -3
  2. package/bin/hubspot-cms-sync.mjs +5 -2
  3. package/docs/CONTENT_LAYOUT.md +146 -0
  4. package/docs/GITHUB_ACTIONS.md +3 -1
  5. package/examples/minimal-site/content/assets/hero.svg +9 -0
  6. package/examples/minimal-site/content/blog/container.json +6 -0
  7. package/examples/minimal-site/content/blog/posts/blog__hello-world.json +13 -0
  8. package/examples/minimal-site/content/forms/contact.json +18 -0
  9. package/examples/minimal-site/content/forms/properties.json +7 -0
  10. package/examples/minimal-site/content/pages/about.json +9 -0
  11. package/examples/minimal-site/content/pages/home.json +9 -0
  12. package/examples/minimal-site/content/pages/home.widgets.json +17 -0
  13. package/examples/minimal-site/css/main.css +3 -0
  14. package/examples/minimal-site/fields.json +11 -0
  15. package/examples/minimal-site/hubspot-cms-sync.config.mjs +27 -0
  16. package/examples/minimal-site/js/hs-forms.js +1 -0
  17. package/examples/minimal-site/modules/hero.module/fields.json +14 -0
  18. package/examples/minimal-site/modules/hero.module/module.html +4 -0
  19. package/examples/minimal-site/site.manifest.json +29 -0
  20. package/examples/minimal-site/sync/accounts.json +10 -0
  21. package/examples/minimal-site/sync/redirects.csv +2 -0
  22. package/examples/minimal-site/templates/blog-post.html +8 -0
  23. package/examples/minimal-site/templates/blog.html +9 -0
  24. package/examples/minimal-site/templates/home.html +5 -0
  25. package/examples/minimal-site/templates/page.html +7 -0
  26. package/examples/minimal-site/theme.json +4 -0
  27. package/package.json +4 -2
  28. package/skill/references/commands.md +4 -0
  29. package/skill/references/config.md +6 -2
  30. package/src/adapters/blog.mjs +34 -8
  31. package/src/lib/content-view.mjs +168 -0
  32. package/src/lib/posts-format.mjs +90 -0
  33. package/src/lib/render.mjs +153 -0
  34. 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 --file content/redirects.csv
33
- hcms redirects dev --file content/redirects.csv --apply
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 a repo-stored CSV or JSON spec. Managed redirects
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
 
@@ -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
- .action(async (account) => {
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 code = await preflightMain([account], { config });
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.
@@ -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. `the consuming repo verification commands`
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,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,6 +1,6 @@
1
1
  {
2
2
  "name": "hubspot-cms-sync",
3
- "version": "0.2.0",
3
+ "version": "0.4.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. 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.
@@ -45,12 +45,14 @@ import {
45
45
  mkdirSync,
46
46
  readdirSync,
47
47
  existsSync,
48
+ rmSync,
48
49
  } from 'node:fs';
49
50
  import { join, resolve as resolvePath, basename, extname } from 'node:path';
50
51
  import { createHash } from 'node:crypto';
51
52
 
52
53
  import { hub, getAll } from '../lib/hub.mjs';
53
54
  import { stableStringify } from '../lib/canonical.mjs';
55
+ import { wireToFile, fileToWire } from '../lib/posts-format.mjs';
54
56
  import { resolve as resolveRefs, canonicalize as canonicalizeRefs } from '../lib/refs.mjs';
55
57
  import { resolveCtaEmbeds, loadInventory } from '../cta-inventory.mjs';
56
58
 
@@ -317,10 +319,13 @@ export async function pull(acct, { contentDir, registry }) {
317
319
  // (codex #7). It is content here, not a volatile timestamp to strip.
318
320
  publishDate: p.publishDate || null,
319
321
  };
320
- writeFileSync(
321
- join(postsOut, `${postFileFor(p.slug)}.json`),
322
- stableStringify(portable),
323
- );
322
+ // Canonical post format is frontmatter + HTML body (.md). Reshaping is lossless
323
+ // to the wire object (lib/posts-format.mjs round-trip), so the push payload is
324
+ // byte-identical to the old .json path. Drop any stale sibling .json from the
325
+ // pre-frontmatter format so push never sees the same post twice.
326
+ const base = join(postsOut, postFileFor(p.slug));
327
+ writeFileSync(`${base}.md`, wireToFile(portable));
328
+ if (existsSync(`${base}.json`)) rmSync(`${base}.json`);
324
329
  pulled++;
325
330
  }
326
331
 
@@ -372,6 +377,10 @@ export async function push(
372
377
  registry,
373
378
  publish = false,
374
379
  limit,
380
+ // only: restrict the push to specific posts by file base name (no extension),
381
+ // e.g. ['blog__hello']. Enables a scoped sample push without touching the rest
382
+ // of the blog — used by verification harnesses; undefined means "all posts".
383
+ only,
375
384
  dryRun = false,
376
385
  hubFn = hub,
377
386
  // Injectable clock + sleep so the "wait past every scheduled publish" gate
@@ -393,12 +402,29 @@ export async function push(
393
402
  // then replaces @asset tokens below. (The old blog-local rehostAssets path is
394
403
  // retired: one upload location, no /blog-migrated vs /synced-assets split.)
395
404
 
396
- let files = readdirSync(postsDir).filter((f) => f.endsWith('.json'));
397
- files.sort();
405
+ // Accept the canonical frontmatter format (.md) and the legacy .json. If both
406
+ // exist for one post, .md wins; dedup by base name so a post is never pushed
407
+ // twice during the transition.
408
+ const byBase = new Map();
409
+ for (const f of readdirSync(postsDir)) {
410
+ const m = /^(.*)\.(md|json)$/.exec(f);
411
+ if (!m) continue;
412
+ const [, base, ext] = m;
413
+ if (ext === 'md' || !byBase.has(base)) byBase.set(base, f);
414
+ }
415
+ let files = [...byBase.values()].sort();
416
+ if (only) {
417
+ const want = new Set(only);
418
+ files = files.filter((f) => want.has(f.replace(/\.(md|json)$/, '')));
419
+ }
398
420
  if (limit) files = files.slice(0, limit);
399
421
 
400
- // Group posts by their blogSlug and resolve each container exactly once.
401
- const posts = files.map((f) => JSON.parse(readFileSync(join(postsDir, f), 'utf8')));
422
+ // Group posts by their blogSlug and resolve each container exactly once. The
423
+ // frontmatter codec yields the same wire object JSON.parse would have.
424
+ const posts = files.map((f) => {
425
+ const raw = readFileSync(join(postsDir, f), 'utf8');
426
+ return f.endsWith('.md') ? fileToWire(raw) : JSON.parse(raw);
427
+ });
402
428
  const containerCache = new Map();
403
429
  async function containerIdFor(blogSlug) {
404
430
  if (containerCache.has(blogSlug)) return containerCache.get(blogSlug);
@@ -0,0 +1,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 pointsAtTheme = (p) => p.includes(`${themeName}/`) || p.includes(`${themeName}\\`);
216
- const itemOk = pointsAtTheme(item);
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 acctName = argv[0];
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>\n');
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 evald = evaluateReadiness(probes, { blogSlug, themeName: config?.theme?.name || THEME_NAME });
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 };