@at-flux/astro-feature-flags 1.0.2 → 1.0.4

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.
@@ -1,20 +1,62 @@
1
1
  # Hide Flagged Routes From Sitemaps
2
2
 
3
- Use `getResolvedFeatures()` plus `featureRouteIncluded()` in your sitemap filter so routes disabled by feature flags are excluded.
3
+ A route disabled by a flag is deleted from the build. If the sitemap still lists it,
4
+ every crawler that reads the sitemap is handed a 404.
5
+
6
+ ## Automatic (the default)
7
+
8
+ Put `astroFeatureFlags()` **after** `sitemap()` in `integrations` and there is nothing
9
+ else to do. Astro runs `astro:build:done` in array order, so by the time the flags
10
+ integration prunes the routes the sitemap is already on disk, and it takes the dead
11
+ `<url>` entries out of it. A sitemap left with no URLs is deleted, along with its entry
12
+ in `sitemap-index.xml`.
4
13
 
5
14
  ```js
6
15
  // astro.config.mjs
7
16
 
8
17
  import sitemap from "@astrojs/sitemap";
18
+ import astroFeatureFlags from "@at-flux/astro-feature-flags";
19
+
20
+ export default defineConfig({
21
+ integrations: [
22
+ sitemap(),
23
+ // after sitemap(), so the sitemap exists when the prune runs
24
+ astroFeatureFlags({
25
+ flags: {
26
+ wip: { colour: "rgb(220 38 38)", routes: ["/blog/*"] },
27
+ },
28
+ environments: {
29
+ prod: {
30
+ when: process.env.NODE_ENV === "production",
31
+ flags: { wip: false },
32
+ },
33
+ },
34
+ }),
35
+ ],
36
+ });
37
+ ```
38
+
39
+ Get the order wrong and the build warns: the sitemap is written after the prune, so
40
+ nothing is found to prune. Pass `pruneSitemap: false` to opt out, and
41
+ `staticMinify: false` to turn off route pruning and the HTML cull with it.
42
+
43
+ Matching is by route prefix, the same decision that deletes the files, so `/blog/*`
44
+ takes out `/blog/post/` and leaves `/blogroll/` alone.
45
+
46
+ ## By hand
47
+
48
+ Filter in `@astrojs/sitemap` instead when the URL list needs to differ from what is
49
+ built — a route that ships but should not be advertised, say. `getResolvedFeatures()`
50
+ resolves the flags once and `featureRouteIncluded()` answers per URL:
51
+
52
+ ```js
9
53
  import astroFeatureFlags, {
10
54
  featureRouteIncluded,
11
55
  getResolvedFeatures,
12
56
  } from "@at-flux/astro-feature-flags";
13
57
 
14
58
  const featureFlagOptions = {
15
- flags: {
16
- wip: { colour: "rgb(220 38 38)", routes: ["/blog/*"] },
17
- },
59
+ flags: { wip: { routes: ["/blog/*"] } },
18
60
  environments: {
19
61
  prod: {
20
62
  when: process.env.NODE_ENV === "production",
@@ -27,23 +69,27 @@ const featureRuntime = getResolvedFeatures(featureFlagOptions);
27
69
 
28
70
  export default defineConfig({
29
71
  integrations: [
30
- astroFeatureFlags(featureFlagOptions),
31
72
  sitemap({
32
73
  filter: (page) => {
33
74
  try {
34
- const pathname = new URL(page).pathname;
35
- return featureRouteIncluded(pathname, featureRuntime);
75
+ return featureRouteIncluded(new URL(page).pathname, featureRuntime);
36
76
  } catch {
37
77
  return true;
38
78
  }
39
79
  },
40
80
  }),
81
+ astroFeatureFlags(featureFlagOptions),
41
82
  ],
42
83
  });
43
84
  ```
44
85
 
45
- `featureRuntime` only needs to be declared before the `sitemap` filter uses it.
86
+ `featureRuntime` only needs to be declared before the `sitemap` filter uses it. Both
87
+ mechanisms can run together: the filter never emits the URL, and the prune then finds
88
+ nothing to remove.
46
89
 
