@at-flux/astro-feature-flags 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/dev-toolbar-app.d.mts +16 -0
  4. package/dist/dev-toolbar-app.d.mts.map +1 -0
  5. package/dist/dev-toolbar-app.mjs +407 -0
  6. package/dist/dev-toolbar-app.mjs.map +1 -0
  7. package/dist/dev-toolbar-flag-icon-BHCQJ53N.mjs +10 -0
  8. package/dist/dev-toolbar-flag-icon-BHCQJ53N.mjs.map +1 -0
  9. package/dist/index.d.mts +103 -0
  10. package/dist/index.d.mts.map +1 -0
  11. package/dist/index.mjs +1107 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/runtime.d.mts +115 -0
  14. package/dist/runtime.d.mts.map +1 -0
  15. package/dist/runtime.mjs +280 -0
  16. package/dist/runtime.mjs.map +1 -0
  17. package/docs/assets/element-gating.png +0 -0
  18. package/docs/assets/page-gating.png +0 -0
  19. package/docs/assets/toolbar-feature-flags.png +0 -0
  20. package/docs/how-to/hide-from-sitemaps.md +49 -0
  21. package/docs/testing.md +40 -0
  22. package/example/README.md +18 -0
  23. package/example/astro.config.mjs +26 -0
  24. package/example/ff.json +26 -0
  25. package/example/package.json +22 -0
  26. package/example/pnpm-lock.yaml +4022 -0
  27. package/example/src/env.d.ts +2 -0
  28. package/example/src/layouts/Layout.astro +21 -0
  29. package/example/src/pages/hot/index.astro +23 -0
  30. package/example/src/pages/hot-dev/index.astro +18 -0
  31. package/example/src/pages/hot-dev/sub/index.astro +21 -0
  32. package/example/src/pages/hot-feature-1/index.astro +38 -0
  33. package/example/src/pages/index.astro +102 -0
  34. package/example/src/styles/global.css +22 -0
  35. package/example/tsconfig.json +4 -0
  36. package/package.json +68 -0
  37. package/src/badge-layout.ts +146 -0
  38. package/src/dev-head-inject.ts +26 -0
  39. package/src/dev-inline-runtimes.ts +397 -0
  40. package/src/dev-outline-css.ts +449 -0
  41. package/src/dev-toolbar-app.ts +516 -0
  42. package/src/dev-toolbar-flag-icon.ts +9 -0
  43. package/src/index.ts +417 -0
  44. package/src/inline-script.ts +14 -0
  45. package/src/production-html-cull.ts +133 -0
  46. package/src/route-prefix-js.ts +7 -0
  47. package/src/runtime.ts +575 -0
  48. package/virtual-astro-feature-flags.d.ts +67 -0
