@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,516 @@
1
+ import type { DevToolbarApp } from "astro";
2
+ import { defineToolbarApp } from "astro/toolbar";
3
+ import {
4
+ closeOnOutsideClick,
5
+ createWindowElement,
6
+ synchronizePlacementOnUpdate,
7
+ } from "astro/runtime/client/dev-toolbar/apps/utils/window.js";
8
+ import { DEV_TOOLBAR_FLAG_ICON_SVG } from "./dev-toolbar-flag-icon";
9
+
10
+ declare global {
11
+ interface Window {
12
+ __AFF__?: {
13
+ tokens: string[];
14
+ colors?: Record<string, string>;
15
+ namespace?: string;
16
+ };
17
+ }
18
+ }
19
+
20
+ type AffState = {
21
+ enabled: Record<string, boolean>;
22
+ outline: Record<string, boolean>;
23
+ badge: Record<string, boolean>;
24
+ colors: Record<string, string>;
25
+ };
26
+
27
+ function toHexColor(raw: string | undefined, fallback: string): string {
28
+ if (!raw) return fallback;
29
+ const s = raw.trim();
30
+ const hex7 = s.match(/^#([0-9a-fA-F]{6})$/);
31
+ if (hex7) return `#${hex7[1]!.toLowerCase()}`;
32
+ const rgb = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
33
+ if (rgb) {
34
+ const r = Math.min(255, Math.round(Number(rgb[1])));
35
+ const g = Math.min(255, Math.round(Number(rgb[2])));
36
+ const b = Math.min(255, Math.round(Number(rgb[3])));
37
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
38
+ }
39
+ return fallback;
40
+ }
41
+
42
+ function readMeta(): {
43
+ tokens: string[];
44
+ colors: Record<string, string>;
45
+ namespace: string;
46
+ } {
47
+ try {
48
+ const raw = window.__AFF__;
49
+ const tokens = raw && Array.isArray(raw.tokens) ? raw.tokens : [];
50
+ const colors =
51
+ raw?.colors && typeof raw.colors === "object" ? raw.colors : {};
52
+ const namespace = raw?.namespace?.trim() ? raw.namespace.trim() : "ff";
53
+ return { tokens, colors, namespace };
54
+ } catch {
55
+ return { tokens: [], colors: {}, namespace: "ff" };
56
+ }
57
+ }
58
+
59
+ function storageKey(namespace: string): string {
60
+ return `${namespace}.dev.v1`;
61
+ }
62
+
63
+ function loadState(tokens: string[], namespace: string): AffState {
64
+ const next: AffState = {
65
+ enabled: {},
66
+ outline: {},
67
+ badge: {},
68
+ colors: {},
69
+ };
70
+ for (const t of tokens) {
71
+ next.enabled[t] = true;
72
+ next.outline[t] = true;
73
+ next.badge[t] = true;
74
+ }
75
+ try {
76
+ const raw =
77
+ localStorage.getItem(storageKey(namespace)) ||
78
+ localStorage.getItem("aff.dev.v1");
79
+ if (!raw) return next;
80
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
81
+ const outline = (parsed.outline ?? parsed.chrome) as
82
+ | Record<string, boolean>
83
+ | undefined;
84
+ const enabled = (parsed.enabled ?? parsed.render) as
85
+ | Record<string, boolean>
86
+ | undefined;
87
+ const badge = parsed.badge as Record<string, boolean> | undefined;
88
+ const colors = parsed.colors as Record<string, string> | undefined;
89
+ if (outline) Object.assign(next.outline, outline);
90
+ if (enabled) Object.assign(next.enabled, enabled);
91
+ if (badge) Object.assign(next.badge, badge);
92
+ if (colors) Object.assign(next.colors, colors);
93
+ } catch {
94
+ /* ignore */
95
+ }
96
+ return next;
97
+ }
98
+
99
+ function persist(state: AffState, namespace: string) {
100
+ try {
101
+ localStorage.setItem(storageKey(namespace), JSON.stringify(state));
102
+ } catch {
103
+ /* ignore */
104
+ }
105
+ }
106
+
107
+ function applyDom(
108
+ tokens: string[],
109
+ state: AffState,
110
+ defaults: Record<string, string>,
111
+ namespace: string,
112
+ ) {
113
+ const root = document.documentElement;
114
+ for (const t of tokens) {
115
+ if (state.outline[t] !== false)
116
+ root.removeAttribute(`data-ff-outline-${t}`);
117
+ else root.setAttribute(`data-ff-outline-${t}`, "off");
118
+
119
+ if (state.enabled[t] !== false)
120
+ root.removeAttribute(`data-ff-enabled-${t}`);
121
+ else root.setAttribute(`data-ff-enabled-${t}`, "off");
122
+
123
+ if (state.badge[t] !== false) root.removeAttribute(`data-ff-badge-${t}`);
124
+ else root.setAttribute(`data-ff-badge-${t}`, "off");
125
+
126
+ const col = state.colors[t] ?? defaults[t];
127
+ if (col) root.style.setProperty(`--${namespace}-c-${t}`, col);
128
+ else root.style.removeProperty(`--${namespace}-c-${t}`);
129
+ }
130
+ }
131
+
132
+ const affDevToolbarApp: DevToolbarApp = defineToolbarApp({
133
+ init(canvas, eventTarget) {
134
+ let state = loadState([], "ff");
135
+ let tokens: string[] = [];
136
+ let namespace = "ff";
137
+
138
+ function mount() {
139
+ canvas.querySelector("astro-dev-toolbar-window")?.remove();
140
+ const {
141
+ tokens: nextTokens,
142
+ colors: defaultColors,
143
+ namespace: ns,
144
+ } = readMeta();
145
+ tokens = nextTokens;
146
+ namespace = ns;
147
+ state = loadState(tokens, namespace);
148
+ applyDom(tokens, state, defaultColors, namespace);
149
+
150
+ const windowElement = createWindowElement(`
151
+ <style>
152
+ :host astro-dev-toolbar-window {
153
+ height: min(520px, 78vh);
154
+ overflow-y: auto;
155
+ color-scheme: dark;
156
+ --color-muted: rgba(191, 193, 201, 1);
157
+ --color-purple: rgba(224, 204, 250, 1);
158
+ }
159
+ header {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: space-between;
163
+ gap: 12px;
164
+ }
165
+ h1 {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ font-weight: 600;
170
+ color: #fff;
171
+ margin: 0;
172
+ font-size: 22px;
173
+ }
174
+ .aff-flag-toolbar-icon {
175
+ display: inline-flex;
176
+ width: 1em;
177
+ height: 1em;
178
+ flex-shrink: 0;
179
+ align-items: center;
180
+ justify-content: center;
181
+ }
182
+ .aff-flag-toolbar-icon svg {
183
+ width: 100%;
184
+ height: 100%;
185
+ display: block;
186
+ }
187
+ .aff-intro {
188
+ margin: 0;
189
+ font-size: 14px;
190
+ line-height: 1.5rem;
191
+ color: var(--color-muted);
192
+ margin-bottom: 1rem;
193
+ }
194
+ .aff-top {
195
+ display: flex;
196
+ align-items: flex-start;
197
+ justify-content: space-between;
198
+ gap: 12px;
199
+ margin: 1rem 0;
200
+ }
201
+ .aff-top-left {
202
+ flex: 1;
203
+ min-width: 0;
204
+ }
205
+ .aff-help {
206
+ display: grid;
207
+ grid-template-columns: minmax(7.5rem, auto) 1fr;
208
+ gap: 0.45rem 0.75rem;
209
+ }
210
+ .aff-help code,
211
+ .aff-help span {
212
+ font-size: 12px;
213
+ line-height: 1.35;
214
+ }
215
+ .aff-help code {
216
+ justify-self: start;
217
+ }
218
+ .aff-actions {
219
+ display: flex;
220
+ justify-content: flex-end;
221
+ margin: 0;
222
+ flex-shrink: 0;
223
+ }
224
+ .aff-reset {
225
+ border: 1px solid #343841;
226
+ border-radius: 0.5rem;
227
+ background: #24262d;
228
+ color: #fff;
229
+ padding: 0.3rem 0.55rem;
230
+ font-size: 12px;
231
+ cursor: pointer;
232
+ }
233
+ hr {
234
+ border: 1px solid rgba(27, 30, 36, 1);
235
+ margin: 1em 0;
236
+ }
237
+ .setting-row {
238
+ display: flex;
239
+ justify-content: space-between;
240
+ align-items: center;
241
+ gap: 12px;
242
+ margin: 0 0 8px;
243
+ }
244
+ .setting-row section { flex: 1; min-width: 0; max-width: 54%; }
245
+ h3.aff-row-title {
246
+ font-size: 12px;
247
+ font-weight: 600;
248
+ color: #fff;
249
+ letter-spacing: 0.05em;
250
+ text-transform: uppercase;
251
+ margin: 0;
252
+ }
253
+ .aff-radios {
254
+ display: flex;
255
+ gap: 8px;
256
+ align-items: center;
257
+ flex-shrink: 0;
258
+ }
259
+ .aff-radios label {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ gap: 4px;
263
+ font-size: 11px;
264
+ cursor: pointer;
265
+ color: var(--color-muted);
266
+ letter-spacing: 0.04em;
267
+ text-transform: uppercase;
268
+ }
269
+ .aff-radios input { accent-color: var(--color-purple); cursor: pointer; }
270
+ .aff-flag {
271
+ margin: 0 0 12px;
272
+ border: 1px solid rgba(52, 56, 65, 1);
273
+ border-radius: 10px;
274
+ padding: 0 10px 10px;
275
+ background: rgba(19, 21, 26, 0.55);
276
+ }
277
+ .aff-flag summary {
278
+ list-style: none;
279
+ cursor: pointer;
280
+ user-select: none;
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 8px;
284
+ padding: 10px 0;
285
+ font-weight: 600;
286
+ font-size: 11px;
287
+ letter-spacing: 0.06em;
288
+ text-transform: uppercase;
289
+ color: #fff;
290
+ justify-content: space-between;
291
+ }
292
+ .aff-flag summary::-webkit-details-marker { display: none; }
293
+ .aff-flag summary::after {
294
+ content: '▸';
295
+ display: inline-flex;
296
+ align-items: center;
297
+ justify-content: center;
298
+ margin-left: auto;
299
+ color: var(--color-muted);
300
+ font-size: 12px;
301
+ transform: rotate(0deg);
302
+ transition: transform 0.12s ease;
303
+ }
304
+ .aff-flag[open] summary::after {
305
+ transform: rotate(90deg);
306
+ }
307
+ .aff-flag-summary-left {
308
+ display: inline-flex;
309
+ align-items: center;
310
+ gap: 8px;
311
+ }
312
+ .aff-swatch {
313
+ width: 10px;
314
+ height: 10px;
315
+ border-radius: 9999px;
316
+ flex-shrink: 0;
317
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12);
318
+ }
319
+ .aff-flag-body { padding-top: 2px; }
320
+ .aff-empty {
321
+ margin: 0;
322
+ font-size: 14px;
323
+ line-height: 1.5rem;
324
+ color: var(--color-muted);
325
+ }
326
+ code {
327
+ color: var(--color-purple);
328
+ border: 1px solid #343841;
329
+ border-radius: 0.35em;
330
+ background-color: #24262d;
331
+ padding: 0.15em 0.35em;
332
+ font-size: 0.9em;
333
+ }
334
+ input[type="color"].aff-color {
335
+ width: 2.25rem;
336
+ height: 1.75rem;
337
+ padding: 0;
338
+ border: 1px solid #343841;
339
+ border-radius: 6px;
340
+ cursor: pointer;
341
+ background: transparent;
342
+ }
343
+ </style>
344
+ <header>
345
+ <h1><span class="aff-flag-toolbar-icon">${DEV_TOOLBAR_FLAG_ICON_SVG}</span> Feature flags</h1>
346
+ <div class="aff-actions"><button id="aff-reset" class="aff-reset" type="button" title="Reset all flags to defaults">Reset all</button></div>
347
+ </header>
348
+ <div class="aff-top">
349
+ <div class="aff-top-left">
350
+ <p class="aff-intro">Per-flag preview for <code>data-${namespace}</code> regions.</p>
351
+ <div class="aff-help">
352
+ <code>Enabled</code><span>Show/hide flagged regions in preview.</span>
353
+ <code>Outline</code><span>Toggle flag outlines without changing layout.</span>
354
+ <code>Badges</code><span>Toggle element and route labels.</span>
355
+ <code>Colour</code><span>Set <code>--${namespace}-c-&lt;token&gt;</code> on <code>&lt;html&gt;</code>.</span>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ <hr />
360
+ <div id="aff-body"></div>
361
+ `);
362
+
363
+ const body = windowElement.querySelector("#aff-body");
364
+ const resetBtn = windowElement.querySelector(
365
+ "#aff-reset",
366
+ ) as HTMLButtonElement | null;
367
+
368
+ if (body) {
369
+ if (tokens.length === 0) {
370
+ const p = document.createElement("p");
371
+ p.className = "aff-empty";
372
+ p.textContent = "No flags in ff.json.";
373
+ body.append(p);
374
+ } else {
375
+ for (const token of tokens) {
376
+ const details = document.createElement("details");
377
+ details.className = "aff-flag";
378
+ details.open = tokens.length <= 8;
379
+
380
+ const summary = document.createElement("summary");
381
+ const summaryLeft = document.createElement("span");
382
+ summaryLeft.className = "aff-flag-summary-left";
383
+ const swatch = document.createElement("span");
384
+ swatch.className = "aff-swatch";
385
+ const name = document.createElement("span");
386
+ name.textContent = token;
387
+ summaryLeft.append(swatch, name);
388
+ summary.append(summaryLeft);
389
+
390
+ const syncSwatch = () => {
391
+ const c =
392
+ state.colors[token] ?? defaultColors[token] ?? "#888888";
393
+ swatch.style.background = c;
394
+ };
395
+ syncSwatch();
396
+
397
+ const inner = document.createElement("div");
398
+ inner.className = "aff-flag-body";
399
+
400
+ const row = (
401
+ title: string,
402
+ tooltip: string,
403
+ key: keyof Pick<AffState, "enabled" | "outline" | "badge">,
404
+ onLab: string,
405
+ offLab: string,
406
+ ) => {
407
+ const label = document.createElement("label");
408
+ label.className = "setting-row";
409
+ const section = document.createElement("section");
410
+ const h = document.createElement("h3");
411
+ h.className = "aff-row-title";
412
+ h.textContent = title;
413
+ section.append(h);
414
+ const radios = document.createElement("div");
415
+ radios.className = "aff-radios";
416
+ const mk = (on: boolean, lab: string) => {
417
+ const wrap = document.createElement("label");
418
+ const inp = document.createElement("input");
419
+ inp.type = "radio";
420
+ inp.name = `aff-${token}-${key}`;
421
+ const cur = state[key][token] !== false;
422
+ inp.checked = on ? cur : !cur;
423
+ inp.addEventListener("change", () => {
424
+ if (!inp.checked) return;
425
+ state[key][token] = on;
426
+ applyDom(tokens, state, defaultColors, namespace);
427
+ persist(state, namespace);
428
+ });
429
+ wrap.append(inp, document.createTextNode(lab));
430
+ return wrap;
431
+ };
432
+ radios.append(mk(true, onLab), mk(false, offLab));
433
+ label.append(section, radios);
434
+ return label;
435
+ };
436
+
437
+ inner.append(
438
+ row(
439
+ "Enabled",
440
+ "When off, nodes with this flag token are hidden in preview.",
441
+ "enabled",
442
+ "On",
443
+ "Off",
444
+ ),
445
+ row(
446
+ "Outline",
447
+ "Toggles dashed outline around flagged elements.",
448
+ "outline",
449
+ "On",
450
+ "Off",
451
+ ),
452
+ row(
453
+ "Badges",
454
+ "Toggles element badges and top-right route pill.",
455
+ "badge",
456
+ "On",
457
+ "Off",
458
+ ),
459
+ );
460
+
461
+ const colorRow = document.createElement("label");
462
+ colorRow.className = "setting-row";
463
+ const csection = document.createElement("section");
464
+ const ch = document.createElement("h3");
465
+ ch.className = "aff-row-title";
466
+ ch.textContent = "Colour";
467
+ ch.title = `Maps to CSS --${namespace}-c-<token> on <html>.`;
468
+ csection.append(ch);
469
+ const cinp = document.createElement("input");
470
+ cinp.type = "color";
471
+ cinp.className = "aff-color";
472
+ cinp.title = `Set --${namespace}-c-${token}`;
473
+ cinp.value = toHexColor(
474
+ state.colors[token] ?? defaultColors[token],
475
+ "#888888",
476
+ );
477
+ cinp.addEventListener("input", () => {
478
+ state.colors[token] = cinp.value;
479
+ syncSwatch();
480
+ applyDom(tokens, state, defaultColors, namespace);
481
+ persist(state, namespace);
482
+ });
483
+ colorRow.append(csection, cinp);
484
+ inner.append(colorRow);
485
+
486
+ details.append(summary, inner);
487
+ body.append(details);
488
+ }
489
+ }
490
+ }
491
+
492
+ if (resetBtn) {
493
+ resetBtn.addEventListener("click", () => {
494
+ try {
495
+ localStorage.removeItem(storageKey(namespace));
496
+ localStorage.removeItem("aff.dev.v1");
497
+ } catch {
498
+ // ignore
499
+ }
500
+ state = loadState(tokens, namespace);
501
+ applyDom(tokens, state, defaultColors, namespace);
502
+ mount();
503
+ });
504
+ }
505
+
506
+ canvas.append(windowElement);
507
+ }
508
+
509
+ mount();
510
+ document.addEventListener("astro:after-swap", mount);
511
+ closeOnOutsideClick(eventTarget);
512
+ synchronizePlacementOnUpdate(eventTarget, canvas);
513
+ },
514
+ });
515
+
516
+ export default affDevToolbarApp;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Inline SVG for the dev toolbar app button and panel header (Heroicons-style flag, 20×20 in a 22×22 box).
3
+ * `currentColor` matches built-in Astro toolbar icons.
4
+ */
5
+ export const DEV_TOOLBAR_FLAG_ICON_SVG =
6
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" fill="currentColor" aria-hidden="true">' +
7
+ '<path d="M4.25 3.25v15.5a.75.75 0 1 1-1.5 0V3.25a.75.75 0 0 1 1.5 0Z"/>' +
8
+ '<path d="M6.5 4.75v9.2a1 1 0 0 0 1.5.87l7.5-4.6a1 1 0 0 0 0-1.74l-7.5-4.6a1 1 0 0 0-1.5.87Z"/>' +
9
+ "</svg>";