@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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/dev-toolbar-app.d.mts +16 -0
- package/dist/dev-toolbar-app.d.mts.map +1 -0
- package/dist/dev-toolbar-app.mjs +407 -0
- package/dist/dev-toolbar-app.mjs.map +1 -0
- package/dist/dev-toolbar-flag-icon-BHCQJ53N.mjs +10 -0
- package/dist/dev-toolbar-flag-icon-BHCQJ53N.mjs.map +1 -0
- package/dist/index.d.mts +103 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1107 -0
- package/dist/index.mjs.map +1 -0
- package/dist/runtime.d.mts +115 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +280 -0
- package/dist/runtime.mjs.map +1 -0
- package/docs/assets/element-gating.png +0 -0
- package/docs/assets/page-gating.png +0 -0
- package/docs/assets/toolbar-feature-flags.png +0 -0
- package/docs/how-to/hide-from-sitemaps.md +49 -0
- package/docs/testing.md +40 -0
- package/example/README.md +18 -0
- package/example/astro.config.mjs +26 -0
- package/example/ff.json +26 -0
- package/example/package.json +22 -0
- package/example/pnpm-lock.yaml +4022 -0
- package/example/src/env.d.ts +2 -0
- package/example/src/layouts/Layout.astro +21 -0
- package/example/src/pages/hot/index.astro +23 -0
- package/example/src/pages/hot-dev/index.astro +18 -0
- package/example/src/pages/hot-dev/sub/index.astro +21 -0
- package/example/src/pages/hot-feature-1/index.astro +38 -0
- package/example/src/pages/index.astro +102 -0
- package/example/src/styles/global.css +22 -0
- package/example/tsconfig.json +4 -0
- package/package.json +68 -0
- package/src/badge-layout.ts +146 -0
- package/src/dev-head-inject.ts +26 -0
- package/src/dev-inline-runtimes.ts +397 -0
- package/src/dev-outline-css.ts +449 -0
- package/src/dev-toolbar-app.ts +516 -0
- package/src/dev-toolbar-flag-icon.ts +9 -0
- package/src/index.ts +417 -0
- package/src/inline-script.ts +14 -0
- package/src/production-html-cull.ts +133 -0
- package/src/route-prefix-js.ts +7 -0
- package/src/runtime.ts +575 -0
- package/virtual-astro-feature-flags.d.ts +67 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type FeatureFlagMap = Record<string, boolean>;
|
|
5
|
+
export type FeatureRouteMap = Record<string, string[]>;
|
|
6
|
+
export type FeatureColorMap = Record<string, string>;
|
|
7
|
+
export type FeatureBoolByTokenMap = Record<string, boolean>;
|
|
8
|
+
|
|
9
|
+
export interface FeatureConfig {
|
|
10
|
+
namespace: string;
|
|
11
|
+
flags: FeatureFlagMap;
|
|
12
|
+
routeFlags: FeatureRouteMap;
|
|
13
|
+
colors: FeatureColorMap;
|
|
14
|
+
outlineDefaultsByToken: FeatureBoolByTokenMap;
|
|
15
|
+
badgeDefaultsByToken: FeatureBoolByTokenMap;
|
|
16
|
+
mode: string;
|
|
17
|
+
/** Which `environments` entry was used (reserved `dev` = all flags on, toolbar layer). */
|
|
18
|
+
activeEnvironment: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Declarative feature flags for Astro. Merge order: inline options → optional root
|
|
23
|
+
* {@link ResolveFeatureRuntimeOptions.jsonConfigPath} → optional per-environment
|
|
24
|
+
* `environments.<name>.jsonConfigPath` (merged when that layer is active).
|
|
25
|
+
*
|
|
26
|
+
* Exactly one `when: true` across environments unless `forceEnvironment` or `AFF_ENVIRONMENT`
|
|
27
|
+
* selects the layer. A reserved **`dev`** environment is required (all flags on at resolve time;
|
|
28
|
+
* toggles are dev-toolbar only) plus at least one other environment (e.g. `prod`).
|
|
29
|
+
*/
|
|
30
|
+
export interface ResolveFeatureRuntimeOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Directory used to resolve relative {@link jsonConfigPath} values (root and per-environment).
|
|
33
|
+
* Defaults to `process.cwd()`.
|
|
34
|
+
*/
|
|
35
|
+
configRoot?: string;
|
|
36
|
+
/** Optional main JSON file merged after inline `flags` / `environments`. */
|
|
37
|
+
jsonConfigPath?: string;
|
|
38
|
+
/** Passed through to `ResolvedFeatureRuntime.mode` (e.g. `process.env.NODE_ENV`). */
|
|
39
|
+
mode?: string;
|
|
40
|
+
/** Process env for `AFF_FEATURE_*` and `ASTRO_FEATURE_FLAGS` (skipped when active env is `dev`). */
|
|
41
|
+
env?: Record<string, string | undefined>;
|
|
42
|
+
/**
|
|
43
|
+
* Use this `environments` entry regardless of `when` / `AFF_ENVIRONMENT` (e.g. sitemaps, tests).
|
|
44
|
+
*/
|
|
45
|
+
forceEnvironment?: string;
|
|
46
|
+
tokenNamespace?: string;
|
|
47
|
+
flags?: Record<
|
|
48
|
+
string,
|
|
49
|
+
{
|
|
50
|
+
color?: string;
|
|
51
|
+
colour?: string;
|
|
52
|
+
outline?: boolean;
|
|
53
|
+
badge?: boolean;
|
|
54
|
+
routes?: string[];
|
|
55
|
+
}
|
|
56
|
+
>;
|
|
57
|
+
environments?: Record<
|
|
58
|
+
string,
|
|
59
|
+
{
|
|
60
|
+
when?: boolean;
|
|
61
|
+
flags?: Record<string, boolean>;
|
|
62
|
+
/** Optional JSON merged after the root file when this environment is active. */
|
|
63
|
+
jsonConfigPath?: string;
|
|
64
|
+
}
|
|
65
|
+
>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toBoolean(value: unknown, fallback = false): boolean {
|
|
69
|
+
if (typeof value === "boolean") return value;
|
|
70
|
+
if (typeof value !== "string") return fallback;
|
|
71
|
+
const normalized = value.trim().toLowerCase();
|
|
72
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
73
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonIfExists(pathname: string): Record<string, unknown> {
|
|
78
|
+
if (!existsSync(pathname)) return {};
|
|
79
|
+
return JSON.parse(readFileSync(pathname, "utf8")) as Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizePath(pathname: string): string {
|
|
83
|
+
if (!pathname) return "/";
|
|
84
|
+
return pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes a route key from `ff.json` for prefix matching.
|
|
89
|
+
* Supports wildcards: `/blog/*`, `/docs/**` → prefix `/blog/`, `/docs/`.
|
|
90
|
+
*/
|
|
91
|
+
export function routePatternToPrefix(pattern: string): string {
|
|
92
|
+
let p = pattern.trim();
|
|
93
|
+
if (p.endsWith("/**")) {
|
|
94
|
+
p = p.slice(0, -3);
|
|
95
|
+
} else if (p.endsWith("/*")) {
|
|
96
|
+
p = p.slice(0, -2);
|
|
97
|
+
} else if (p.endsWith("*") && p.length > 1) {
|
|
98
|
+
p = p.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
return normalizePath(p);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toEnumKey(flagName: string): string {
|
|
104
|
+
return flagName
|
|
105
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
106
|
+
.trim()
|
|
107
|
+
.split(/\s+/)
|
|
108
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
109
|
+
.join("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toToken(flagName: string): string {
|
|
113
|
+
return flagName
|
|
114
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
115
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
116
|
+
.replace(/([a-zA-Z])([0-9])/g, "$1-$2")
|
|
117
|
+
.replace(/([0-9])([a-zA-Z])/g, "$1-$2")
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/-+/g, "-")
|
|
120
|
+
.replace(/^-|-$/g, "");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeRecords<T extends Record<string, unknown>>(
|
|
124
|
+
base: T,
|
|
125
|
+
...layers: Partial<T>[]
|
|
126
|
+
): T {
|
|
127
|
+
const out = { ...base };
|
|
128
|
+
for (const layer of layers) {
|
|
129
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
130
|
+
if (v === undefined) continue;
|
|
131
|
+
const cur = out[k as keyof T];
|
|
132
|
+
if (
|
|
133
|
+
v &&
|
|
134
|
+
typeof v === "object" &&
|
|
135
|
+
!Array.isArray(v) &&
|
|
136
|
+
cur &&
|
|
137
|
+
typeof cur === "object" &&
|
|
138
|
+
!Array.isArray(cur)
|
|
139
|
+
) {
|
|
140
|
+
(out as Record<string, unknown>)[k] = mergeRecords(
|
|
141
|
+
cur as Record<string, unknown>,
|
|
142
|
+
v as Record<string, unknown>,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
(out as Record<string, unknown>)[k] = v;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolvePath(relativeOrAbsolute: string, configRoot: string): string {
|
|
153
|
+
return isAbsolute(relativeOrAbsolute)
|
|
154
|
+
? relativeOrAbsolute
|
|
155
|
+
: join(configRoot, relativeOrAbsolute);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type NormalizedFlagDef = {
|
|
159
|
+
color?: string;
|
|
160
|
+
outline: boolean;
|
|
161
|
+
badge: boolean;
|
|
162
|
+
routes: string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
type NormalizedEnvEntry = {
|
|
166
|
+
when?: boolean;
|
|
167
|
+
flags: Record<string, boolean>;
|
|
168
|
+
/** Per-environment JSON merged after root `jsonConfigPath` when this layer is active. */
|
|
169
|
+
jsonConfigPath?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type DeclarativeFeatureConfig = {
|
|
173
|
+
tokenNamespace: string;
|
|
174
|
+
flags: Record<string, NormalizedFlagDef>;
|
|
175
|
+
environments: Record<string, NormalizedEnvEntry>;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function normalizeDeclarativeConfig(
|
|
179
|
+
raw: Record<string, unknown>,
|
|
180
|
+
mode: string = process.env.NODE_ENV ?? "development",
|
|
181
|
+
): DeclarativeFeatureConfig {
|
|
182
|
+
const tokenNamespaceRaw =
|
|
183
|
+
(typeof raw.tokenNamespace === "string" ? raw.tokenNamespace : undefined) ??
|
|
184
|
+
(typeof raw.namespace === "string" ? raw.namespace : undefined) ??
|
|
185
|
+
"ff";
|
|
186
|
+
|
|
187
|
+
const flagsRaw =
|
|
188
|
+
raw.flags && typeof raw.flags === "object" && !Array.isArray(raw.flags)
|
|
189
|
+
? (raw.flags as Record<string, unknown>)
|
|
190
|
+
: {};
|
|
191
|
+
const flags: Record<string, NormalizedFlagDef> = {};
|
|
192
|
+
for (const [flagName, maybeDef] of Object.entries(flagsRaw)) {
|
|
193
|
+
if (!maybeDef || typeof maybeDef !== "object" || Array.isArray(maybeDef))
|
|
194
|
+
continue;
|
|
195
|
+
const def = maybeDef as Record<string, unknown>;
|
|
196
|
+
const color =
|
|
197
|
+
(typeof def.colour === "string" ? def.colour : undefined) ??
|
|
198
|
+
(typeof def.color === "string" ? def.color : undefined);
|
|
199
|
+
const outline = typeof def.outline === "boolean" ? def.outline : true;
|
|
200
|
+
const badge = typeof def.badge === "boolean" ? def.badge : true;
|
|
201
|
+
const routes = Array.isArray(def.routes)
|
|
202
|
+
? def.routes.filter((v): v is string => typeof v === "string")
|
|
203
|
+
: [];
|
|
204
|
+
flags[flagName] = { color, outline, badge, routes };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const envRaw =
|
|
208
|
+
raw.environments &&
|
|
209
|
+
typeof raw.environments === "object" &&
|
|
210
|
+
!Array.isArray(raw.environments)
|
|
211
|
+
? (raw.environments as Record<string, unknown>)
|
|
212
|
+
: {};
|
|
213
|
+
const environments: Record<string, NormalizedEnvEntry> = {};
|
|
214
|
+
for (const [envName, maybeEnv] of Object.entries(envRaw)) {
|
|
215
|
+
if (envName === "dev") continue;
|
|
216
|
+
if (!maybeEnv || typeof maybeEnv !== "object" || Array.isArray(maybeEnv))
|
|
217
|
+
continue;
|
|
218
|
+
const env = maybeEnv as Record<string, unknown>;
|
|
219
|
+
const envFlagsRaw =
|
|
220
|
+
env.flags && typeof env.flags === "object" && !Array.isArray(env.flags)
|
|
221
|
+
? (env.flags as Record<string, unknown>)
|
|
222
|
+
: {};
|
|
223
|
+
const envFlags: Record<string, boolean> = {};
|
|
224
|
+
for (const [flagName, value] of Object.entries(envFlagsRaw)) {
|
|
225
|
+
envFlags[flagName] = Boolean(value);
|
|
226
|
+
}
|
|
227
|
+
const when = typeof env.when === "boolean" ? env.when : undefined;
|
|
228
|
+
const layerJson =
|
|
229
|
+
typeof env.jsonConfigPath === "string" ? env.jsonConfigPath : undefined;
|
|
230
|
+
environments[envName] = {
|
|
231
|
+
...(when !== undefined ? { when } : {}),
|
|
232
|
+
...(layerJson ? { jsonConfigPath: layerJson } : {}),
|
|
233
|
+
flags: envFlags,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
environments.dev = {
|
|
238
|
+
when: mode !== "production",
|
|
239
|
+
flags: {},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
tokenNamespace: tokenNamespaceRaw,
|
|
244
|
+
flags,
|
|
245
|
+
environments,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function assertEnvironmentShape(environments: Record<string, NormalizedEnvEntry>) {
|
|
250
|
+
const keys = Object.keys(environments);
|
|
251
|
+
if (!environments.dev) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
'astro-feature-flags: environments must include a reserved "dev" entry.',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (keys.length < 2) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
"astro-feature-flags: environments must include `dev` plus at least one other layer (e.g. `prod`).",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function countActiveWhen(environments: Record<string, NormalizedEnvEntry>): number {
|
|
264
|
+
return Object.values(environments).filter((e) => e.when === true).length;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function resolveAffEnvironment(
|
|
268
|
+
options: ResolveFeatureRuntimeOptions,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
const proc =
|
|
271
|
+
options.env ?? (process.env as Record<string, string | undefined>);
|
|
272
|
+
return proc.AFF_ENVIRONMENT?.trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resolveActiveEnvironmentName(
|
|
276
|
+
normalized: DeclarativeFeatureConfig,
|
|
277
|
+
options: ResolveFeatureRuntimeOptions,
|
|
278
|
+
): string {
|
|
279
|
+
const { environments } = normalized;
|
|
280
|
+
assertEnvironmentShape(environments);
|
|
281
|
+
|
|
282
|
+
const forced = options.forceEnvironment?.trim();
|
|
283
|
+
if (forced && environments[forced]) return forced;
|
|
284
|
+
|
|
285
|
+
const aff = resolveAffEnvironment(options);
|
|
286
|
+
if (aff && environments[aff]) return aff;
|
|
287
|
+
|
|
288
|
+
const n = countActiveWhen(environments);
|
|
289
|
+
if (n !== 1) {
|
|
290
|
+
const activeNames = Object.entries(environments)
|
|
291
|
+
.filter(([, e]) => e.when === true)
|
|
292
|
+
.map(([k]) => k);
|
|
293
|
+
const devWhen = environments.dev?.when;
|
|
294
|
+
const hint =
|
|
295
|
+
n > 1 && environments.dev && devWhen === true
|
|
296
|
+
? " The reserved `dev` environment is active for this `mode` while another layer also has `when: true`. Pin one layer with `forceEnvironment` or `AFF_ENVIRONMENT`, or adjust non-dev `when` predicates."
|
|
297
|
+
: "";
|
|
298
|
+
throw new Error(
|
|
299
|
+
`astro-feature-flags: exactly one environment must have when: true (found ${n}${activeNames.length ? `: ${activeNames.join(", ")}` : ""}).${hint} Set forceEnvironment or AFF_ENVIRONMENT to pick a layer explicitly.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
for (const [name, cfg] of Object.entries(environments)) {
|
|
303
|
+
if (cfg.when === true) return name;
|
|
304
|
+
}
|
|
305
|
+
throw new Error("astro-feature-flags: internal error resolving environment.");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readBaseMergedRaw(options: ResolveFeatureRuntimeOptions): Record<string, unknown> {
|
|
309
|
+
const configRoot = options.configRoot ?? process.cwd();
|
|
310
|
+
const {
|
|
311
|
+
tokenNamespace = "ff",
|
|
312
|
+
flags = {},
|
|
313
|
+
environments = {},
|
|
314
|
+
} = options;
|
|
315
|
+
const inlineRaw: Record<string, unknown> = {
|
|
316
|
+
tokenNamespace,
|
|
317
|
+
flags,
|
|
318
|
+
environments,
|
|
319
|
+
};
|
|
320
|
+
let merged = { ...inlineRaw };
|
|
321
|
+
if (options.jsonConfigPath) {
|
|
322
|
+
const p = resolvePath(options.jsonConfigPath, configRoot);
|
|
323
|
+
merged = mergeRecords(merged as Record<string, unknown>, readJsonIfExists(p));
|
|
324
|
+
}
|
|
325
|
+
return merged;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function mergePerEnvironmentJson(
|
|
329
|
+
baseRaw: Record<string, unknown>,
|
|
330
|
+
envName: string,
|
|
331
|
+
configRoot: string,
|
|
332
|
+
mode: string,
|
|
333
|
+
): Record<string, unknown> {
|
|
334
|
+
if (envName === "dev") return baseRaw;
|
|
335
|
+
const normalized = normalizeDeclarativeConfig(baseRaw, mode);
|
|
336
|
+
const entry = normalized.environments[envName];
|
|
337
|
+
const rel = entry?.jsonConfigPath;
|
|
338
|
+
if (!rel) return baseRaw;
|
|
339
|
+
const p = resolvePath(rel, configRoot);
|
|
340
|
+
return mergeRecords(baseRaw, readJsonIfExists(p));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readMergedRawForActiveEnv(
|
|
344
|
+
options: ResolveFeatureRuntimeOptions,
|
|
345
|
+
envName: string,
|
|
346
|
+
): Record<string, unknown> {
|
|
347
|
+
const configRoot = options.configRoot ?? process.cwd();
|
|
348
|
+
const mode = options.mode ?? process.env.NODE_ENV ?? "development";
|
|
349
|
+
const base = readBaseMergedRaw(options);
|
|
350
|
+
return mergePerEnvironmentJson(base, envName, configRoot, mode);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function loadFeatureConfig(
|
|
354
|
+
options: ResolveFeatureRuntimeOptions = {},
|
|
355
|
+
): FeatureConfig {
|
|
356
|
+
const mode = options.mode ?? process.env.NODE_ENV ?? "development";
|
|
357
|
+
const baseRaw = readBaseMergedRaw(options);
|
|
358
|
+
const baseNorm = normalizeDeclarativeConfig(baseRaw, mode);
|
|
359
|
+
const envName = resolveActiveEnvironmentName(baseNorm, options);
|
|
360
|
+
const fullRaw = readMergedRawForActiveEnv(options, envName);
|
|
361
|
+
const normalized = normalizeDeclarativeConfig(fullRaw, mode);
|
|
362
|
+
|
|
363
|
+
const envFlags = normalized.environments[envName]?.flags ?? {};
|
|
364
|
+
const routeFlags: FeatureRouteMap = {};
|
|
365
|
+
const colors: FeatureColorMap = {};
|
|
366
|
+
const outlineDefaultsByToken: FeatureBoolByTokenMap = {};
|
|
367
|
+
const badgeDefaultsByToken: FeatureBoolByTokenMap = {};
|
|
368
|
+
const resolvedFlags: FeatureFlagMap = {};
|
|
369
|
+
|
|
370
|
+
for (const [flagName, flagDef] of Object.entries(normalized.flags)) {
|
|
371
|
+
if (envName === "dev") {
|
|
372
|
+
resolvedFlags[flagName] = true;
|
|
373
|
+
} else {
|
|
374
|
+
resolvedFlags[flagName] = envFlags[flagName] === true;
|
|
375
|
+
}
|
|
376
|
+
if (flagDef.color) colors[flagName] = flagDef.color;
|
|
377
|
+
const token = toToken(flagName);
|
|
378
|
+
outlineDefaultsByToken[token] = flagDef.outline;
|
|
379
|
+
badgeDefaultsByToken[token] = flagDef.badge;
|
|
380
|
+
for (const route of flagDef.routes) {
|
|
381
|
+
routeFlags[route] = [...(routeFlags[route] ?? []), flagName];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
namespace: normalized.tokenNamespace || "ff",
|
|
387
|
+
flags: resolvedFlags,
|
|
388
|
+
routeFlags,
|
|
389
|
+
colors,
|
|
390
|
+
outlineDefaultsByToken,
|
|
391
|
+
badgeDefaultsByToken,
|
|
392
|
+
mode,
|
|
393
|
+
activeEnvironment: envName,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Applies `AFF_FEATURE_*` and `ASTRO_FEATURE_FLAGS` on top of a resolved flag map. */
|
|
398
|
+
export function mergeFlagsWithProcessEnvOverrides(
|
|
399
|
+
flags: FeatureFlagMap,
|
|
400
|
+
env: Record<string, string | undefined> = process.env as Record<
|
|
401
|
+
string,
|
|
402
|
+
string | undefined
|
|
403
|
+
>,
|
|
404
|
+
): FeatureFlagMap {
|
|
405
|
+
const resolvedFlags: FeatureFlagMap = { ...flags };
|
|
406
|
+
for (const [key, value] of Object.entries(resolvedFlags)) {
|
|
407
|
+
const slug = toToken(key).replace(/-/g, "_").toUpperCase();
|
|
408
|
+
const envKey = `AFF_FEATURE_${slug}`;
|
|
409
|
+
resolvedFlags[key] = toBoolean(env[envKey], Boolean(value));
|
|
410
|
+
}
|
|
411
|
+
const envMapRaw = env.ASTRO_FEATURE_FLAGS;
|
|
412
|
+
if (envMapRaw) {
|
|
413
|
+
try {
|
|
414
|
+
const envMap = JSON.parse(envMapRaw) as Record<string, unknown>;
|
|
415
|
+
for (const [flag, value] of Object.entries(envMap)) {
|
|
416
|
+
resolvedFlags[flag] = Boolean(value);
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
// ignore invalid JSON override
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return resolvedFlags;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Resolved flag booleans for each `environments` key, after the same process-env overrides
|
|
427
|
+
* as {@link resolveFeatureRuntime} (overrides skipped for the reserved `dev` layer).
|
|
428
|
+
*/
|
|
429
|
+
export function resolveFeatureFlagsByEnvironment(
|
|
430
|
+
options: ResolveFeatureRuntimeOptions = {},
|
|
431
|
+
): Record<string, FeatureFlagMap> {
|
|
432
|
+
const baseRaw = readBaseMergedRaw(options);
|
|
433
|
+
const mode = options.mode ?? process.env.NODE_ENV ?? "development";
|
|
434
|
+
const normalized = normalizeDeclarativeConfig(baseRaw, mode);
|
|
435
|
+
const envKeys = Object.keys(normalized.environments);
|
|
436
|
+
const proc = options.env ?? (process.env as Record<string, string | undefined>);
|
|
437
|
+
const out: Record<string, FeatureFlagMap> = {};
|
|
438
|
+
for (const envName of envKeys) {
|
|
439
|
+
const cfg = loadFeatureConfig({ ...options, forceEnvironment: envName });
|
|
440
|
+
out[envName] =
|
|
441
|
+
envName === "dev"
|
|
442
|
+
? { ...cfg.flags }
|
|
443
|
+
: mergeFlagsWithProcessEnvOverrides({ ...cfg.flags }, proc);
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Normalize colour map keys (flag names or tokens) to CSS tokens. */
|
|
449
|
+
export function colorsToTokenMap(
|
|
450
|
+
colors: FeatureColorMap,
|
|
451
|
+
): Record<string, string> {
|
|
452
|
+
const out: Record<string, string> = {};
|
|
453
|
+
for (const [key, value] of Object.entries(colors)) {
|
|
454
|
+
out[toToken(key)] = value;
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface ResolvedFeatureRuntime {
|
|
460
|
+
namespace: string;
|
|
461
|
+
mode: string;
|
|
462
|
+
/** True when the active environment is the reserved `dev` layer (toolbar + all flags on). */
|
|
463
|
+
isDev: boolean;
|
|
464
|
+
activeEnvironment: string;
|
|
465
|
+
flags: FeatureFlagMap;
|
|
466
|
+
routeFlags: FeatureRouteMap;
|
|
467
|
+
/** Outline/badge/route-frame colour per flag token (CSS colour strings). */
|
|
468
|
+
flagColorsByToken: Record<string, string>;
|
|
469
|
+
/** Default dev-toolbar outline/badge state per flag token. */
|
|
470
|
+
flagOutlineDefaultsByToken: Record<string, boolean>;
|
|
471
|
+
flagBadgeDefaultsByToken: Record<string, boolean>;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function resolveFeatureRuntime(
|
|
475
|
+
options: ResolveFeatureRuntimeOptions = {},
|
|
476
|
+
): ResolvedFeatureRuntime {
|
|
477
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
478
|
+
const config = loadFeatureConfig(options);
|
|
479
|
+
const isDevLayer = config.activeEnvironment === "dev";
|
|
480
|
+
const resolvedFlags = isDevLayer
|
|
481
|
+
? { ...config.flags }
|
|
482
|
+
: mergeFlagsWithProcessEnvOverrides(config.flags, env);
|
|
483
|
+
|
|
484
|
+
const colorByToken = colorsToTokenMap(config.colors);
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
namespace: config.namespace,
|
|
488
|
+
mode: config.mode,
|
|
489
|
+
isDev: isDevLayer,
|
|
490
|
+
activeEnvironment: config.activeEnvironment,
|
|
491
|
+
flags: resolvedFlags,
|
|
492
|
+
routeFlags: config.routeFlags,
|
|
493
|
+
flagColorsByToken: colorByToken,
|
|
494
|
+
flagOutlineDefaultsByToken: config.outlineDefaultsByToken,
|
|
495
|
+
flagBadgeDefaultsByToken: config.badgeDefaultsByToken,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function isFlagEnabled(flags: FeatureFlagMap, flag: string): boolean {
|
|
500
|
+
return Boolean(flags?.[flag]);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function isRouteFlagged(
|
|
504
|
+
pathname: string,
|
|
505
|
+
routeFlags: FeatureRouteMap,
|
|
506
|
+
): boolean {
|
|
507
|
+
const normalized = normalizePath(pathname);
|
|
508
|
+
return Object.keys(routeFlags || {}).some((routePrefix) => {
|
|
509
|
+
const route = routePatternToPrefix(routePrefix);
|
|
510
|
+
return normalized === route || normalized.startsWith(route);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function shouldIncludeRoute({
|
|
515
|
+
pathname,
|
|
516
|
+
routeFlags,
|
|
517
|
+
flags,
|
|
518
|
+
isDev,
|
|
519
|
+
}: {
|
|
520
|
+
pathname: string;
|
|
521
|
+
routeFlags: FeatureRouteMap;
|
|
522
|
+
flags: FeatureFlagMap;
|
|
523
|
+
isDev: boolean;
|
|
524
|
+
}): boolean {
|
|
525
|
+
if (isDev) return true;
|
|
526
|
+
const normalized = normalizePath(pathname);
|
|
527
|
+
for (const [routePrefix, flagNames] of Object.entries(routeFlags || {})) {
|
|
528
|
+
const route = routePatternToPrefix(routePrefix);
|
|
529
|
+
if (normalized === route || normalized.startsWith(route)) {
|
|
530
|
+
return (flagNames || []).every((flagName) =>
|
|
531
|
+
isFlagEnabled(flags, flagName),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function routePathsToPrune({
|
|
539
|
+
routeFlags,
|
|
540
|
+
flags,
|
|
541
|
+
}: {
|
|
542
|
+
routeFlags: FeatureRouteMap;
|
|
543
|
+
flags: FeatureFlagMap;
|
|
544
|
+
}): string[] {
|
|
545
|
+
return Object.entries(routeFlags || {})
|
|
546
|
+
.filter(([, flagNames]) =>
|
|
547
|
+
(flagNames || []).some((flagName) => !isFlagEnabled(flags, flagName)),
|
|
548
|
+
)
|
|
549
|
+
.map(([routePath]) =>
|
|
550
|
+
routePatternToPrefix(routePath).replace(/^\/+|\/+$/g, ""),
|
|
551
|
+
)
|
|
552
|
+
.filter(Boolean);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Longest `routeFlags` key that matches `pathname` (prefix match, trailing slashes normalized).
|
|
557
|
+
*/
|
|
558
|
+
export function longestMatchingRoutePrefix(
|
|
559
|
+
pathname: string,
|
|
560
|
+
routeFlags: FeatureRouteMap,
|
|
561
|
+
): string | null {
|
|
562
|
+
const normalized = normalizePath(pathname);
|
|
563
|
+
let best: string | null = null;
|
|
564
|
+
let bestLen = -1;
|
|
565
|
+
for (const routePrefix of Object.keys(routeFlags || {})) {
|
|
566
|
+
const route = routePatternToPrefix(routePrefix);
|
|
567
|
+
if (normalized === route || normalized.startsWith(route)) {
|
|
568
|
+
if (route.length > bestLen) {
|
|
569
|
+
bestLen = route.length;
|
|
570
|
+
best = routePrefix;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return best;
|
|
575
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
declare module "virtual:astro-feature-flags" {
|
|
2
|
+
export const FeatureFlag: Record<string, string>;
|
|
3
|
+
/** Slug tokens for `data-ff` (use on elements). */
|
|
4
|
+
export const FeatureToken: Record<string, string>;
|
|
5
|
+
export const featureFlagTokens: readonly string[];
|
|
6
|
+
export const featureFlagColors: Readonly<Record<string, string>>;
|
|
7
|
+
/** Flags for the active build environment (reserved layer name `dev` = all on). */
|
|
8
|
+
export const featureFlags: Readonly<Record<string, boolean>>;
|
|
9
|
+
/**
|
|
10
|
+
* Resolved booleans per configured environment key (`staging`, `prod`, …). The reserved
|
|
11
|
+
* **`dev`** entry is the built-in local-dev layer (all flags on); any other key is a shipped layer.
|
|
12
|
+
*/
|
|
13
|
+
export const featureFlagsByEnvironment: Readonly<
|
|
14
|
+
Record<string, Readonly<Record<string, boolean>>>
|
|
15
|
+
>;
|
|
16
|
+
/**
|
|
17
|
+
* Default non-`dev` layer for helpers: `prod` if defined, otherwise the first other key
|
|
18
|
+
* (sorted). Use with {@link shouldIncludePathForEnvironment} when you want “compare to
|
|
19
|
+
* primary shipped layer” without hard-coding `prod`.
|
|
20
|
+
*/
|
|
21
|
+
export const defaultNonDevEnvironment: string;
|
|
22
|
+
export const featureRouteFlags: Readonly<Record<string, string[]>>;
|
|
23
|
+
export const featureNamespace: string;
|
|
24
|
+
/** Which environment layer this build resolved to (`dev` = built-in local dev layer when active). */
|
|
25
|
+
export const activeEnvironmentKey: string;
|
|
26
|
+
/**
|
|
27
|
+
* `import.meta.env.DEV` from Vite. Usually matches a local `astro dev` session; can differ from
|
|
28
|
+
* `activeEnvironmentKey` if you pin another layer with `forceEnvironment` / `AFF_ENVIRONMENT`.
|
|
29
|
+
*/
|
|
30
|
+
export const isAstroDev: boolean;
|
|
31
|
+
/** Dev-only outline/badge CSS; empty string in production (static HTML is culled instead). */
|
|
32
|
+
export const featureFlagStyles: string;
|
|
33
|
+
/** Inline script for dev toolbar + `data-ff-*` on `<html>` (empty string in production). */
|
|
34
|
+
export const affDevBootstrap: string;
|
|
35
|
+
|
|
36
|
+
export function isFeatureEnabled(flag: string): boolean;
|
|
37
|
+
/** Map for a named layer, or `null` if unknown. */
|
|
38
|
+
export function flagsForEnvironment(
|
|
39
|
+
envName: string,
|
|
40
|
+
): Readonly<Record<string, boolean>> | null;
|
|
41
|
+
export function isFeatureEnabledForEnvironment(
|
|
42
|
+
flag: string,
|
|
43
|
+
envName: string,
|
|
44
|
+
): boolean;
|
|
45
|
+
/** Same as `isFeatureEnabled` — SSR follows the active layer; dev toolbar does not change server output. */
|
|
46
|
+
export function shouldRenderFeature(flag: string): boolean;
|
|
47
|
+
export function isFeatureRoute(pathname: string): boolean;
|
|
48
|
+
export function shouldIncludePath(pathname: string): boolean;
|
|
49
|
+
/** Route gating for an arbitrary configured layer (e.g. `prod`, `staging`). */
|
|
50
|
+
export function shouldIncludePathForEnvironment(
|
|
51
|
+
pathname: string,
|
|
52
|
+
envName: string,
|
|
53
|
+
): boolean;
|
|
54
|
+
export function matchedFeatureRoutePrefix(pathname: string): string | null;
|
|
55
|
+
/** Longest `routes` match → flag token for `data-ff-route` on `<html>` (dev route pill). */
|
|
56
|
+
export function routeFeatureTokenForPath(
|
|
57
|
+
pathname: string,
|
|
58
|
+
): string | undefined;
|
|
59
|
+
/** Longest `routes` match → all flag tokens for the route (for multi-flag route badges). */
|
|
60
|
+
export function routeFeatureTokensForPath(pathname: string): string[];
|
|
61
|
+
/** All matching route patterns grouped by feature for a pathname. */
|
|
62
|
+
export function routeFeatureMatchesForPath(pathname: string): Array<{
|
|
63
|
+
flag: string;
|
|
64
|
+
token: string;
|
|
65
|
+
routes: string[];
|
|
66
|
+
}>;
|
|
67
|
+
}
|