@woodsportal/hubspot-kit 1.0.31 → 1.0.34

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 (57) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli.js +798 -281
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/index.js.map +1 -1
  6. package/examples/fixtures/adopted-module-hybrid/woodscli.json +1 -1
  7. package/examples/fixtures/adopted-module-monolith/woodscli.json +1 -1
  8. package/examples/greenfield/module-hybrid-cdn/public/fields.json +26 -20
  9. package/examples/greenfield/module-hybrid-cdn/public/meta.json +9 -0
  10. package/examples/greenfield/module-hybrid-cdn/public/module.html +3 -4
  11. package/examples/greenfield/module-hybrid-cdn/src/assets/css/main.prod.css +6 -0
  12. package/examples/greenfield/module-hybrid-cdn/src/cdn/app-entry.ts +5 -0
  13. package/examples/greenfield/module-hybrid-cdn/src/hubspot/bootstrap.ts +6 -0
  14. package/examples/greenfield/module-hybrid-cdn/src/portal/ensure-portal-cdn-stylesheet.ts +58 -0
  15. package/examples/greenfield/module-hybrid-cdn/woodscli.json +1 -1
  16. package/examples/greenfield/module-monolith/woodscli.json +1 -1
  17. package/package.json +2 -2
  18. package/scripts/build-cdn.mjs +6 -1
  19. package/scripts/bump-module-build-id.mjs +28 -0
  20. package/scripts/hubspot-module-postbuild.mjs +35 -1
  21. package/scripts/load-portal-cdn-config.mjs +21 -3
  22. package/scripts/portal-build-id.mjs +346 -0
  23. package/scripts/portal-build-id.test.mjs +132 -0
  24. package/scripts/publish-cdn-github.mjs +60 -15
  25. package/scripts/resolve-cdn-base-url.mjs +2 -1
  26. package/scripts/resolve-module-build-id-meta.mjs +7 -0
  27. package/scripts/resolve-portal-build-id.mjs +16 -56
  28. package/scripts/tailwind/prefix-plugin.js +46 -8
  29. package/scripts/tailwind/tailwind-content-plugin.js +0 -1
  30. package/scripts/vite/portal-build-shared.js +3 -1
  31. package/src/dev-module-config/__tests__/flatten-visible-fields.test.ts +107 -0
  32. package/src/dev-module-config/runtime/module-config-bridge.ts +10 -2
  33. package/src/dev-module-config/schema/types.ts +10 -0
  34. package/src/dev-module-config/schema/visibility.ts +42 -4
  35. package/src/dev-module-config/ui/ModuleConfigShell.tsx +10 -9
  36. package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +3 -3
  37. package/templates/module-hybrid-cdn/public/fields.json +52 -41
  38. package/templates/module-hybrid-cdn/public/meta.json +9 -0
  39. package/templates/module-hybrid-cdn/public/module.html +18 -4
  40. package/templates/module-hybrid-cdn/src/assets/css/main.dev.css +8 -0
  41. package/templates/module-hybrid-cdn/src/assets/css/main.prod.css +12 -0
  42. package/templates/module-hybrid-cdn/src/assets/css/tailwind.css.config.css +7 -0
  43. package/templates/module-hybrid-cdn/src/cdn/app-entry.ts +14 -3
  44. package/templates/module-hybrid-cdn/src/features/starter/components/starter-home.tsx +9 -11
  45. package/templates/module-hybrid-cdn/src/features/starter/hooks/starter-preview-values.ts +23 -5
  46. package/templates/module-hybrid-cdn/src/features/starter/hooks/use-starter-preview.dev.ts +1 -0
  47. package/templates/module-hybrid-cdn/src/hubspot/bootstrap.ts +6 -0
  48. package/templates/module-hybrid-cdn/src/portal/ensure-portal-cdn-stylesheet.ts +115 -0
  49. package/templates/module-hybrid-cdn/src/portal/mount-portal.tsx +35 -5
  50. package/templates/module-hybrid-cdn/woodscli.json +1 -1
  51. package/templates/module-monolith/public/fields.json +23 -16
  52. package/templates/module-monolith/public/meta.json +9 -0
  53. package/templates/module-monolith/src/features/starter/components/starter-home.tsx +9 -11
  54. package/templates/module-monolith/src/features/starter/hooks/starter-preview-values.ts +23 -5
  55. package/templates/module-monolith/src/features/starter/hooks/use-starter-preview.dev.ts +1 -0
  56. package/templates/module-monolith/woodscli.json +1 -1
  57. package/vite/vite.config.dev.mjs +16 -10
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Module build ID: per-developer CDN pins, auto-increment, and layered resolution.
3
+ */
4
+ import { spawnSync } from 'node:child_process'
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+
8
+ import { resolveProjectConfigPath } from './config-files.mjs'
9
+ import { findProjectRoot } from './project-root.mjs'
10
+ import { wphsPaths } from './wphs-paths.mjs'
11
+
12
+ export const LEGACY_BUILD_ID_FILE = '.portal-build-id'
13
+ export const DEFAULT_BUILD_ID_FILE = '.module-build-id'
14
+ export const MODULE_BUILD_ID_STATE_FILE = 'module-build-id.json'
15
+ export const DEVELOPER_CONFIG_FILE = 'developer.json'
16
+
17
+ const SLUG_MAX_LEN = 12
18
+ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,10}[a-z0-9])?$/
19
+
20
+ /**
21
+ * @param {string} input
22
+ */
23
+ export function sanitizeDevSlug(input) {
24
+ if (!input?.trim()) return undefined
25
+ const slug = input
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-+|-+$/g, '')
30
+ .slice(0, SLUG_MAX_LEN)
31
+ if (!slug || !SLUG_PATTERN.test(slug)) return undefined
32
+ return slug
33
+ }
34
+
35
+ /**
36
+ * @param {string} baseline
37
+ * @param {string} slug
38
+ * @param {number} seq
39
+ */
40
+ export function formatBuildId(baseline, slug, seq) {
41
+ const base = baseline?.trim()
42
+ const devSlug = sanitizeDevSlug(slug)
43
+ const n = Number(seq)
44
+ if (!base) {
45
+ throw new Error('module build id baseline is required')
46
+ }
47
+ if (!devSlug) {
48
+ throw new Error('module build id dev slug is required')
49
+ }
50
+ if (!Number.isInteger(n) || n < 1) {
51
+ throw new Error(`module build id seq must be a positive integer (got ${seq})`)
52
+ }
53
+ const id = `${base}.${devSlug}.${n}`
54
+ if (!isGitTagSafe(id)) {
55
+ throw new Error(`module build id is not git-tag safe: ${id}`)
56
+ }
57
+ return id
58
+ }
59
+
60
+ /**
61
+ * @param {string} value
62
+ */
63
+ export function isGitTagSafe(value) {
64
+ if (!value?.trim()) return false
65
+ if (value.includes(' ') || value.includes('..')) return false
66
+ return /^[A-Za-z0-9._-]+$/.test(value)
67
+ }
68
+
69
+ /**
70
+ * Strip legacy numeric or per-dev suffixes to recover channel baseline.
71
+ * @param {string} raw
72
+ */
73
+ export function stripToBaseline(raw) {
74
+ const line = raw?.trim()
75
+ if (!line) return undefined
76
+
77
+ const perDev = line.match(/^(v\d+\.\d+\.\d+(?:-[a-z0-9]+)?)\.[a-z0-9-]{1,12}\.\d+$/i)
78
+ if (perDev) return perDev[1]
79
+
80
+ const numericSuffix = line.match(/^(v\d+\.\d+\.\d+(?:-[a-z0-9]+)?)\.\d+$/)
81
+ if (numericSuffix) return numericSuffix[1]
82
+
83
+ return line
84
+ }
85
+
86
+ /**
87
+ * @param {string} projectRoot
88
+ */
89
+ export function getBuildIdFilePath(projectRoot) {
90
+ const configPath = resolveProjectConfigPath(projectRoot)
91
+ if (configPath) {
92
+ try {
93
+ const config = JSON.parse(readFileSync(configPath, 'utf8'))
94
+ if (config.cdn?.buildIdFile) {
95
+ return resolve(projectRoot, config.cdn.buildIdFile)
96
+ }
97
+ } catch {
98
+ // ignore invalid config
99
+ }
100
+ }
101
+ return resolve(projectRoot, DEFAULT_BUILD_ID_FILE)
102
+ }
103
+
104
+ /**
105
+ * @param {string} projectRoot
106
+ */
107
+ export function getLegacyBuildIdFilePath(projectRoot) {
108
+ return resolve(projectRoot, LEGACY_BUILD_ID_FILE)
109
+ }
110
+
111
+ /**
112
+ * @param {string} filePath
113
+ */
114
+ function readFirstLine(filePath) {
115
+ if (!existsSync(filePath)) return undefined
116
+ const line = readFileSync(filePath, 'utf8')
117
+ .split('\n')
118
+ .map((l) => l.trim())
119
+ .find((l) => l.length > 0 && !l.startsWith('#'))
120
+ return line || undefined
121
+ }
122
+
123
+ /**
124
+ * @param {string} projectRoot
125
+ */
126
+ export function readBaselineBuildId(projectRoot) {
127
+ const primary = getBuildIdFilePath(projectRoot)
128
+ const fromPrimary = readFirstLine(primary)
129
+ if (fromPrimary) return stripToBaseline(fromPrimary)
130
+
131
+ const legacy = getLegacyBuildIdFilePath(projectRoot)
132
+ const fromLegacy = readFirstLine(legacy)
133
+ if (fromLegacy) return stripToBaseline(fromLegacy)
134
+
135
+ return undefined
136
+ }
137
+
138
+ /**
139
+ * @param {string} projectRoot
140
+ */
141
+ export function moduleBuildIdStatePath(projectRoot) {
142
+ return resolve(wphsPaths(projectRoot).stateDir, MODULE_BUILD_ID_STATE_FILE)
143
+ }
144
+
145
+ /**
146
+ * @param {string} projectRoot
147
+ */
148
+ export function developerConfigPath(projectRoot) {
149
+ return resolve(wphsPaths(projectRoot).configDir, DEVELOPER_CONFIG_FILE)
150
+ }
151
+
152
+ /**
153
+ * @typedef {{ slug?: string, seq?: number, baseline?: string, tier?: string, updatedAt?: string }} ModuleBuildIdState
154
+ */
155
+
156
+ /**
157
+ * @param {string} projectRoot
158
+ * @returns {ModuleBuildIdState | undefined}
159
+ */
160
+ export function readModuleBuildIdState(projectRoot) {
161
+ const path = moduleBuildIdStatePath(projectRoot)
162
+ if (!existsSync(path)) return undefined
163
+ try {
164
+ return JSON.parse(readFileSync(path, 'utf8'))
165
+ } catch {
166
+ return undefined
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {string} projectRoot
172
+ * @param {ModuleBuildIdState} state
173
+ * @param {boolean} [dryRun]
174
+ */
175
+ export function writeModuleBuildIdState(projectRoot, state, dryRun = false) {
176
+ const path = moduleBuildIdStatePath(projectRoot)
177
+ if (!dryRun) {
178
+ mkdirSync(resolve(path, '..'), { recursive: true })
179
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`)
180
+ }
181
+ return path
182
+ }
183
+
184
+ /**
185
+ * @param {NodeJS.ProcessEnv} [env]
186
+ */
187
+ export function readEnvBuildIdOverride(env = process.env) {
188
+ const explicit = env.PORTAL_BUILD_ID?.trim() || env.MODULE_BUILD_ID?.trim()
189
+ if (explicit) {
190
+ return { id: explicit, source: 'env' }
191
+ }
192
+ const ref = env.GITHUB_REF_NAME?.trim()
193
+ if (ref) {
194
+ return { id: ref, source: 'github-ref' }
195
+ }
196
+ return undefined
197
+ }
198
+
199
+ /**
200
+ * @param {NodeJS.ProcessEnv} [env]
201
+ */
202
+ export function hasExplicitBuildIdOverride(env = process.env) {
203
+ return Boolean(readEnvBuildIdOverride(env))
204
+ }
205
+
206
+ /**
207
+ * @param {NodeJS.ProcessEnv} [env]
208
+ * @param {string} [projectRoot]
209
+ */
210
+ export function resolveDevSlug(env = process.env, projectRoot = findProjectRoot()) {
211
+ const fromEnv = sanitizeDevSlug(env.PORTAL_CDN_DEV_SLUG) ?? sanitizeDevSlug(env.MODULE_CDN_DEV_SLUG)
212
+ if (fromEnv) return fromEnv
213
+
214
+ const devConfigPath = developerConfigPath(projectRoot)
215
+ if (existsSync(devConfigPath)) {
216
+ try {
217
+ const raw = JSON.parse(readFileSync(devConfigPath, 'utf8'))
218
+ const fromFile = sanitizeDevSlug(raw?.slug)
219
+ if (fromFile) return fromFile
220
+ } catch {
221
+ // ignore
222
+ }
223
+ }
224
+
225
+ const gitName = spawnSync('git', ['config', 'user.name'], {
226
+ encoding: 'utf8',
227
+ cwd: projectRoot,
228
+ })
229
+ if (gitName.status === 0) {
230
+ const fromGit = sanitizeDevSlug(gitName.stdout)
231
+ if (fromGit) return fromGit
232
+ }
233
+
234
+ return undefined
235
+ }
236
+
237
+ /**
238
+ * @typedef {'env' | 'github-ref' | 'state' | 'baseline' | 'fallback'} BuildIdSource
239
+ */
240
+
241
+ /**
242
+ * @param {NodeJS.ProcessEnv} [env]
243
+ * @param {string} [projectRoot]
244
+ */
245
+ export function resolveModuleBuildIdMeta(env = process.env, projectRoot = findProjectRoot()) {
246
+ const override = readEnvBuildIdOverride(env)
247
+ if (override) {
248
+ return { id: override.id, source: override.source, slug: undefined, seq: undefined, baseline: undefined }
249
+ }
250
+
251
+ const baseline = readBaselineBuildId(projectRoot)
252
+ const state = readModuleBuildIdState(projectRoot)
253
+ const slug = sanitizeDevSlug(state?.slug) ?? resolveDevSlug(env, projectRoot)
254
+
255
+ if (baseline && slug && state?.seq && Number.isInteger(state.seq) && state.seq >= 1) {
256
+ const stateBaseline = state.baseline ? stripToBaseline(state.baseline) : baseline
257
+ return {
258
+ id: formatBuildId(stateBaseline ?? baseline, slug, state.seq),
259
+ source: 'state',
260
+ slug,
261
+ seq: state.seq,
262
+ baseline: stateBaseline ?? baseline,
263
+ }
264
+ }
265
+
266
+ if (baseline) {
267
+ return { id: baseline, source: 'baseline', slug, seq: undefined, baseline }
268
+ }
269
+
270
+ return { id: 'dev', source: 'fallback', slug, seq: undefined, baseline: undefined }
271
+ }
272
+
273
+ /**
274
+ * @param {NodeJS.ProcessEnv} [env]
275
+ * @param {string} [projectRoot]
276
+ */
277
+ export function resolveModuleBuildId(env = process.env, projectRoot = findProjectRoot()) {
278
+ return resolveModuleBuildIdMeta(env, projectRoot).id
279
+ }
280
+
281
+ /**
282
+ * @param {{ projectRoot?: string, tier?: string, dryRun?: boolean, env?: NodeJS.ProcessEnv }} [options]
283
+ */
284
+ export function bumpModuleBuildId(options = {}) {
285
+ const projectRoot = options.projectRoot ?? findProjectRoot()
286
+ const env = options.env ?? process.env
287
+ const dryRun = options.dryRun ?? false
288
+
289
+ if (hasExplicitBuildIdOverride(env)) {
290
+ const override = readEnvBuildIdOverride(env)
291
+ return { id: override.id, bumped: false, source: override.source }
292
+ }
293
+
294
+ const baseline = readBaselineBuildId(projectRoot)
295
+ if (!baseline) {
296
+ throw new Error(
297
+ `${DEFAULT_BUILD_ID_FILE} missing — run wp setup or add a channel baseline (e.g. v0.0.1-dev)`,
298
+ )
299
+ }
300
+
301
+ const slug = resolveDevSlug(env, projectRoot)
302
+ if (!slug) {
303
+ throw new Error(
304
+ 'CDN dev slug not configured — set PORTAL_CDN_DEV_SLUG, run wp setup, or configure git user.name',
305
+ )
306
+ }
307
+
308
+ const state = readModuleBuildIdState(projectRoot) ?? {}
309
+ const stateBaseline = state.baseline ? stripToBaseline(state.baseline) : baseline
310
+ const nextSeq =
311
+ state.slug === slug && stateBaseline === baseline && Number.isInteger(state.seq)
312
+ ? state.seq + 1
313
+ : 1
314
+
315
+ const id = formatBuildId(baseline, slug, nextSeq)
316
+ const nextState = {
317
+ slug,
318
+ seq: nextSeq,
319
+ baseline,
320
+ tier: options.tier,
321
+ updatedAt: new Date().toISOString(),
322
+ }
323
+ writeModuleBuildIdState(projectRoot, nextState, dryRun)
324
+
325
+ return { id, bumped: true, source: 'state', slug, seq: nextSeq, baseline }
326
+ }
327
+
328
+ let cached
329
+
330
+ export function clearPortalBuildIdCache() {
331
+ cached = undefined
332
+ }
333
+
334
+ /** Cached build id for the current Node process (file + env read once). */
335
+ export function portalBuildId() {
336
+ if (cached === undefined) {
337
+ cached = resolveModuleBuildId()
338
+ }
339
+ return cached
340
+ }
341
+
342
+ /** Inject resolved id into env for child processes (vite, etc.). */
343
+ export function envWithPortalBuildId(baseEnv = process.env, projectRoot = findProjectRoot()) {
344
+ const buildId = resolveModuleBuildId(baseEnv, projectRoot)
345
+ return { ...baseEnv, PORTAL_BUILD_ID: buildId, MODULE_BUILD_ID: buildId }
346
+ }
@@ -0,0 +1,132 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, describe, it } from 'node:test'
6
+
7
+ import {
8
+ bumpModuleBuildId,
9
+ clearPortalBuildIdCache,
10
+ formatBuildId,
11
+ hasExplicitBuildIdOverride,
12
+ readBaselineBuildId,
13
+ resolveDevSlug,
14
+ resolveModuleBuildId,
15
+ resolveModuleBuildIdMeta,
16
+ sanitizeDevSlug,
17
+ stripToBaseline,
18
+ writeModuleBuildIdState,
19
+ } from './portal-build-id.mjs'
20
+ import { wphsPaths } from './wphs-paths.mjs'
21
+
22
+ const roots = []
23
+
24
+ function makeProject() {
25
+ const root = mkdtempSync(join(tmpdir(), 'wp-build-id-'))
26
+ roots.push(root)
27
+ mkdirSync(join(root, '.wphs', 'config'), { recursive: true })
28
+ mkdirSync(join(root, '.wphs', 'state'), { recursive: true })
29
+ writeFileSync(
30
+ join(root, 'woodscli.json'),
31
+ JSON.stringify({
32
+ configVersion: 1,
33
+ projectType: 'module-hybrid-cdn',
34
+ module: { name: 'test', uploadPath: 'x.module', outDir: 'dist' },
35
+ cdn: { mode: 'jsdelivr', buildIdFile: '.module-build-id', mirror: 'portal.cdn.json' },
36
+ }),
37
+ )
38
+ writeFileSync(join(root, '.module-build-id'), '# baseline\nv0.0.1-dev\n')
39
+ return root
40
+ }
41
+
42
+ afterEach(() => {
43
+ clearPortalBuildIdCache()
44
+ for (const root of roots.splice(0)) {
45
+ rmSync(root, { recursive: true, force: true })
46
+ }
47
+ })
48
+
49
+ describe('sanitizeDevSlug', () => {
50
+ it('normalizes names', () => {
51
+ assert.equal(sanitizeDevSlug('Manab Roy'), 'manab-roy')
52
+ assert.equal(sanitizeDevSlug(' JDoe '), 'jdoe')
53
+ })
54
+ })
55
+
56
+ describe('stripToBaseline', () => {
57
+ it('strips numeric legacy suffix', () => {
58
+ assert.equal(stripToBaseline('v0.0.1-dev.10'), 'v0.0.1-dev')
59
+ })
60
+ it('strips per-dev suffix', () => {
61
+ assert.equal(stripToBaseline('v0.0.1-dev.manab.3'), 'v0.0.1-dev')
62
+ })
63
+ })
64
+
65
+ describe('formatBuildId', () => {
66
+ it('formats slugged ids', () => {
67
+ assert.equal(formatBuildId('v0.0.1-dev', 'manab', 1), 'v0.0.1-dev.manab.1')
68
+ })
69
+ })
70
+
71
+ describe('resolveModuleBuildId', () => {
72
+ it('prefers env override', () => {
73
+ const root = makeProject()
74
+ const id = resolveModuleBuildId({ PORTAL_BUILD_ID: 'ci-abc' }, root)
75
+ assert.equal(id, 'ci-abc')
76
+ assert.equal(hasExplicitBuildIdOverride({ PORTAL_BUILD_ID: 'ci-abc' }), true)
77
+ })
78
+
79
+ it('uses state when present', () => {
80
+ const root = makeProject()
81
+ writeFileSync(join(root, '.wphs', 'config', 'developer.json'), JSON.stringify({ slug: 'manab' }))
82
+ writeModuleBuildIdState(root, { slug: 'manab', seq: 4, baseline: 'v0.0.1-dev' })
83
+ const meta = resolveModuleBuildIdMeta({}, root)
84
+ assert.equal(meta.id, 'v0.0.1-dev.manab.4')
85
+ assert.equal(meta.source, 'state')
86
+ })
87
+
88
+ it('falls back to baseline without state', () => {
89
+ const root = makeProject()
90
+ writeFileSync(join(root, '.wphs', 'config', 'developer.json'), JSON.stringify({ slug: 'manab' }))
91
+ assert.equal(readBaselineBuildId(root), 'v0.0.1-dev')
92
+ const meta = resolveModuleBuildIdMeta({}, root)
93
+ assert.equal(meta.id, 'v0.0.1-dev')
94
+ assert.equal(meta.source, 'baseline')
95
+ })
96
+ })
97
+
98
+ describe('bumpModuleBuildId', () => {
99
+ it('increments seq for same slug and baseline', () => {
100
+ const root = makeProject()
101
+ writeFileSync(join(root, '.wphs', 'config', 'developer.json'), JSON.stringify({ slug: 'manab' }))
102
+
103
+ const first = bumpModuleBuildId({ projectRoot: root, tier: 'dev' })
104
+ assert.equal(first.id, 'v0.0.1-dev.manab.1')
105
+ assert.equal(first.bumped, true)
106
+
107
+ const second = bumpModuleBuildId({ projectRoot: root, tier: 'dev' })
108
+ assert.equal(second.id, 'v0.0.1-dev.manab.2')
109
+
110
+ const statePath = join(wphsPaths(root).stateDir, 'module-build-id.json')
111
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
112
+ assert.equal(state.seq, 2)
113
+ })
114
+
115
+ it('respects env override without bumping', () => {
116
+ const root = makeProject()
117
+ const result = bumpModuleBuildId({
118
+ projectRoot: root,
119
+ env: { PORTAL_BUILD_ID: 'release-1' },
120
+ })
121
+ assert.equal(result.id, 'release-1')
122
+ assert.equal(result.bumped, false)
123
+ })
124
+ })
125
+
126
+ describe('resolveDevSlug', () => {
127
+ it('reads slug from developer.json', () => {
128
+ const root = makeProject()
129
+ writeFileSync(join(root, '.wphs', 'config', 'developer.json'), JSON.stringify({ slug: 'alice' }))
130
+ assert.equal(resolveDevSlug({}, root), 'alice')
131
+ })
132
+ })
@@ -32,13 +32,14 @@ import {
32
32
  isQuietBuild,
33
33
  portalSubprocessStdio,
34
34
  } from './portal-subprocess-stdio.mjs'
35
- import { resolvePortalBuildId } from './resolve-portal-build-id.mjs'
35
+ import { resolveModuleBuildIdMeta } from './portal-build-id.mjs'
36
36
 
37
37
  const root = findProjectRoot()
38
38
 
39
39
  const args = new Set(process.argv.slice(2))
40
40
  const dryRun = args.has('--dry-run')
41
41
  const skipJsDelivrCheck = args.has('--skip-jsdelivr-check')
42
+ const forceTag = args.has('--force-tag')
42
43
 
43
44
  function log(msg) {
44
45
  if (isQuietBuild()) {
@@ -106,7 +107,7 @@ function copyCdnArtifacts(sourceDir, targetDir) {
106
107
 
107
108
  function writeCdnReadme(repoRoot, buildId, config) {
108
109
  const readmePath = join(repoRoot, 'README.md')
109
- const base = jsDelivrBaseUrl(config.github, buildId, config.cdnPathInRepo)
110
+ const base = jsDelivrBaseUrl(config.github, config.cdnPathInRepo, config.defaultBranch)
110
111
  const body = `# woodsportal-client-frontend-cdn
111
112
 
112
113
  Public CDN assets for the WoodsPortal HubSpot module (**Strategy B — Hybrid CDN**).
@@ -116,10 +117,10 @@ Public CDN assets for the WoodsPortal HubSpot module (**Strategy B — Hybrid CD
116
117
  | | |
117
118
  |---|---|
118
119
  | Latest tag | \`${buildId}\` |
119
- | jsDelivr base | ${base} |
120
+ | jsDelivr base | ${base} (@${config.defaultBranch}) |
120
121
  | Source repo | [digitalwoods.io-woodsportal-client-frontend](https://github.com/Digital-Woods/digitalwoods.io-woodsportal-client-frontend) (private) |
121
122
 
122
- ## jsDelivr URLs (tag \`${buildId}\`)
123
+ ## jsDelivr URLs (build \`${buildId}\` on \`${config.defaultBranch}\`)
123
124
 
124
125
  - \`${base}/vendor.${buildId}.js\`
125
126
  - \`${base}/app.${buildId}.js\`
@@ -130,8 +131,29 @@ Published at ${new Date().toISOString()}.
130
131
  writeFileSync(readmePath, body, 'utf8')
131
132
  }
132
133
 
133
- async function checkJsDelivr(config, buildId, vendorFile, appFile, appCssFile) {
134
- const base = jsDelivrBaseUrl(config.github, buildId, config.cdnPathInRepo)
134
+ async function assertGithubRepoPublic(github) {
135
+ const res = await fetch(`https://api.github.com/repos/${github}`, {
136
+ headers: { Accept: 'application/vnd.github+json' },
137
+ })
138
+ if (res.status === 404) {
139
+ fail(
140
+ `GitHub repo ${github} not found or not visible — jsDelivr requires a public CDN mirror repo.`,
141
+ )
142
+ }
143
+ if (!res.ok) {
144
+ fail(`Could not verify GitHub repo ${github} (HTTP ${res.status}).`)
145
+ }
146
+ const data = await res.json()
147
+ if (data.private === true) {
148
+ fail(
149
+ `CDN mirror ${github} is private — jsDelivr only serves public GitHub repos. ` +
150
+ 'Make the repo public (Settings → Danger zone → Change visibility) or use a public mirror.',
151
+ )
152
+ }
153
+ }
154
+
155
+ async function checkJsDelivr(config, vendorFile, appFile, appCssFile) {
156
+ const base = jsDelivrBaseUrl(config.github, config.cdnPathInRepo, config.defaultBranch)
135
157
  for (const file of [appCssFile, vendorFile, appFile]) {
136
158
  const url = `${base}/${file}`
137
159
  log(`Checking jsDelivr (may take a minute on first publish): ${url}`)
@@ -149,7 +171,8 @@ async function checkJsDelivr(config, buildId, vendorFile, appFile, appCssFile) {
149
171
  }
150
172
 
151
173
  const config = loadPortalCdnConfig()
152
- const buildId = resolvePortalBuildId()
174
+ const buildMeta = resolveModuleBuildIdMeta()
175
+ const buildId = buildMeta.id
153
176
  const sourceCdn = resolve(root, 'dist/cdn')
154
177
  const token = process.env.PORTAL_CDN_PUBLISH_TOKEN ?? process.env.GITHUB_TOKEN
155
178
 
@@ -174,12 +197,12 @@ if (!appCssFile) {
174
197
 
175
198
  if (!vendorFile.includes(buildId) || !appFile.includes(buildId) || !appCssFile.includes(buildId)) {
176
199
  fail(
177
- `Build id mismatch: .portal-build-id is "${buildId}" but chunks are ${vendorFile}, ${appFile}, ${appCssFile}. ` +
178
- 'Update .portal-build-id and rebuild with `yarn build:cdn:prod` or `yarn build:cdn:dev`.',
200
+ `Build id mismatch: module build id is "${buildId}" but chunks are ${vendorFile}, ${appFile}, ${appCssFile}. ` +
201
+ 'Run `wp deploy` (auto-bumps id) or rebuild CDN after aligning `.module-build-id`.',
179
202
  )
180
203
  }
181
204
 
182
- const jsdelivrBase = jsDelivrBaseUrl(config.github, buildId, config.cdnPathInRepo)
205
+ const jsdelivrBase = jsDelivrBaseUrl(config.github, config.cdnPathInRepo, config.defaultBranch)
183
206
 
184
207
  const appSource = readFileSync(join(sourceCdn, appFile), 'utf8')
185
208
  const bakedApis = [
@@ -196,12 +219,20 @@ if (bakedApis.length === 1 && bakedApis[0] === 'https://api.woodsportal.com') {
196
219
 
197
220
  log('Configuration')
198
221
  log(` config file: ${config.configPath ?? '(defaults)'}`)
199
- log(` build id / tag: ${buildId}`)
222
+ log(` build id / tag: ${buildId} (source: ${buildMeta.source})`)
223
+ if (buildMeta.slug) {
224
+ log(` dev slug: ${buildMeta.slug}${buildMeta.seq ? ` · seq ${buildMeta.seq}` : ''}`)
225
+ }
226
+ if (config.usingLocalMirror) {
227
+ log(' CDN mirror: portal.cdn.local.json override')
228
+ }
200
229
  log(` GitHub (jsDelivr): ${config.github}`)
201
230
  log(` git remote: ${config.gitRemote}`)
202
231
  log(` target path: ${config.cdnPathInRepo}/`)
203
232
  log(` jsDelivr base: ${jsdelivrBase}`)
204
233
 
234
+ await assertGithubRepoPublic(config.github)
235
+
205
236
  if (dryRun) {
206
237
  log('Dry run — would copy:')
207
238
  for (const f of readdirSync(sourceCdn)) {
@@ -257,6 +288,14 @@ if (!status) {
257
288
  )
258
289
  }
259
290
 
291
+ const remoteTag = runCapture('git', ['ls-remote', 'origin', `refs/tags/${buildId}`], { cwd: workDir })
292
+ if (remoteTag && !forceTag) {
293
+ fail(
294
+ `Tag ${buildId} already exists on remote CDN repo — run wp deploy to auto-bump the module build id, ` +
295
+ 'or pass --force-tag only if you intend to overwrite that release.',
296
+ )
297
+ }
298
+
260
299
  const existingTag = spawnSync('git', ['rev-parse', buildId], {
261
300
  cwd: workDir,
262
301
  stdio: 'pipe',
@@ -264,13 +303,19 @@ const existingTag = spawnSync('git', ['rev-parse', buildId], {
264
303
  })
265
304
 
266
305
  if (existingTag.status === 0) {
267
- log(`Tag ${buildId} already exists locally — deleting and recreating on current commit.`)
306
+ if (!forceTag) {
307
+ fail(
308
+ `Tag ${buildId} already exists locally — run wp deploy to bump .module-build-id or pass --force-tag.`,
309
+ )
310
+ }
311
+ log(`Tag ${buildId} already exists locally — deleting and recreating on current commit (--force-tag).`)
268
312
  run('git', ['tag', '-d', buildId], { cwd: workDir })
269
313
  }
270
314
 
271
315
  run('git', ['tag', '-a', buildId, '-m', `WoodsPortal CDN ${buildId}`], { cwd: workDir })
272
316
  run('git', ['push', '-u', 'origin', branch], { cwd: workDir })
273
- run('git', ['push', 'origin', `refs/tags/${buildId}`, '--force'], { cwd: workDir })
317
+ const tagPushArgs = ['push', 'origin', `refs/tags/${buildId}`, ...(forceTag ? ['--force'] : [])]
318
+ run('git', tagPushArgs, { cwd: workDir })
274
319
 
275
320
  log('')
276
321
  log('Published successfully.')
@@ -280,12 +325,12 @@ log(` jsDelivr app: ${jsdelivrBase}/${appFile}`)
280
325
  log(` jsDelivr css: ${jsdelivrBase}/${appCssFile}`)
281
326
  log('')
282
327
  log('Next steps:')
283
- log(' 1. Wait ~5–15 minutes for jsDelivr to index the new tag (first publish can be slower).')
328
+ log(` 1. jsDelivr serves from @${config.defaultBranch} (versioned chunk filenames include ${buildId}).`)
284
329
  log(' 2. yarn build:hubspot:dev # dev API + jsDelivr URLs')
285
330
  log(' yarn build:hubspot:prod # prod API')
286
331
  log(' 3. yarn upload:hubspot')
287
332
  log(' Or: yarn deploy:dev | yarn deploy:prod')
288
333
 
289
334
  if (!skipJsDelivrCheck) {
290
- await checkJsDelivr(config, buildId, vendorFile, appFile, appCssFile)
335
+ await checkJsDelivr(config, vendorFile, appFile, appCssFile)
291
336
  }
@@ -28,5 +28,6 @@ export function resolveCdnBaseUrl(env = process.env) {
28
28
  }
29
29
 
30
30
  const cdnPath = config.cdnPathInRepo.replace(/^\//, '').replace(/\/$/, '')
31
- return `https://cdn.jsdelivr.net/gh/${config.github}@${buildId}/${cdnPath}`
31
+ const branch = config.defaultBranch.replace(/^@/, '').trim() || 'main'
32
+ return `https://cdn.jsdelivr.net/gh/${config.github}@${branch}/${cdnPath}`
32
33
  }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { clearPortalBuildIdCache, resolveModuleBuildIdMeta } from './portal-build-id.mjs'
3
+ import { findProjectRoot } from './project-root.mjs'
4
+
5
+ clearPortalBuildIdCache()
6
+ const meta = resolveModuleBuildIdMeta(process.env, findProjectRoot())
7
+ process.stdout.write(`${JSON.stringify(meta)}\n`)