@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.
- package/README.md +32 -31
- package/dist/dev-toolbar-app.d.mts +0 -1
- package/dist/dev-toolbar-app.d.mts.map +1 -1
- package/dist/dev-toolbar-app.mjs +1 -1
- package/dist/dev-toolbar-app.mjs.map +1 -1
- package/dist/{dev-toolbar-flag-icon-BHCQJ53N.mjs → dev-toolbar-flag-icon-MQsIFekv.mjs} +1 -1
- package/dist/{dev-toolbar-flag-icon-BHCQJ53N.mjs.map → dev-toolbar-flag-icon-MQsIFekv.mjs.map} +1 -1
- package/dist/index.d.mts +74 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +204 -18
- package/dist/index.mjs.map +1 -1
- package/dist/runtime.d.mts +4 -11
- package/dist/runtime.d.mts.map +1 -1
- package/dist/runtime.mjs.map +1 -1
- package/docs/how-to/hide-from-sitemaps.md +56 -10
- package/package.json +7 -7
- package/src/dev-head-inject.ts +2 -0
- package/src/dev-inline-runtimes.ts +77 -15
- package/src/dev-outline-css.ts +48 -7
- package/src/dev-toolbar-app.ts +2 -4
- package/src/index.ts +60 -11
- package/src/inline-script.ts +3 -1
- package/src/runtime.ts +18 -11
- package/src/sitemap-prune.ts +171 -0
|
@@ -1,20 +1,62 @@
|
|
|
1
1
|
# Hide Flagged Routes From Sitemaps
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
38
|
+
"astro": ">=5.0.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "24.7.0",
|
|
42
|
-
"astro": "
|
|
43
|
-
"tsdown": "0.
|
|
44
|
-
"typescript": "
|
|
45
|
-
"vitest": "
|
|
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": "
|
|
60
|
+
"node-html-parser": "9.0.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "tsdown",
|
package/src/dev-head-inject.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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")
|
|
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(
|
|
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 (
|
|
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) =>
|
|
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 =
|
|
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(
|
|
229
|
-
|
|
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(
|
|
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(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
root.style.setProperty(
|
|
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, {
|
|
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);
|
package/src/dev-outline-css.ts
CHANGED
|
@@ -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 ??
|
|
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
|
|
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
|
|
448
|
+
return `\n@layer ${DEV_LAYER};\n` + chunks.join("\n") + "\n";
|
|
408
449
|
}
|
|
409
450
|
|
|
410
451
|
/**
|
package/src/dev-toolbar-app.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
| undefined;
|
|
82
|
+
Record<string, boolean> | undefined;
|
|
84
83
|
const enabled = (parsed.enabled ?? parsed.render) as
|
|
85
|
-
|
|
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<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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 {
|
|
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": ({
|
|
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
|
};
|
package/src/inline-script.ts
CHANGED
|
@@ -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(
|
|
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
|
|