@@ -0,0 +1,397 @@
1
+ type HeadInlinePayload = {
2
+ featureFlagStyles: string;
3
+ routeFlags: Record<string, string[]>;
4
+ flagNameToToken: Record<string, string>;
5
+ };
6
+
7
+ type BootstrapPayload = {
8
+ flagTokens: string[];
9
+ colors: Record<string, string>;
10
+ outlineDefaults: Record<string, boolean>;
11
+ badgeDefaults: Record<string, boolean>;
12
+ routeFlags: Record<string, string[]>;
13
+ flagNameToToken: Record<string, string>;
14
+ namespace: string;
15
+ };
16
+
17
+ export function affHeadInlineRuntime(payload: HeadInlinePayload): void {
18
+ const toPrefix = (pattern: string): string => {
19
+ let p = String(pattern || "").trim();
20
+ if (p.endsWith("/**")) p = p.slice(0, -3);
21
+ else if (p.endsWith("/*")) p = p.slice(0, -2);
22
+ else if (p.length > 1 && p.endsWith("*")) p = p.slice(0, -1);
23
+ return p.endsWith("/") ? p : `${p}/`;
24
+ };
25
+ try {
26
+ const { featureFlagStyles, routeFlags: RF, flagNameToToken: M } = payload;
27
+ const s = document.createElement("style");
28
+ s.setAttribute("data-astro-feature-flags", "");
29
+ s.textContent = featureFlagStyles;
30
+ (document.head || document.documentElement).appendChild(s);
31
+
32
+ const affPath = () => {
33
+ let p = typeof location !== "undefined" && location.pathname ? location.pathname : "/";
34
+ if (!p.endsWith("/")) p += "/";
35
+ return p;
36
+ };
37
+
38
+ const affFlagNames = (pathname: string) => {
39
+ let p = pathname || "/";
40
+ if (!p.endsWith("/")) p += "/";
41
+ const out: string[] = [];
42
+ const seen = new Set<string>();
43
+ for (const rp in RF) {
44
+ if (!Object.prototype.hasOwnProperty.call(RF, rp)) continue;
45
+ const route = toPrefix(rp);
46
+ if (!(p === route || p.indexOf(route) === 0)) continue;
47
+ const fns = RF[rp] || [];
48
+ for (let i = 0; i < fns.length; i++) {
49
+ const fn = fns[i];
50
+ if (fn && !seen.has(fn)) {
51
+ seen.add(fn);
52
+ out.push(fn);
53
+ }
54
+ }
55
+ }
56
+ return out;
57
+ };
58
+
59
+ const affTokens = (pathname: string) =>
60
+ affFlagNames(pathname)
61
+ .map((name) => M[name])
62
+ .filter(Boolean);
63
+
64
+ const affSyncDisabledOverlay = () => {
65
+ const root = document.documentElement;
66
+ if (!document.body) return;
67
+ const names = affFlagNames(affPath());
68
+ const disabled: string[] = [];
69
+ for (let i = 0; i < names.length; i++) {
70
+ const tk = M[names[i]];
71
+ if (!tk) continue;
72
+ if (root.getAttribute(`data-ff-enabled-${tk}`) === "off") disabled.push(tk);
73
+ }
74
+ const id = "aff-route-disabled-overlay";
75
+ let el = document.getElementById(id);
76
+ if (!disabled.length) {
77
+ if (el) el.remove();
78
+ root.removeAttribute("data-aff-route-disabled");
79
+ return;
80
+ }
81
+ root.setAttribute("data-aff-route-disabled", "");
82
+ if (!el) {
83
+ el = document.createElement("div");
84
+ el.id = id;
85
+ el.className = "aff-route-disabled-overlay";
86
+ const p = document.createElement("p");
87
+ el.appendChild(p);
88
+ document.body.appendChild(el);
89
+ }
90
+ const pEl = el.querySelector("p") || el;
91
+ const flagsText = disabled.join(", ");
92
+ const line1 =
93
+ disabled.length === 1
94
+ ? `Page disabled: ${flagsText} flag is disabled.`
95
+ : `Page disabled: ${flagsText} flags are disabled.`;
96
+ const line2 =
97
+ "In production builds, pages with disabled flags like this will not be emitted";
98
+ pEl.textContent = `${line1}\n${line2}`;
99
+ };
100
+
101
+ const affApply = () => {
102
+ document.documentElement.setAttribute("data-ff-route", affTokens(affPath()).join(" "));
103
+ affSyncDisabledOverlay();
104
+ };
105
+
106
+ affApply();
107
+ if (typeof MutationObserver === "function") {
108
+ const obs = new MutationObserver((muts) => {
109
+ for (let i = 0; i < muts.length; i++) {
110
+ const a = muts[i] && muts[i].attributeName;
111
+ if (a === "data-ff-route" || (a && a.indexOf("data-ff-enabled-") === 0)) {
112
+ affSyncDisabledOverlay();
113
+ break;
114
+ }
115
+ }
116
+ });
117
+ obs.observe(document.documentElement, { attributes: true });
118
+ }
119
+ document.addEventListener("astro:page-load", affApply);
120
+ document.addEventListener("astro:after-swap", affApply);
121
+ } catch {
122
+ // no-op in injected runtime
123
+ }
124
+ }
125
+
126
+ export function affDevBootstrapRuntime(payload: BootstrapPayload): void {
127
+ const toPrefix = (pattern: string): string => {
128
+ let p = String(pattern || "").trim();
129
+ if (p.endsWith("/**")) p = p.slice(0, -3);
130
+ else if (p.endsWith("/*")) p = p.slice(0, -2);
131
+ else if (p.length > 1 && p.endsWith("*")) p = p.slice(0, -1);
132
+ return p.endsWith("/") ? p : `${p}/`;
133
+ };
134
+ const T = payload.flagTokens;
135
+ const C = payload.colors;
136
+ const OD = payload.outlineDefaults;
137
+ const BD = payload.badgeDefaults;
138
+ const RF = payload.routeFlags;
139
+ const F2T = payload.flagNameToToken;
140
+ const N = payload.namespace;
141
+ const K = `${N}.dev.v1`;
142
+
143
+ const root = document.documentElement;
144
+ const isEnabled = (tk: string) => root.getAttribute(`data-ff-enabled-${tk}`) !== "off";
145
+ const currentTokenColor = (
146
+ tk: string,
147
+ cols: Record<string, string>,
148
+ ): string => {
149
+ const cssVar = root.style.getPropertyValue(`--${N}-c-${tk}`).trim();
150
+ return cssVar || cols[tk] || C[tk] || "#94a3b8";
151
+ };
152
+ const unique = (a: string[]) => [...new Set(a.filter(Boolean))];
153
+
154
+ const comboTokens = (el: Element): string[] => {
155
+ let out: string[] = [];
156
+ const raw = (el.getAttribute(`data-${N}`) || "").trim();
157
+ if (raw) out = out.concat(raw.split(/\s+/).filter(Boolean));
158
+ for (let i = 0; i < T.length; i++) {
159
+ const tk = T[i];
160
+ if (el.hasAttribute(`data-${N}-${tk}`)) out.push(tk);
161
+ }
162
+ return unique(out);
163
+ };
164
+
165
+ const stripBooleanFlagAttrs = (el: Element) => {
166
+ for (let i = 0; i < T.length; i++) {
167
+ el.removeAttribute(`data-${N}-${T[i]}`);
168
+ }
169
+ };
170
+ const clearComboVisualState = (el: HTMLElement) => {
171
+ el.removeAttribute("data-ff-label");
172
+ el.style.removeProperty("--ff-combo-gradient");
173
+ el.style.removeProperty("--ff-combo-gradient-soft");
174
+ el.style.removeProperty("--ff-combo-outline");
175
+ el.style.removeProperty("--ff-combo-text");
176
+ el.style.removeProperty("--ff-combo-badge-border");
177
+ el.style.removeProperty("--ff-combo-badge-gradient");
178
+ };
179
+
180
+ const applyCombo = (el: HTMLElement, cols: Record<string, string>) => {
181
+ const allTokens = comboTokens(el);
182
+ const hasCombo = allTokens.length >= 2;
183
+ const hasDisabledInCombo = hasCombo && allTokens.some((tk) => !isEnabled(tk));
184
+ const visual = allTokens.filter((tk) => isEnabled(tk));
185
+ if (hasDisabledInCombo) {
186
+ // Fail-closed for combos: if any member token is disabled, hide the whole element.
187
+ // Keep all combo tokens mirrored in data-<ns> so enabled-off CSS selectors can hide it.
188
+ stripBooleanFlagAttrs(el);
189
+ el.setAttribute(`data-${N}`, allTokens.join(" "));
190
+ clearComboVisualState(el);
191
+ return;
192
+ }
193
+ if (visual.length < 2) {
194
+ clearComboVisualState(el);
195
+ if (hasCombo) {
196
+ // Only normalize attrs for true combos. Single-flag shorthand markers
197
+ // (e.g. data-ff-wip) must stay intact so re-enabling can recover styling.
198
+ stripBooleanFlagAttrs(el);
199
+ if (visual.length === 1) {
200
+ el.setAttribute(`data-${N}`, visual[0]!);
201
+ } else {
202
+ el.removeAttribute(`data-${N}`);
203
+ }
204
+ }
205
+ return;
206
+ }
207
+ stripBooleanFlagAttrs(el);
208
+ // Mirror all enabled tokens into the primary namespace attribute so `[data-<ns>*=" "]`
209
+ // matches (required for boolean-only combos like data-ff-a + data-ff-b).
210
+ el.setAttribute(`data-${N}`, visual.join(" "));
211
+ el.setAttribute("data-ff-label", visual.join(" | "));
212
+ const strong = visual.map((tk) => currentTokenColor(tk, cols));
213
+ const soft = strong.map((c) => `color-mix(in oklab, white 86%, ${c} 14%)`);
214
+ el.style.setProperty("--ff-combo-gradient", `linear-gradient(90deg, ${strong.join(", ")})`);
215
+ el.style.setProperty("--ff-combo-gradient-soft", `linear-gradient(90deg, ${soft.join(", ")})`);
216
+ el.style.setProperty("--ff-combo-outline", strong[0] || "#64748b");
217
+ el.style.setProperty("--ff-combo-text", strong[0] || "#111827");
218
+ el.style.setProperty(
219
+ "--ff-combo-badge-border",
220
+ `color-mix(in oklab, ${strong[0] || "#64748b"} 35%, transparent)`,
221
+ );
222
+ el.style.setProperty("--ff-combo-badge-gradient", `linear-gradient(90deg, ${soft.join(", ")})`);
223
+ };
224
+
225
+ const routeTokensForPath = (path: string): string[] => {
226
+ const out: string[] = [];
227
+ const seen = new Set<string>();
228
+ for (const rp in RF) {
229
+ if (!Object.prototype.hasOwnProperty.call(RF, rp)) continue;
230
+ const cnd = toPrefix(rp);
231
+ if (!(path === cnd || path.indexOf(cnd) === 0)) continue;
232
+ const fns = RF[rp] || [];
233
+ for (let i = 0; i < fns.length; i++) {
234
+ const tk = F2T[fns[i]];
235
+ if (tk && !seen.has(tk)) {
236
+ seen.add(tk);
237
+ out.push(tk);
238
+ }
239
+ }
240
+ }
241
+ return out;
242
+ };
243
+
244
+ const routeLabelFor = (path: string, toks: string[]): string => {
245
+ if (!toks.length) return "";
246
+ const byFlag: Record<string, string[]> = {};
247
+ for (const rp in RF) {
248
+ if (!Object.prototype.hasOwnProperty.call(RF, rp)) continue;
249
+ const cnd = toPrefix(rp);
250
+ if (!(path === cnd || path.indexOf(cnd) === 0)) continue;
251
+ const fns = RF[rp] || [];
252
+ for (let i = 0; i < fns.length; i++) {
253
+ const tk = F2T[fns[i]];
254
+ if (!tk) continue;
255
+ if (!byFlag[tk]) byFlag[tk] = [];
256
+ byFlag[tk].push(rp);
257
+ }
258
+ }
259
+ return `Page: ${toks
260
+ .map((tk) => `${tk} - ${(byFlag[tk] || []).join(", ") || "matched"}`)
261
+ .join(" | ")}`;
262
+ };
263
+
264
+ const affSyncRouteChrome = (path: string) => {
265
+ if (!document.body) return;
266
+ // Keep every matched route token on <html> so per-token toolbar rules
267
+ // (`[data-ff-route~="tk"]`) still apply; gradients use enabled-only below.
268
+ const routeAll = routeTokensForPath(path);
269
+ if (!routeAll.length) {
270
+ document.getElementById("aff-route-frame")?.remove();
271
+ document.getElementById("aff-route-badge")?.remove();
272
+ root.removeAttribute("data-ff-route");
273
+ root.removeAttribute("data-ff-route-label");
274
+ return;
275
+ }
276
+ root.setAttribute("data-ff-route", routeAll.join(" "));
277
+ root.setAttribute("data-ff-route-label", routeLabelFor(path, routeAll));
278
+ if (!document.getElementById("aff-route-frame")) {
279
+ const frame = document.createElement("div");
280
+ frame.id = "aff-route-frame";
281
+ frame.className = "aff-route-frame";
282
+ frame.setAttribute("aria-hidden", "true");
283
+ document.body.appendChild(frame);
284
+ }
285
+ let badge = document.getElementById("aff-route-badge");
286
+ if (!badge) {
287
+ badge = document.createElement("div");
288
+ badge.id = "aff-route-badge";
289
+ badge.className = "aff-route-badge";
290
+ badge.setAttribute("role", "status");
291
+ document.body.appendChild(badge);
292
+ }
293
+ badge.textContent = root.getAttribute("data-ff-route-label") || "";
294
+ badge.setAttribute("aria-label", badge.textContent || "");
295
+ };
296
+
297
+ try {
298
+ (window as Window & { __AFF__?: unknown }).__AFF__ = {
299
+ tokens: T,
300
+ colors: C,
301
+ namespace: N,
302
+ };
303
+ const raw = localStorage.getItem(K) || localStorage.getItem("aff.dev.v1");
304
+ const st = raw ? JSON.parse(raw) : {};
305
+ const o = st.outline || st.chrome || {};
306
+ const e = st.enabled || st.render || {};
307
+ const b = st.badge || {};
308
+ const cols = st.colors || {};
309
+
310
+ for (let i = 0; i < T.length; i++) {
311
+ const t = T[i];
312
+ const od = OD[t] !== false;
313
+ const bd = BD[t] !== false;
314
+ if (o[t] === false || (!od && o[t] !== true))
315
+ root.setAttribute(`data-ff-outline-${t}`, "off");
316
+ else root.removeAttribute(`data-ff-outline-${t}`);
317
+ if (e[t] === false) root.setAttribute(`data-ff-enabled-${t}`, "off");
318
+ else root.removeAttribute(`data-ff-enabled-${t}`);
319
+ if (b[t] === false || (!bd && b[t] !== true))
320
+ root.setAttribute(`data-ff-badge-${t}`, "off");
321
+ else root.removeAttribute(`data-ff-badge-${t}`);
322
+ const col = cols[t] || C[t];
323
+ const vn = `--${N}-c-${t}`;
324
+ if (col) root.style.setProperty(vn, col);
325
+ else root.style.removeProperty(vn);
326
+ }
327
+
328
+ const applyAll = () => {
329
+ const map = new Set<HTMLElement>();
330
+ document
331
+ .querySelectorAll<HTMLElement>(`[data-${N}]`)
332
+ .forEach((el) => map.add(el));
333
+ for (let i = 0; i < T.length; i++) {
334
+ document
335
+ .querySelectorAll<HTMLElement>(`[data-${N}-${T[i]}]`)
336
+ .forEach((el) => map.add(el));
337
+ }
338
+ map.forEach((el) => applyCombo(el, cols));
339
+ };
340
+
341
+ const affAfterNav = () => {
342
+ if (!document.body) return;
343
+ let p = window.location?.pathname || "/";
344
+ if (!p.endsWith("/")) p += "/";
345
+ const toks = routeTokensForPath(p).filter((tk) => isEnabled(tk));
346
+ if (toks.length) {
347
+ const strong = toks.map((tk) => currentTokenColor(tk, cols));
348
+ const soft = strong.map((c) => `color-mix(in oklab, white 86%, ${c} 14%)`);
349
+ root.style.setProperty(`--${N}-route-gradient`, `linear-gradient(90deg, ${soft.join(", ")})`);
350
+ root.style.setProperty("--ff-route-outline-gradient", `linear-gradient(90deg, ${strong.join(", ")})`);
351
+ root.style.setProperty("--ff-route-badge-gradient", `linear-gradient(90deg, ${soft.join(", ")})`);
352
+ root.style.setProperty(
353
+ "--ff-route-badge-border",
354
+ `color-mix(in oklab, ${strong[0] || "#64748b"} 35%, transparent)`,
355
+ );
356
+ root.style.setProperty("--ff-route-outline", strong[0] || "#64748b");
357
+ root.style.setProperty("--ff-route-text", strong[0] || "#111827");
358
+ } else {
359
+ root.style.removeProperty(`--${N}-route-gradient`);
360
+ root.style.removeProperty("--ff-route-outline-gradient");
361
+ root.style.removeProperty("--ff-route-badge-gradient");
362
+ root.style.removeProperty("--ff-route-badge-border");
363
+ root.style.removeProperty("--ff-route-outline");
364
+ root.style.removeProperty("--ff-route-text");
365
+ }
366
+ applyAll();
367
+ affSyncRouteChrome(p);
368
+ };
369
+
370
+ affAfterNav();
371
+ if (document.readyState === "loading") {
372
+ document.addEventListener("DOMContentLoaded", affAfterNav, { once: true });
373
+ }
374
+ document.addEventListener("astro:page-load", affAfterNav);
375
+ document.addEventListener("astro:after-swap", affAfterNav);
376
+ if (typeof MutationObserver === "function") {
377
+ const obs = new MutationObserver((muts) => {
378
+ for (let i = 0; i < muts.length; i++) {
379
+ const a = muts[i]?.attributeName;
380
+ if (!a) continue;
381
+ if (
382
+ a === "style" ||
383
+ a.indexOf("data-ff-enabled-") === 0 ||
384
+ a.indexOf("data-ff-outline-") === 0 ||
385
+ a.indexOf("data-ff-badge-") === 0
386
+ ) {
387
+ affAfterNav();
388
+ break;
389
+ }
390
+ }
391
+ });
392
+ obs.observe(root, { attributes: true });
393
+ }
394
+ } catch {
395
+ // no-op in injected runtime
396
+ }
397
+ }