canopycms-next 0.0.66-int.81 → 0.0.66-int.82

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/dist/config.cjs CHANGED
@@ -77,6 +77,29 @@ function resolveReactAliases(resolve) {
77
77
  return null;
78
78
  }
79
79
  }
80
+ var SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
81
+ var BUILD_ID_RULE = 'must be 1-255 characters of [A-Za-z0-9._-] and not "." or ".."';
82
+ function isUsableBuildId(value) {
83
+ return SAFE_BUILD_ID.test(value) && value !== "." && value !== "..";
84
+ }
85
+ function resolveStaticBuildId() {
86
+ const raw = process.env.CANOPY_BUILD_ID;
87
+ const trimmed = raw?.trim();
88
+ if (raw === void 0) return null;
89
+ if (!trimmed) {
90
+ console.warn(
91
+ "CanopyCMS: CANOPY_BUILD_ID is set but blank \u2014 using Next's random build id instead. This export is NOT reproducible; two builds of one source tree will differ."
92
+ );
93
+ return null;
94
+ }
95
+ if (!isUsableBuildId(trimmed)) {
96
+ console.warn(
97
+ `CanopyCMS: ignoring CANOPY_BUILD_ID="${trimmed}" \u2014 a build id becomes a single path segment under _next/static/, so it ${BUILD_ID_RULE}. Using Next's random build id instead; this export is NOT reproducible.`
98
+ );
99
+ return null;
100
+ }
101
+ return trimmed;
102
+ }
80
103
  function withCanopy(nextConfig = {}, options = {}) {
81
104
  const resolve = (0, import_node_module.createRequire)(import_node_path.default.join(process.cwd(), "noop.js")).resolve;
82
105
  const installedOptional = OPTIONAL_PACKAGES.filter((pkg) => {
@@ -119,12 +142,16 @@ function withCanopy(nextConfig = {}, options = {}) {
119
142
  ...options.staticBuild ? STATIC_PAGE_EXTENSIONS : CMS_PAGE_EXTENSIONS
120
143
  ])
121
144
  ];
145
+ const generateBuildId = nextConfig.generateBuildId ?? (options.staticBuild ? resolveStaticBuildId : void 0);
122
146
  return {
123
147
  ...nextConfig,
124
148
  transpilePackages: allPackages,
125
149
  pageExtensions,
126
150
  webpack,
127
- rewrites: withAssetsRewrite(nextConfig.rewrites)
151
+ rewrites: withAssetsRewrite(nextConfig.rewrites),
152
+ // Spread conditionally: emitting `generateBuildId: undefined` would be a key Next has to
153
+ // reason about, where absence is unambiguous.
154
+ ...generateBuildId ? { generateBuildId } : {}
128
155
  };
129
156
  }
130
157
  // Annotate the CommonJS export names for ESM import in node:
package/dist/config.mjs CHANGED
@@ -41,6 +41,29 @@ function resolveReactAliases(resolve) {
41
41
  return null;
42
42
  }
43
43
  }
44
+ var SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
45
+ var BUILD_ID_RULE = 'must be 1-255 characters of [A-Za-z0-9._-] and not "." or ".."';
46
+ function isUsableBuildId(value) {
47
+ return SAFE_BUILD_ID.test(value) && value !== "." && value !== "..";
48
+ }
49
+ function resolveStaticBuildId() {
50
+ const raw = process.env.CANOPY_BUILD_ID;
51
+ const trimmed = raw?.trim();
52
+ if (raw === void 0) return null;
53
+ if (!trimmed) {
54
+ console.warn(
55
+ "CanopyCMS: CANOPY_BUILD_ID is set but blank \u2014 using Next's random build id instead. This export is NOT reproducible; two builds of one source tree will differ."
56
+ );
57
+ return null;
58
+ }
59
+ if (!isUsableBuildId(trimmed)) {
60
+ console.warn(
61
+ `CanopyCMS: ignoring CANOPY_BUILD_ID="${trimmed}" \u2014 a build id becomes a single path segment under _next/static/, so it ${BUILD_ID_RULE}. Using Next's random build id instead; this export is NOT reproducible.`
62
+ );
63
+ return null;
64
+ }
65
+ return trimmed;
66
+ }
44
67
  function withCanopy(nextConfig = {}, options = {}) {
45
68
  const resolve = createRequire(path.join(process.cwd(), "noop.js")).resolve;
46
69
  const installedOptional = OPTIONAL_PACKAGES.filter((pkg) => {
@@ -83,12 +106,16 @@ function withCanopy(nextConfig = {}, options = {}) {
83
106
  ...options.staticBuild ? STATIC_PAGE_EXTENSIONS : CMS_PAGE_EXTENSIONS
84
107
  ])
85
108
  ];
109
+ const generateBuildId = nextConfig.generateBuildId ?? (options.staticBuild ? resolveStaticBuildId : void 0);
86
110
  return {
87
111
  ...nextConfig,
88
112
  transpilePackages: allPackages,
89
113
  pageExtensions,
90
114
  webpack,
91
- rewrites: withAssetsRewrite(nextConfig.rewrites)
115
+ rewrites: withAssetsRewrite(nextConfig.rewrites),
116
+ // Spread conditionally: emitting `generateBuildId: undefined` would be a key Next has to
117
+ // reason about, where absence is unambiguous.
118
+ ...generateBuildId ? { generateBuildId } : {}
92
119
  };
93
120
  }