47
- You can still place `astroFeatureFlags(featureFlagOptions)` anywhere in `integrations`; the key is that `featureRouteIncluded(pathname, featureRuntime)` has access to the resolved runtime object.
90
+ If the sitemap runs under a `mode` whose active layer is not the one you want for URL
91
+ filtering, pass **`forceEnvironment`** (it wins over **`AFF_ENVIRONMENT`** when both are
92
+ set), for example
93
+ `getResolvedFeatures({ ...featureFlagOptions, mode: "development", forceEnvironment: "prod" })`.
48
94
 
49
- If the sitemap plugin runs with a `mode` where the active layer is not the one you want for URL filtering, pass **`forceEnvironment`** (it wins over **`AFF_ENVIRONMENT`** when both are set), for example `getResolvedFeatures({ ...featureFlagOptions, mode: "development", forceEnvironment: "prod" })`. Behaviour is covered by **`test/sitemap-route-filter.test.ts`** in this package.
95
+ Covered by **`test/sitemap-prune.test.ts`** and **`test/sitemap-route-filter.test.ts`**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@at-flux/astro-feature-flags",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "description": "Typed Astro feature flags from JSON config. Includes environment specific rendering and pruning from elements to entire routes",
6
6
  "author": "atflux <dev@atflux.uk>",
@@ -35,14 +35,14 @@
35
35
  }
36
36
  },
37
37
  "peerDependencies": {
38
- "astro": "4.7.0 || 5.0.0"
38
+ "astro": ">=5.0.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "24.7.0",
42
- "astro": "5.18.1",
43
- "tsdown": "0.21.8",
44
- "typescript": "5.9.3",
45
- "vitest": "3.2.4"
42
+ "astro": "7.1.3",
43
+ "tsdown": "0.22.13",
44
+ "typescript": "7.0.2",
45
+ "vitest": "4.1.10"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
@@ -57,7 +57,7 @@
57
57
  "access": "public"
58
58
  },
