@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,2 @@
1
+ /// <reference types="astro/client" />
2
+ /// <reference path="../../virtual-astro-feature-flags.d.ts" />
@@ -0,0 +1,21 @@
1
+ ---
2
+ import '../styles/global.css';
3
+
4
+ interface Props {
5
+ title?: string;
6
+ }
7
+
8
+ const { title = 'Feature flags example' } = Astro.props;
9
+ ---
10
+
11
+ <!doctype html>
12
+ <html lang='en'>
13
+ <head>
14
+ <meta charset='utf-8' />
15
+ <meta name='viewport' content='width=device-width, initial-scale=1' />
16
+ <title>{title}</title>
17
+ </head>
18
+ <body class='min-h-screen bg-slate-100 text-slate-900 antialiased'>
19
+ <slot />
20
+ </body>
21
+ </html>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import Layout from '../../layouts/Layout.astro';
3
+ import { FeatureToken } from 'virtual:astro-feature-flags';
4
+ ---
5
+
6
+ <Layout title='Hot Feature 2'>
7
+ <main class='ff-shell'>
8
+ <h1 class='ff-title'>Hot Feature 2</h1>
9
+ <p class='ff-sub'>This route is mapped to <span class='ff-code'>hotFeature2</span>.</p>
10
+
11
+ <div data-ff={FeatureToken.HotFeature2} class='ff-card'>
12
+ Imported token usage: <span class='ff-code'>data-ff=&#123;FeatureToken.HotFeature2&#125;</span>.
13
+ </div>
14
+
15
+ <div data-ff-hot-feature-2 class='ff-card'>
16
+ Shorthand usage: <span class='ff-code'>data-ff-hot-feature-2</span>.
17
+ </div>
18
+
19
+ <p class='mt-6'>
20
+ <a class='ff-link' href={`${import.meta.env.BASE_URL}`}>← Home</a>
21
+ </p>
22
+ </main>
23
+ </Layout>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import Layout from '../../layouts/Layout.astro';
3
+ ---
4
+
5
+ <Layout title='Hot Dev'>
6
+ <main class='ff-shell'>
7
+ <h1 class='ff-title'>Hot Dev Route</h1>
8
+ <p class='ff-sub'>
9
+ This route matches <code>/hot-dev/*</code>. Use it to verify wildcard route badges and nested pages.
10
+ </p>
11
+ <p class='mt-4'>
12
+ <a class='ff-link' href={`${import.meta.env.BASE_URL}hot-dev/sub/`}>Open nested sub page →</a>
13
+ </p>
14
+ <p class='mt-6'>
15
+ <a class='ff-link' href={`${import.meta.env.BASE_URL}`}>← Home</a>
16
+ </p>
17
+ </main>
18
+ </Layout>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import Layout from '../../../layouts/Layout.astro';
3
+ ---
4
+
5
+ <Layout title='Hot Dev Sub Page'>
6
+ <main class='ff-shell'>
7
+ <h1 class='ff-title'>Hot Dev Nested Page</h1>
8
+ <p class='ff-sub'>
9
+ This is a nested route under <code>/hot-dev/*</code>. The route badge is injected automatically via wildcard matching.
10
+ </p>
11
+
12
+ <div data-ff='wip hot-feature-2' class='ff-card'>
13
+ Combined flags (space-separated): <span class='ff-code'>data-ff="wip hot-feature-2"</span>.
14
+ This block is visible only when <strong>both</strong> <code>wip</code> and <code>hotFeature2</code> are enabled (outside the reserved <code>dev</code> environment layer, both must be on in config).
15
+ </div>
16
+
17
+ <p class='mt-6'>
18
+ <a class='ff-link' href={`${import.meta.env.BASE_URL}hot-dev/`}>← Hot Dev</a>
19
+ </p>
20
+ </main>
21
+ </Layout>
@@ -0,0 +1,38 @@
1
+ ---
2
+ import Layout from '../../layouts/Layout.astro';
3
+ import { FeatureToken } from 'virtual:astro-feature-flags';
4
+ ---
5
+
6
+ <Layout title='Hot Feature 1'>
7
+ <main class='ff-shell'>
8
+ <h1 class='ff-title'>Hot Feature 1</h1>
9
+ <p class='ff-sub'>This page is mapped to <span class='ff-code'>hotFeature1</span> route rules.</p>
10
+
11
+ <div data-ff={FeatureToken.HotFeature1} class='ff-card'>
12
+ Imported token usage: <span class='ff-code'>data-ff=&#123;FeatureToken.HotFeature1&#125;</span>.
13
+ </div>
14
+
15
+ <div data-ff-hot-feature-1 class='ff-card'>
16
+ Shorthand usage: <span class='ff-code'>data-ff-hot-feature-1</span>.
17
+ </div>
18
+
19
+ <div
20
+ data-ff-hot-feature-1
21
+ data-ff-align='center'
22
+ data-ff-horizontal='25'
23
+ data-ff-vertical='110'
24
+ data-ff-anchor='bottom'
25
+ class='ff-card'
26
+ >
27
+ Badge positioning overrides:
28
+ <span class='ff-code'>data-ff-align="center"</span>,
29
+ <span class='ff-code'>data-ff-horizontal="25"</span>,
30
+ <span class='ff-code'>data-ff-vertical="110"</span>,
31
+ <span class='ff-code'>data-ff-anchor="bottom"</span>.
32
+ </div>
33
+
34
+ <p class='mt-6'>
35
+ <a class='ff-link' href={`${import.meta.env.BASE_URL}`}>← Home</a>
36
+ </p>
37
+ </main>
38
+ </Layout>
@@ -0,0 +1,102 @@
1
+ ---
2
+ import Layout from "../layouts/Layout.astro";
3
+ import { FeatureToken } from "virtual:astro-feature-flags";
4
+ import { Code } from "astro:components";
5
+ ---
6
+
7
+ <Layout title="Home">
8
+ <main class="ff-shell">
9
+ <h1 class="ff-title">Astro Feature Flags Integration Example</h1>
10
+ <p class="ff-sub">
11
+ The integration is configured in <span class="ff-code"
12
+ >astro.config.mjs</span
13
+ >. Flags declare colours and route patterns; you declare <strong>non-dev</strong>
14
+ environments only.
15
+ <br/>
16
+ Disabled flags will either be omitted or hidden in production builds.
17
+ </p>
18
+
19
+ <div class="ff-card overflow-x-auto text-xs leading-5 mt-4 mb-2">
20
+ <Code
21
+ code={`// astro.config.mjs
22
+ import astroFeatureFlags from '@at-flux/astro-feature-flags';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ export default defineConfig({
26
+ integrations: [
27
+ astroFeatureFlags({
28
+ configRoot: fileURLToPath(new URL('.', import.meta.url)),
29
+ jsonConfigPath: 'ff.json',
30
+ environments: {
31
+ prod: { when: process.env.NODE_ENV === 'production', flags: { /* … */ } },
32
+ },
33
+ })
34
+ ]
35
+ });`}
36
+ lang="js"
37
+ />
38
+ </div>
39
+
40
+ <h2 class="mt-6 text-lg font-semibold text-slate-900">Element tagging</h2>
41
+
42
+ <h3 class="text-base font-semibold text-slate-900 mt-2">WIP (work in progress)</h3>
43
+ <div data-ff={FeatureToken.Wip} class="ff-card">
44
+ Imported token: <span class="ff-code"
45
+ >data-ff=&#123;FeatureToken.Wip&#125;</span>
46
+ </div>
47
+ <div data-ff-wip class="ff-card">
48
+ Shorthand: <span class="ff-code">data-ff-wip</span> <strong>(no import)</strong>
49
+ </div>
50
+
51
+ <h3 class="text-base font-semibold text-slate-900 mt-4">Hot Feature 1</h3>
52
+ <div data-ff={FeatureToken.HotFeature1} class="ff-card">
53
+ Imported token: <span class="ff-code"
54
+ >data-ff=&#123;FeatureToken.HotFeature1&#125;</span>
55
+ </div>
56
+ <div data-ff-hot-feature-1 class="ff-card">
57
+ Shorthand: <span class="ff-code">data-ff-hot-feature-1</span>
58
+ </div>
59
+
60
+ <h3 class="text-base font-semibold text-slate-900 mt-4">Hot Feature 2</h3>
61
+ <div data-ff-hot-feature-2 class="ff-card">
62
+ Shorthand: <span class="ff-code">data-ff-hot-feature-2</span>
63
+ </div>
64
+
65
+ <h3 class="text-base font-semibold text-slate-900 mt-4">Combinations</h3>
66
+ <div data-ff="hot-feature-1 hot-feature-2" class="ff-card">
67
+ Shorthand v1: <span class="ff-code"
68
+ >data-ff="hot-feature-1 hot-feature-2"</span>
69
+ </div>
70
+ <div data-ff-hot-feature-1 data-ff-hot-feature-2 class="ff-card">
71
+ Shorthand v2: <span class="ff-code"
72
+ >data-ff-hot-feature-1 data-ff-hot-feature-2</span>
73
+ </div>
74
+ <div data-ff={[FeatureToken.Wip, FeatureToken.HotFeature1, FeatureToken.HotFeature2].join(' ')} class="ff-card">
75
+ A combination of all 3: <span class="ff-code"
76
+ >data-ff={[FeatureToken.Wip, FeatureToken.HotFeature1, FeatureToken.HotFeature2].join(' ')}</span>
77
+ </div>
78
+
79
+ <h2 class="mt-6 text-lg font-semibold text-slate-900">Route examples</h2>
80
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-sm text-slate-700">
81
+ <li>
82
+ <a class="ff-link" href={`${import.meta.env.BASE_URL}hot-feature-1/`}
83
+ >Hot Feature 1 route</a>
84
+ </li>
85
+ <li>
86
+ <a class="ff-link" href={`${import.meta.env.BASE_URL}hot/`}
87
+ >Hot Feature 2 route</a>
88
+ </li>
89
+ <li>
90
+ <a class="ff-link" href={`${import.meta.env.BASE_URL}hot-dev/sub/`}
91
+ >Hot Dev nested route (wip + hotFeature2)</a>
92
+ </li>
93
+ </ul>
94
+ <p
95
+ class="mt-6 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
96
+ >
97
+ If route badges disappear, open the toolbar and click <strong
98
+ >Reset all</strong
99
+ >.
100
+ </p>
101
+ </main>
102
+ </Layout>
@@ -0,0 +1,22 @@
1
+ @import "tailwindcss";
2
+
3
+ @layer components {
4
+ .ff-shell {
5
+ @apply mx-auto max-w-3xl px-6 py-8;
6
+ }
7
+ .ff-title {
8
+ @apply mt-0 mb-4 text-3xl font-semibold tracking-tight text-slate-900;
9
+ }
10
+ .ff-sub {
11
+ @apply text-sm leading-6 text-slate-600;
12
+ }
13
+ .ff-card {
14
+ @apply mt-3 rounded-xl border border-slate-200 bg-slate-50 p-4 text-slate-900 shadow-sm;
15
+ }
16
+ .ff-code {
17
+ @apply rounded bg-slate-100 px-1 py-0.5 font-mono text-xs text-slate-800;
18
+ }
19
+ .ff-link {
20
+ @apply text-indigo-700 underline decoration-indigo-300 underline-offset-2 hover:text-indigo-900;
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "include": [".astro/types.d.ts", "**/*"]
4
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@at-flux/astro-feature-flags",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Typed Astro feature flags from JSON config. Includes environment specific rendering and pruning from elements to entire routes",
6
+ "author": "atflux <dev@atflux.uk>",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "astro",
10
+ "astro-integration",
11
+ "feature-flags",
12
+ "typescript",
13
+ "devtools",
14
+ "tooling"
15
+ ],
16
+ "main": "./dist/index.mjs",
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "docs",
21
+ "example",
22
+ "virtual-astro-feature-flags.d.ts"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.mts",
27
+ "default": "./dist/index.mjs"
28
+ },
29
+ "./runtime": {
30
+ "types": "./dist/runtime.d.mts",
31
+ "default": "./dist/runtime.mjs"
32
+ },
33
+ "./virtual-astro-feature-flags": {
34
+ "types": "./virtual-astro-feature-flags.d.ts"
35
+ }
36
+ },
37
+ "peerDependencies": {
38
+ "astro": "^4.7.0 || ^5.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.7.0",
42
+ "astro": "^5.18.0",
43
+ "tsdown": "^0.21.7",
44
+ "typescript": "^5.9.3",
45
+ "vitest": "^3.2.4"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/at-flux/astroflare.git",
50
+ "directory": "packages/astro-feature-flags"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/at-flux/astroflare/issues"
54
+ },
55
+ "homepage": "https://github.com/at-flux/astroflare/tree/main/packages/astro-feature-flags",
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "dependencies": {
60
+ "node-html-parser": "^7.1.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsdown",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
67
+ }
68
+ }
@@ -0,0 +1,146 @@
1
+ export type ElementBadgeVerticalAnchor = "top" | "bottom";
2
+
3
+ /** Horizontal placement of the element pill: `end` = top-right (LTR), `start` = top-left, `center` = centred. */
4
+ export type ElementBadgeHorizontalAlign = "start" | "center" | "end";
5
+
6
+ /** Subset of dev CSS options used for element-badge geometry (also on `DevOutlineCssOptions`). */
7
+ export interface ElementBadgeLayoutOptions {
8
+ /**
9
+ * Horizontal alignment along the element’s top or bottom edge. Default `end` (top-right in LTR).
10
+ * Ignored when `elementBadgeHorizontalPercent` is set.
11
+ */
12
+ elementBadgeHorizontalAlign?: ElementBadgeHorizontalAlign;
13
+ /**
14
+ * Optional: anchor at a percentage along the element width (0–100), with the pill centred on that point (`translateX(-50%)`).
15
+ * When set, overrides `elementBadgeHorizontalAlign`.
16
+ */
17
+ elementBadgeHorizontalPercent?: number;
18
+ /**
19
+ * Vertical shift as a percentage of the **badge’s own height** (CSS transform %).
20
+ * With anchor `top`, moves the badge upward. Default `80` (~20% of the pill overlaps the element top edge).
21
+ */
22
+ elementBadgeVerticalShiftPercent?: number;
23
+ /** Anchor the badge to the top or bottom edge of the element. Default `top`. */
24
+ elementBadgeVerticalAnchor?: ElementBadgeVerticalAnchor;
25
+ }
26
+
27
+ export type NormalizedElementBadgeLayout =
28
+ | {
29
+ mode: "align";
30
+ horizontalAlign: ElementBadgeHorizontalAlign;
31
+ verticalShiftPercent: number;
32
+ verticalAnchor: ElementBadgeVerticalAnchor;
33
+ }
34
+ | {
35
+ mode: "percent";
36
+ horizontalPercent: number;
37
+ verticalShiftPercent: number;
38
+ verticalAnchor: ElementBadgeVerticalAnchor;
39
+ };
40
+
41
+ function clampPercent(n: number, fallback: number): number {
42
+ if (typeof n !== "number" || Number.isNaN(n)) return fallback;
43
+ return Math.min(100, Math.max(0, n));
44
+ }
45
+
46
+ export function normalizeElementBadgeLayout(
47
+ input: ElementBadgeLayoutOptions | undefined,
48
+ ): NormalizedElementBadgeLayout {
49
+ const v = clampPercent(input?.elementBadgeVerticalShiftPercent ?? 80, 80);
50
+ const anchor =
51
+ input?.elementBadgeVerticalAnchor === "bottom" ? "bottom" : "top";
52
+ const hp = input?.elementBadgeHorizontalPercent;
53
+ if (hp !== undefined && hp !== null && typeof hp === "number") {
54
+ return {
55
+ mode: "percent",
56
+ horizontalPercent: clampPercent(hp, 50),
57
+ verticalShiftPercent: v,
58
+ verticalAnchor: anchor,
59
+ };
60
+ }
61
+ const align: ElementBadgeHorizontalAlign =
62
+ input?.elementBadgeHorizontalAlign === "start" ||
63
+ input?.elementBadgeHorizontalAlign === "center"
64
+ ? input.elementBadgeHorizontalAlign
65
+ : "end";
66
+ return {
67
+ mode: "align",
68
+ horizontalAlign: align,
69
+ verticalShiftPercent: v,
70
+ verticalAnchor: anchor,
71
+ };
72
+ }
73
+
74
+ const INSET = "0.35rem";
75
+
76
+ /**
77
+ * `left` / `right` / `top` | `bottom` / `transform` for the element `::before` badge (no trailing newline).
78
+ */
79
+ export function elementBadgePositionBlock(
80
+ layout: NormalizedElementBadgeLayout,
81
+ ): string {
82
+ const { verticalShiftPercent: v, verticalAnchor } = layout;
83
+ const y =
84
+ verticalAnchor === "top"
85
+ ? `translateY(calc(-1 * ${v}%))`
86
+ : `translateY(${v}%)`;
87
+
88
+ if (layout.mode === "percent") {
89
+ const h = layout.horizontalPercent;
90
+ if (verticalAnchor === "top") {
91
+ return ` left: ${h}%;
92
+ right: auto;
93
+ top: 0;
94
+ bottom: auto;
95
+ transform: translateX(-50%) ${y};`;
96
+ }
97
+ return ` left: ${h}%;
98
+ right: auto;
99
+ bottom: 0;
100
+ top: auto;
101
+ transform: translateX(-50%) ${y};`;
102
+ }
103
+
104
+ const { horizontalAlign } = layout;
105
+ if (horizontalAlign === "center") {
106
+ if (verticalAnchor === "top") {
107
+ return ` left: 50%;
108
+ right: auto;
109
+ top: 0;
110
+ bottom: auto;
111
+ transform: translateX(-50%) ${y};`;
112
+ }
113
+ return ` left: 50%;
114
+ right: auto;
115
+ bottom: 0;
116
+ top: auto;
117
+ transform: translateX(-50%) ${y};`;
118
+ }
119
+ if (horizontalAlign === "start") {
120
+ if (verticalAnchor === "top") {
121
+ return ` left: ${INSET};
122
+ right: auto;
123
+ top: 0;
124
+ bottom: auto;
125
+ transform: ${y};`;
126
+ }
127
+ return ` left: ${INSET};
128
+ right: auto;
129
+ bottom: 0;
130
+ top: auto;
131
+ transform: ${y};`;
132
+ }
133
+ /* end — top-right / bottom-right */
134
+ if (verticalAnchor === "top") {
135
+ return ` left: auto;
136
+ right: ${INSET};
137
+ top: 0;
138
+ bottom: auto;
139
+ transform: ${y};`;
140
+ }
141
+ return ` left: auto;
142
+ right: ${INSET};
143
+ bottom: 0;
144
+ top: auto;
145
+ transform: ${y};`;
146
+ }
@@ -0,0 +1,26 @@
1
+ import type { ResolvedFeatureRuntime } from "./runtime";
2
+ import { toToken } from "./runtime";
3
+ import { inlineInvoke } from "./inline-script";
4
+ import { affHeadInlineRuntime } from "./dev-inline-runtimes";
5
+
6
+ /**
7
+ * Single `injectScript('head-inline', …)` payload for `astro dev`: dev-only outline CSS,
8
+ * `data-ff-route` on `<html>` (pathname + {@link ResolvedFeatureRuntime.routeFlags}), then
9
+ * the feature-flag dev bootstrap (toolbar state, combo badges, route chrome).
10
+ */
11
+ export function buildAffDevHeadInline(args: {
12
+ runtime: ResolvedFeatureRuntime;
13
+ featureFlagStyles: string;
14
+ affDevBootstrap: string;
15
+ }): string {
16
+ const { runtime, featureFlagStyles, affDevBootstrap } = args;
17
+ const flagNameToToken = Object.fromEntries(
18
+ Object.keys(runtime.flags).map((name) => [name, toToken(name)]),
19
+ );
20
+ const setup = inlineInvoke(affHeadInlineRuntime, {
21
+ featureFlagStyles,
22
+ routeFlags: runtime.routeFlags,
23
+ flagNameToToken,
24
+ });
25
+ return `${setup}\n${affDevBootstrap}`;
26
+ }