94
121
  export {
@@ -11,7 +11,8 @@ export interface WithCanopyOptions {
11
11
  *
12
12
  * When `true`: adds `static.ts` and `static.tsx` to `pageExtensions` instead, so
13
13
  * Next.js processes the static-export-only variant of a dual-build content route
14
- * (e.g. `page.static.tsx`) while ignoring `.server.*` CMS-only files.
14
+ * (e.g. `page.static.tsx`) while ignoring `.server.*` CMS-only files. It also lets
15
+ * `CANOPY_BUILD_ID` pin Next's build id, so a content-addressed artifact is reproducible.
15
16
  *
16
17
  * @example
17
18
  * ```ts
@@ -35,6 +36,9 @@ export interface WithCanopyOptions {
35
36
  * of the static-export-only page variants (e.g. `page.static.tsx`).
36
37
  * - Resolves React to a single copy from your project root, preventing
37
38
  * dual-instance crashes when using `file:` symlinks for local development
39
+ * - With `staticBuild: true`, honors `CANOPY_BUILD_ID` as Next's build id (Next's default is
40
+ * random, which puts two builds of one source tree in different `_next/static/` directories).
41
+ * Unset, or on a non-static build, Next's default is used unchanged.
38
42
  *
39
43
  * **When you need this:**
40
44
  * - Always recommended — it replaces manual `transpilePackages` configuration
@@ -110,6 +110,56 @@ function resolveReactAliases(resolve) {
110
110
  return null;
111
111
  }
112
112
  }
113
+ /**
114
+ * A build id Next can safely use as a single path segment.
115
+ *
116
+ * Next applies NO validation: `generateBuildId`'s return is trimmed and interpolated straight
117
+ * into `out/_next/static/<id>/`. So `CANOPY_BUILD_ID=$(git describe --all)` yields `heads/main`
118
+ * and silently nests that directory one level deeper than every emitted URL expects, and a value
119
+ * containing `..` climbs out of it. Both are the adopter's own pipeline misfiring rather than an
120
+ * injection vector, which is exactly why a clear message beats a broken deploy.
121
+ */
122
+ const SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
123
+ /** The message every rejection uses, so the stated rule and the enforced rule cannot drift. */
124
+ const BUILD_ID_RULE = 'must be 1-255 characters of [A-Za-z0-9._-] and not "." or ".."';
125
+ function isUsableBuildId(value) {
126
+ // `.` and `..` clear the character class but are not names — as a path segment they resolve to
127
+ // the static directory itself or its parent. `a..b` is an ordinary filename and stays allowed.
128
+ // The 255 bound is the same rule: a longer segment fails at `mkdir` with ENAMETOOLONG.
129
+ return SAFE_BUILD_ID.test(value) && value !== '.' && value !== '..';
130
+ }
131
+ /**
132
+ * Resolve Next's build id from `CANOPY_BUILD_ID`, or `null` to keep Next's random default.
133
+ *
134
+ * Both rejections warn rather than passing the value through or throwing. Unset means "I did not
135
+ * ask for a reproducible build" and says nothing; set-but-unusable almost always means a pipeline
136
+ * computed the id and the command failed (`CANOPY_BUILD_ID=$(git rev-parse ...)`), so the adopter
137
+ * believes they pinned it and would otherwise get a random — or structurally broken — artifact
138
+ * with nothing said. Falling back to Next's default keeps the build working; it is only the
139
+ * reproducibility that is lost, and the warning is what makes that recoverable.
140
+ *
141
+ * Declared above `withCanopy` deliberately: sitting between that function and its JSDoc block
142
+ * orphans the block onto this one, and the shipped `dist/config.d.ts` loses every line of
143
+ * `withCanopy`'s adopter-facing documentation.
144
+ */
145
+ function resolveStaticBuildId() {
146
+ const raw = process.env.CANOPY_BUILD_ID;
147
+ const trimmed = raw?.trim();
148
+ if (raw === undefined)
149
+ return null;
150
+ if (!trimmed) {
151
+ console.warn("CanopyCMS: CANOPY_BUILD_ID is set but blank — using Next's random build id instead. " +
152
+ 'This export is NOT reproducible; two builds of one source tree will differ.');
153
+ return null;
154
+ }
155
+ if (!isUsableBuildId(trimmed)) {
156
+ console.warn(`CanopyCMS: ignoring CANOPY_BUILD_ID="${trimmed}" — a build id becomes a single path ` +
157
+ `segment under _next/static/, so it ${BUILD_ID_RULE}. Using Next's random ` +
158
+ 'build id instead; this export is NOT reproducible.');
159
+ return null;
160
+ }
161
+ return trimmed;
162
+ }
113
163
  /**
114
164
  * Wrap your Next.js config to set up module transpilation and React
115
165
  * resolution for CanopyCMS packages.
@@ -124,6 +174,9 @@ function resolveReactAliases(resolve) {
124
174
  * of the static-export-only page variants (e.g. `page.static.tsx`).
125
175
  * - Resolves React to a single copy from your project root, preventing
126
176
  * dual-instance crashes when using `file:` symlinks for local development
177
+ * - With `staticBuild: true`, honors `CANOPY_BUILD_ID` as Next's build id (Next's default is
178
+ * random, which puts two builds of one source tree in different `_next/static/` directories).
179
+ * Unset, or on a non-static build, Next's default is used unchanged.
127
180
  *
128
181
  * **When you need this:**
129
182
  * - Always recommended — it replaces manual `transpilePackages` configuration
@@ -209,11 +262,31 @@ export function withCanopy(nextConfig = {}, options = {}) {
209
262
  ...(options.staticBuild ? STATIC_PAGE_EXTENSIONS : CMS_PAGE_EXTENSIONS),
210
263
  ]),
211
264
  ];
265
+ // Static exports are routinely content-addressed (an S3/CloudFront artifact keyed on a tree
266
+ // hash), and Next defaults `generateBuildId` to `nanoid()` — so two builds of one source tree
267
+ // land under different `out/_next/static/<id>/` directories and the id names two different file
268
+ // sets. An explicit `generateBuildId` in the host config always wins.
269
+ //
270
+ // Deliberately gated on `staticBuild`. Under the dual-build convention the two flavors have
271
+ // different `pageExtensions` and therefore different chunk sets; pinning both from one env var
272
+ // would give two different file sets the SAME `_next/static/<id>/` path, which nothing can route
273
+ // between if they ever share an origin. The CMS build keeps nanoid so the ids stay distinct.
274
+ //
275
+ // `resolveStaticBuildId` (above) holds the rules for reading the variable and why each exists.
276
+ // One rule lives here instead, because it constrains this line rather than that function:
277
+ // returning the resolver's string directly matters. Next re-rolls ids containing `ad`
278
+ // (ad-blocker false positives) only on the `null` fallback path — a returned string is used
279
+ // verbatim (`next/dist/build/generate-build-id.js`) — which is what makes a hex tree hash usable
280
+ // as a build id at all. Do not "helpfully" route this through the fallback.
281
+ const generateBuildId = nextConfig.generateBuildId ?? (options.staticBuild ? resolveStaticBuildId : undefined);
212
282
  return {
213
283
  ...nextConfig,
214
284
  transpilePackages: allPackages,
215
285
  pageExtensions,
216
286
  webpack,
217
287
  rewrites: withAssetsRewrite(nextConfig.rewrites),
288
+ // Spread conditionally: emitting `generateBuildId: undefined` would be a key Next has to
289
+ // reason about, where absence is unambiguous.
290
+ ...(generateBuildId ? { generateBuildId } : {}),
218
291
  };
219
292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-next",
3
- "version": "0.0.66-int.81",
3
+ "version": "0.0.66-int.82",
4
4
  "description": "Next.js adapter for CanopyCMS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "peerDependencies": {
37
37
  "next": "^13.5.7 || ^14.2.25 || ^15.5.21 || >=16.0.0 <16.2.0 || >=16.3.0 <17.0.0",
38
38
  "react": "^18.0.0 || ^19.0.0",
39
- "canopycms": "0.0.66-int.81"
39
+ "canopycms": "0.0.66-int.82"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^22.9.0",
@@ -46,7 +46,7 @@
46
46
  "react": "^18.3.1",
47
47
  "typescript": "^5.6.3",
48
48
  "vitest": "^4.1.0",
49
- "canopycms": "0.0.66-int.81"
49
+ "canopycms": "0.0.66-int.82"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/add-js-extensions.mjs dist && esbuild src/config.ts --bundle --platform=node --packages=external --external:next --format=cjs --outfile=dist/config.cjs && esbuild src/config.ts --bundle --platform=node --packages=external --external:next --format=esm --outfile=dist/config.mjs",