59
59
  "dependencies": {
60
- "node-html-parser": "7.1.0"
60
+ "node-html-parser": "9.0.0"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsdown",
@@ -2,6 +2,7 @@ import type { ResolvedFeatureRuntime } from "./runtime";
2
2
  import { toToken } from "./runtime";
3
3
  import { inlineInvoke } from "./inline-script";
4
4
  import { affHeadInlineRuntime } from "./dev-inline-runtimes";
5
+ import { DEV_LAYER } from "./dev-outline-css";
5
6
 
6
7
  /**
7
8
  * Single `injectScript('head-inline', …)` payload for `astro dev`: dev-only outline CSS,
@@ -21,6 +22,7 @@ export function buildAffDevHeadInline(args: {
21
22
  featureFlagStyles,
22
23
  routeFlags: runtime.routeFlags,
23
24
  flagNameToToken,
25
+ devLayer: DEV_LAYER,
24
26
  });
25
27
  return `${setup}\n${affDevBootstrap}`;
26
28
  }
@@ -2,6 +2,7 @@ type HeadInlinePayload = {
2
2
  featureFlagStyles: string;
3
3
  routeFlags: Record<string, string[]>;
4
4
  flagNameToToken: Record<string, string>;
5
+ devLayer: string;
5
6
  };
6
7
 
7
8
  type BootstrapPayload = {
@@ -23,7 +24,33 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
23
24
  return p.endsWith("/") ? p : `${p}/`;
24
25
  };
25
26
  try {
26
- const { featureFlagStyles, routeFlags: RF, flagNameToToken: M } = payload;
27
+ const {
28
+ featureFlagStyles,
29
+ routeFlags: RF,
30
+ flagNameToToken: M,
31
+ devLayer: L,
32
+ } = payload;
33
+
34
+ // Layer priority is fixed by the order layers are first declared, in
35
+ // document order. The dev sheet is appended, so it lands after the app's
36
+ // own stylesheets and its layer would sort last — above Tailwind's
37
+ // `utilities` — which is the opposite of what the layer is for. A style
38
+ // element holding nothing but the order statement, inserted as the first
39
+ // child of <head>, declares the layer before any app sheet and so pins it
40
+ // below everything the app writes.
41
+ const ensureLayerOrder = () => {
42
+ if (document.querySelector("style[data-astro-feature-flags-layer]"))
43
+ return;
44
+ const head = document.head || document.documentElement;
45
+ const s = document.createElement("style");
46
+ s.setAttribute("data-astro-feature-flags-layer", "");
47
+ s.setAttribute(
48
+ "data-astro-transition-persist",
49
+ "astro-feature-flags-layer",
50
+ );
51
+ s.textContent = `@layer ${L};`;
52
+ head.insertBefore(s, head.firstChild);
53
+ };
27
54
 
28
55
  // Inject the dev-chrome stylesheet. Astro's ClientRouter swaps <head> on a
29
56
  // view-transition navigation, and a runtime-injected <style> is dropped on
@@ -33,6 +60,7 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
33
60
  // keep the injector idempotent + re-run it on every nav so the styles stay
34
61
  // constant across pages even where a swap removes them.
35
62
  const ensureFeatureFlagStyles = () => {
63
+ ensureLayerOrder();
36
64
  if (document.querySelector("style[data-astro-feature-flags]")) return;
37
65
  const s = document.createElement("style");
38
66
  s.setAttribute("data-astro-feature-flags", "");
@@ -43,7 +71,10 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
43
71
  ensureFeatureFlagStyles();
44
72
 
45
73
  const affPath = () => {
46
- let p = typeof location !== "undefined" && location.pathname ? location.pathname : "/";
74
+ let p =
75
+ typeof location !== "undefined" && location.pathname
76
+ ? location.pathname
77
+ : "/";
47
78
  if (!p.endsWith("/")) p += "/";
48
79
  return p;
49
80
  };
@@ -82,7 +113,8 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
82
113
  for (let i = 0; i < names.length; i++) {
83
114
  const tk = M[names[i]];
84
115
  if (!tk) continue;
85
- if (root.getAttribute(`data-ff-enabled-${tk}`) === "off") disabled.push(tk);
116
+ if (root.getAttribute(`data-ff-enabled-${tk}`) === "off")
117
+ disabled.push(tk);
86
118
  }
87
119
  const id = "aff-route-disabled-overlay";
88
120
  let el = document.getElementById(id);
@@ -113,7 +145,10 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
113
145
 
114
146
  const affApply = () => {
115
147
  ensureFeatureFlagStyles();
116
- document.documentElement.setAttribute("data-ff-route", affTokens(affPath()).join(" "));
148
+ document.documentElement.setAttribute(
149
+ "data-ff-route",
150
+ affTokens(affPath()).join(" "),
151
+ );
117
152
  affSyncDisabledOverlay();
118
153
  };
119
154
 
@@ -122,7 +157,10 @@ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
122
157
  const obs = new MutationObserver((muts) => {
123
158
  for (let i = 0; i < muts.length; i++) {
124
159
  const a = muts[i] && muts[i].attributeName;
125
- if (a === "data-ff-route" || (a && a.indexOf("data-ff-enabled-") === 0)) {
160
+ if (
161
+ a === "data-ff-route" ||
162
+ (a && a.indexOf("data-ff-enabled-") === 0)
163
+ ) {
126
164
  affSyncDisabledOverlay();
127
165
  break;
128
166
  }
@@ -155,7 +193,8 @@ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
155
193
  const K = `${N}.dev.v1`;
156
194
 
157
195
  const root = document.documentElement;
158
- const isEnabled = (tk: string) => root.getAttribute(`data-ff-enabled-${tk}`) !== "off";
196
+ const isEnabled = (tk: string) =>
197
+ root.getAttribute(`data-ff-enabled-${tk}`) !== "off";
159
198
  const currentTokenColor = (
160
199
  tk: string,
161
200
  cols: Record<string, string>,
@@ -194,7 +233,8 @@ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
194
233
  const applyCombo = (el: HTMLElement, cols: Record<string, string>) => {
195
234
  const allTokens = comboTokens(el);
196
235
  const hasCombo = allTokens.length >= 2;
197
- const hasDisabledInCombo = hasCombo && allTokens.some((tk) => !isEnabled(tk));
236
+ const hasDisabledInCombo =
237
+ hasCombo && allTokens.some((tk) => !isEnabled(tk));
198
238
  const visual = allTokens.filter((tk) => isEnabled(tk));
199
239
  if (hasDisabledInCombo) {
200
240
  // Fail-closed for combos: if any member token is disabled, hide the whole element.
@@ -225,15 +265,24 @@ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
225
265
  el.setAttribute("data-ff-label", visual.join(" | "));
226
266
  const strong = visual.map((tk) => currentTokenColor(tk, cols));
227
267
  const soft = strong.map((c) => `color-mix(in oklab, white 86%, ${c} 14%)`);
228
- el.style.setProperty("--ff-combo-gradient", `linear-gradient(90deg, ${strong.join(", ")})`);
229
- el.style.setProperty("--ff-combo-gradient-soft", `linear-gradient(90deg, ${soft.join(", ")})`);
268
+ el.style.setProperty(
269
+ "--ff-combo-gradient",
270
+ `linear-gradient(90deg, ${strong.join(", ")})`,
271
+ );
272
+ el.style.setProperty(
273
+ "--ff-combo-gradient-soft",
274
+ `linear-gradient(90deg, ${soft.join(", ")})`,
275
+ );
230
276
  el.style.setProperty("--ff-combo-outline", strong[0] || "#64748b");
231
277
  el.style.setProperty("--ff-combo-text", strong[0] || "#111827");
232
278
  el.style.setProperty(
233
279
  "--ff-combo-badge-border",
234
280
  `color-mix(in oklab, ${strong[0] || "#64748b"} 35%, transparent)`,
235
281
  );
236
- el.style.setProperty("--ff-combo-badge-gradient", `linear-gradient(90deg, ${soft.join(", ")})`);
282
+ el.style.setProperty(
283
+ "--ff-combo-badge-gradient",
284
+ `linear-gradient(90deg, ${soft.join(", ")})`,
285
+ );
237
286
  };
238
287
 
239
288
  const routeTokensForPath = (path: string): string[] => {
@@ -359,10 +408,21 @@ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
359
408
  const toks = routeTokensForPath(p).filter((tk) => isEnabled(tk));
360
409
  if (toks.length) {
361
410
  const strong = toks.map((tk) => currentTokenColor(tk, cols));
362
- const soft = strong.map((c) => `color-mix(in oklab, white 86%, ${c} 14%)`);
363
- root.style.setProperty(`--${N}-route-gradient`, `linear-gradient(90deg, ${soft.join(", ")})`);
364
- root.style.setProperty("--ff-route-outline-gradient", `linear-gradient(90deg, ${strong.join(", ")})`);
365
- root.style.setProperty("--ff-route-badge-gradient", `linear-gradient(90deg, ${soft.join(", ")})`);
411
+ const soft = strong.map(
412
+ (c) => `color-mix(in oklab, white 86%, ${c} 14%)`,
413
+ );
414
+ root.style.setProperty(
415
+ `--${N}-route-gradient`,
416
+ `linear-gradient(90deg, ${soft.join(", ")})`,
417
+ );
418
+ root.style.setProperty(
419
+ "--ff-route-outline-gradient",
420
+ `linear-gradient(90deg, ${strong.join(", ")})`,
421
+ );
422
+ root.style.setProperty(
423
+ "--ff-route-badge-gradient",
424
+ `linear-gradient(90deg, ${soft.join(", ")})`,
425
+ );
366
426
  root.style.setProperty(
367
427
  "--ff-route-badge-border",
368
428
  `color-mix(in oklab, ${strong[0] || "#64748b"} 35%, transparent)`,
@@ -383,7 +443,9 @@ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
383
443
 
384
444
  affAfterNav();
385
445
  if (document.readyState === "loading") {
386
- document.addEventListener("DOMContentLoaded", affAfterNav, { once: true });
446
+ document.addEventListener("DOMContentLoaded", affAfterNav, {
447
+ once: true,
448
+ });
387
449
  }
388
450
  document.addEventListener("astro:page-load", affAfterNav);
389
451
  document.addEventListener("astro:after-swap", affAfterNav);
@@ -8,6 +8,13 @@ import { affDevBootstrapRuntime } from "./dev-inline-runtimes";
8
8
  import type { ResolvedFeatureRuntime } from "./runtime";
9
9
  import { toToken } from "./runtime";
10
10
 
11
+ /**
12
+ * Cascade layer for the declarations the app must be able to override without
13
+ * writing `!important`. Named rather than anonymous so the order statement in
14
+ * {@link affHeadInlineRuntime} can name it too.
15
+ */
16
+ export const DEV_LAYER = "aff-dev";
17
+
11
18
  export type DevOutlineHiddenStrategy = "visibility" | "display";
12
19
 
13
20
  export interface DevOutlineCssOptions extends ElementBadgeLayoutOptions {
@@ -67,7 +74,9 @@ function normalizeCssOptions(
67
74
  ...(css?.outlineColorByToken ?? {}),
68
75
  };
69
76
  const badgeLabelWip =
70
- css?.badgeLabelWip ?? css?.badgeLabelDev ?? defaultDevOutlineCssOptions.badgeLabelWip;
77
+ css?.badgeLabelWip ??
78
+ css?.badgeLabelDev ??
79
+ defaultDevOutlineCssOptions.badgeLabelWip;
71
80
  return {
72
81
  ...defaultDevOutlineCssOptions,
73
82
  ...(css ?? {}),
@@ -97,6 +106,28 @@ function featureColorVar(token: string, namespace: string): string {
97
106
  * Dev-only styles: `data-ff="token"` (or space-separated tokens) gates outlines/badges.
98
107
  * `html[data-ff-route="<token>"]` (dev) adds a fixed top-right route badge (label only).
99
108
  * Toolbar toggles: `data-ff-enabled-*`, `data-ff-outline-*`, `data-ff-badge-*`, `--<namespace>-c-*`.
109
+ *
110
+ * The badge is an absolutely positioned `::before`, so its host has to be a
111
+ * containing block, and the host also wants rounding for the outline to follow.
112
+ * Both of those are the app's business, not ours: a flagged element that the app
113
+ * positions itself, or rounds itself, must keep doing so. Marking a flagged
114
+ * element must not move it.
115
+ *
116
+ * So `position` and `border-radius` are emitted inside `:where()` *and* inside
117
+ * the `aff-dev` cascade layer. `:where()` alone is not enough: an unlayered rule
118
+ * beats a layered one before specificity is ever consulted, so a zero-specificity
119
+ * unlayered rule still overrides Tailwind's `.rounded-full` and `.absolute`,
120
+ * which live in `@layer utilities`. Layering ours is what lets the app win.
121
+ *
122
+ * Layer priority follows the order layers are first declared, so the layer only
123
+ * sorts below the app's own layers if `@layer aff-dev;` appears before them in
124
+ * document order. The sheet opens with that statement for the case where it
125
+ * lands first, and {@link affHeadInlineRuntime} also prepends a style element
126
+ * carrying it as the first child of `<head>`, which is what actually guarantees
127
+ * the order at runtime.
128
+ *
129
+ * Everything else here is dev chrome the app has no opinion about, and stays at
130
+ * its natural specificity.
100
131
  */
101
132
  export function createFeatureFlagStyles(
102
133
  runtime: ResolvedFeatureRuntime,
@@ -111,7 +142,9 @@ export function createFeatureFlagStyles(
111
142
  return {
112
143
  token,
113
144
  baseSelectors,
114
- selOutline: baseSelectors.map((s) => `${s}:not([${nsAttr}*=" "])`).join(", "),
145
+ selOutline: baseSelectors
146
+ .map((s) => `${s}:not([${nsAttr}*=" "])`)
147
+ .join(", "),
115
148
  selIs: `:is(${baseSelectors.join(", ")})`,
116
149
  };
117
150
  };
@@ -138,11 +171,15 @@ html {
138
171
  html:not([data-ff-outline-${token}="off"]) {
139
172
  --aff-outline-c-${token}: var(${v}, ${col});
140
173
  }
174
+ @layer ${DEV_LAYER} {
175
+ :where(${selOutline}) {
176
+ position: relative;
177
+ border-radius: ${opts.borderRadius};
178
+ }
179
+ }
141
180
  ${selOutline} {
142
- position: relative;
143
181
  outline: ${opts.outlineWidth} solid var(--aff-outline-c-${token});
144
182
  outline-offset: ${opts.outlineOffset};
145
- border-radius: ${opts.borderRadius};
146
183
  }
147
184
  html:not([data-ff-badge-${token}="off"]) ${selIsSingleBadge}::before {
148
185
  box-sizing: border-box;
@@ -226,11 +263,15 @@ html[data-ff-enabled-${token}="off"] ${selIs} {
226
263
 
227
264
  // Multi-token value on a single element: show combined badge text and a gradient outline.
228
265
  chunks.push(`
266
+ @layer ${DEV_LAYER} {
267
+ :where([${nsAttr}*=" "]) {
268
+ position: relative;
269
+ border-radius: ${opts.borderRadius};
270
+ }
271
+ }
229
272
  [${nsAttr}*=" "] {
230
- position: relative;
231
273
  outline: none !important;
232
274
  outline-offset: 0 !important;
233
- border-radius: ${opts.borderRadius};
234
275
  }
235
276
  html [${nsAttr}*=" "]::after {
236
277
  content: "";
@@ -404,7 +445,7 @@ html[data-ff-outline-${token}="off"] ${selIs}[${nsAttr}*=" "]::after {
404
445
  `);
405
446
  }
406
447
 
407
- return "\n" + chunks.join("\n") + "\n";
448
+ return `\n@layer ${DEV_LAYER};\n` + chunks.join("\n") + "\n";
408
449
  }
409
450
 
410
451
  /**
@@ -79,11 +79,9 @@ function loadState(tokens: string[], namespace: string): AffState {
79
79
  if (!raw) return next;
80
80
  const parsed = JSON.parse(raw) as Record<string, unknown>;
81
81
  const outline = (parsed.outline ?? parsed.chrome) as
82
- | Record<string, boolean>
83
- | undefined;
82
+ Record<string, boolean> | undefined;
84
83
  const enabled = (parsed.enabled ?? parsed.render) as
85
- | Record<string, boolean>
86
- | undefined;
84
+ Record<string, boolean> | undefined;
87
85
  const badge = parsed.badge as Record<string, boolean> | undefined;
88
86
  const colors = parsed.colors as Record<string, string> | undefined;
89
87
  if (outline) Object.assign(next.outline, outline);
package/src/index.ts CHANGED
@@ -42,21 +42,38 @@ import {
42
42
  buildAffDevBootstrapScript,
43
43
  createFeatureFlagStyles,
44
44
  createProductionGateStyles,
45
+ DEV_LAYER,
45
46
  } from "./dev-outline-css";
46
47
  import { applyProductionHtmlCullToDist } from "./production-html-cull";
48
+ import { applySitemapPruneToDist } from "./sitemap-prune";
47
49
  import { buildAffDevHeadInline } from "./dev-head-inject";
48
50
  import { routePrefixJsHelper } from "./route-prefix-js";
49
51
 
50
- export { createFeatureFlagStyles, createProductionGateStyles };
52
+ export { createFeatureFlagStyles, createProductionGateStyles, DEV_LAYER };
51
53
  export {
52
54
  cullProductionHtml,
53
55
  applyProductionHtmlCullToDist,
54
56
  } from "./production-html-cull";
57
+ export type { SitemapPruneResult } from "./sitemap-prune";
58
+ export {
59
+ applySitemapPruneToDist,
60
+ pruneSitemapIndexXml,
61
+ pruneSitemapXml,
62
+ sitemapUrlCount,
63
+ } from "./sitemap-prune";
55
64
 
56
65
  export interface AstroFeatureFlagsOptions extends ResolveFeatureRuntimeOptions {
57
66
  css?: DevOutlineCssOptions;
58
67
  /** When false, keeps static build output untouched (no route pruning / HTML cull). */
59
68
  staticMinify?: boolean;
69
+ /**
70
+ * When false, leaves generated sitemaps alone. On by default: a pruned route that is
71
+ * still advertised in `sitemap-0.xml` is a 404 handed to every crawler that reads it.
72
+ *
73
+ * Needs this integration to sit **after** `@astrojs/sitemap` in `integrations`, because
74
+ * Astro runs `astro:build:done` in array order.
75
+ */
76
+ pruneSitemap?: boolean;
60
77
  }
61
78
 
62
79
  /** Prefer `prod` if present; else first non-`dev` key (sorted); else `"prod"`. */
@@ -80,13 +97,14 @@ function withDefaultEnvironments(
80
97
  ? { ...options.environments }
81
98
  : {};
82
99
  delete raw.dev;
83
- const environments: NonNullable<ResolveFeatureRuntimeOptions["environments"]> =
84
- {
85
- dev: {
86
- when: mode !== "production",
87
- },
88
- ...raw,
89
- };
100
+ const environments: NonNullable<
101
+ ResolveFeatureRuntimeOptions["environments"]
102
+ > = {
103
+ dev: {
104
+ when: mode !== "production",
105
+ },
106
+ ...raw,
107
+ };
90
108
  if (Object.keys(environments).length < 2) {
91
109
  environments.prod = environments.prod ?? {
92
110
  when: mode === "production",
@@ -130,7 +148,9 @@ export function createVirtualModuleSource(
130
148
  Object.fromEntries(flagNames.map((name) => [name, toToken(name)])),
131
149
  runtime.namespace,
132
150
  );
133
- const affRoutePatternToPrefix = routePrefixJsHelper("affRoutePatternToPrefix");
151
+ const affRoutePatternToPrefix = routePrefixJsHelper(
152
+ "affRoutePatternToPrefix",
153
+ );
134
154
 
135
155
  return `${affRoutePatternToPrefix}
136
156
 
@@ -301,7 +321,12 @@ export {
301
321
  export default function astroFeatureFlags(
302
322
  options: AstroFeatureFlagsOptions = {},
303
323
  ): any {
304
- const { css, staticMinify = true, ...flagOpts } = options;
324
+ const {
325
+ css,
326
+ staticMinify = true,
327
+ pruneSitemap = true,
328
+ ...flagOpts
329
+ } = options;
305
330
  const opts = withDefaultEnvironments(flagOpts);
306
331
  const mode = opts.mode ?? process.env.NODE_ENV ?? "development";
307
332
 
@@ -310,15 +335,23 @@ export default function astroFeatureFlags(
310
335
  mode,
311
336
  });
312
337
 
338
+ /**
339
+ * Set in `astro:config:setup`, read in `astro:build:done`, so that a sitemap this
340
+ * pass never saw can be reported as an ordering mistake rather than silently skipped.
341
+ */
342
+ let sitemapIntegrationPresent = false;
343
+
313
344
  return {
314
345
  name: "astro-feature-flags",
315
346
  hooks: {
316
347
  "astro:config:setup": ({
348
+ config,
317
349
  updateConfig,
318
350
  addDevToolbarApp,
319
351
  command,
320
352
  injectScript,
321
353
  }: {
354
+ config?: { integrations?: { name?: string }[] };
322
355
  updateConfig: (config: unknown) => void;
323
356
  addDevToolbarApp?: (opts: {
324
357
  id: string;
@@ -329,6 +362,9 @@ export default function astroFeatureFlags(
329
362
  command?: string;
330
363
  injectScript?: (stage: string, content: string) => void;
331
364
  }) => {
365
+ sitemapIntegrationPresent = (config?.integrations ?? []).some(
366
+ (integration) => integration?.name === "@astrojs/sitemap",
367
+ );
332
368
  const flagNames = Object.keys(runtime.flags);
333
369
  const flagTokens = flagNames.map((name) => toToken(name));
334
370
  const flagsByEnvironment = resolveFeatureFlagsByEnvironment(opts);
@@ -400,7 +436,13 @@ export default function astroFeatureFlags(
400
436
  },
401
437
  });
402
438
  },
403
- "astro:build:done": ({ dir }: { dir: URL }) => {
439
+ "astro:build:done": ({
440
+ dir,
441
+ logger,
442
+ }: {
443
+ dir: URL;
444
+ logger?: { warn: (message: string) => void };
445
+ }) => {
404
446
  if (runtime.isDev || !staticMinify) return;
405
447
  const outDir = fileURLToPath(dir);
406
448
  const prunePaths = routePathsToPrune({
@@ -411,6 +453,13 @@ export default function astroFeatureFlags(
411
453
  rmSync(join(outDir, routePath), { recursive: true, force: true });
412
454
  }
413
455
  applyProductionHtmlCullToDist(outDir, runtime);
456
+ if (!pruneSitemap) return;
457
+ const sitemaps = applySitemapPruneToDist(outDir, runtime);
458
+ if (!sitemaps.found && sitemapIntegrationPresent && prunePaths.length) {
459
+ logger?.warn(
460
+ "@astrojs/sitemap is configured but no sitemap was on disk yet, so pruned routes may still be listed. Move astroFeatureFlags() after sitemap() in `integrations`.",
461
+ );
462
+ }
414
463
  },
415
464
  },
416
465
  };
@@ -2,7 +2,9 @@ export function compactInlineScript(source: string): string {
2
2
  const lines = source
3
3
  .split("\n")
4
4
  .map((line) => line.trim())
5
- .filter((line, idx, arr) => line.length > 0 || (idx > 0 && idx < arr.length - 1));
5
+ .filter(
6
+ (line, idx, arr) => line.length > 0 || (idx > 0 && idx < arr.length - 1),
7
+ );
6
8
  return lines.join("\n").trim();
7
9
  }
8
10