@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/index.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
elementBadgePositionBlock,
|
|
6
|
+
normalizeElementBadgeLayout,
|
|
7
|
+
} from "./badge-layout";
|
|
8
|
+
import { DEV_TOOLBAR_FLAG_ICON_SVG } from "./dev-toolbar-flag-icon";
|
|
9
|
+
import type {
|
|
10
|
+
ElementBadgeHorizontalAlign,
|
|
11
|
+
ElementBadgeLayoutOptions,
|
|
12
|
+
ElementBadgeVerticalAnchor,
|
|
13
|
+
NormalizedElementBadgeLayout,
|
|
14
|
+
} from "./badge-layout";
|
|
15
|
+
import {
|
|
16
|
+
type FeatureFlagMap,
|
|
17
|
+
type ResolveFeatureRuntimeOptions,
|
|
18
|
+
type ResolvedFeatureRuntime,
|
|
19
|
+
resolveFeatureFlagsByEnvironment,
|
|
20
|
+
resolveFeatureRuntime,
|
|
21
|
+
routePathsToPrune,
|
|
22
|
+
shouldIncludeRoute,
|
|
23
|
+
toEnumKey,
|
|
24
|
+
toToken,
|
|
25
|
+
} from "./runtime";
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
ElementBadgeHorizontalAlign,
|
|
29
|
+
ElementBadgeLayoutOptions,
|
|
30
|
+
ElementBadgeVerticalAnchor,
|
|
31
|
+
NormalizedElementBadgeLayout,
|
|
32
|
+
};
|
|
33
|
+
export { elementBadgePositionBlock, normalizeElementBadgeLayout };
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
DevOutlineCssOptions,
|
|
37
|
+
DevOutlineHiddenStrategy,
|
|
38
|
+
} from "./dev-outline-css";
|
|
39
|
+
|
|
40
|
+
import type { DevOutlineCssOptions } from "./dev-outline-css";
|
|
41
|
+
import {
|
|
42
|
+
buildAffDevBootstrapScript,
|
|
43
|
+
createFeatureFlagStyles,
|
|
44
|
+
createProductionGateStyles,
|
|
45
|
+
} from "./dev-outline-css";
|
|
46
|
+
import { applyProductionHtmlCullToDist } from "./production-html-cull";
|
|
47
|
+
import { buildAffDevHeadInline } from "./dev-head-inject";
|
|
48
|
+
import { routePrefixJsHelper } from "./route-prefix-js";
|
|
49
|
+
|
|
50
|
+
export { createFeatureFlagStyles, createProductionGateStyles };
|
|
51
|
+
export {
|
|
52
|
+
cullProductionHtml,
|
|
53
|
+
applyProductionHtmlCullToDist,
|
|
54
|
+
} from "./production-html-cull";
|
|
55
|
+
|
|
56
|
+
export interface AstroFeatureFlagsOptions extends ResolveFeatureRuntimeOptions {
|
|
57
|
+
css?: DevOutlineCssOptions;
|
|
58
|
+
/** When false, keeps static build output untouched (no route pruning / HTML cull). */
|
|
59
|
+
staticMinify?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Prefer `prod` if present; else first non-`dev` key (sorted); else `"prod"`. */
|
|
63
|
+
export function primaryNonDevEnvironmentKey(
|
|
64
|
+
flagsByEnvironment: Record<string, FeatureFlagMap>,
|
|
65
|
+
): string {
|
|
66
|
+
const keys = Object.keys(flagsByEnvironment);
|
|
67
|
+
if (keys.includes("prod")) return "prod";
|
|
68
|
+
const nonDev = keys.filter((k) => k !== "dev").sort();
|
|
69
|
+
return nonDev[0] ?? "prod";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withDefaultEnvironments(
|
|
73
|
+
options: ResolveFeatureRuntimeOptions = {},
|
|
74
|
+
): ResolveFeatureRuntimeOptions {
|
|
75
|
+
const mode = options.mode ?? process.env.NODE_ENV ?? "development";
|
|
76
|
+
const raw =
|
|
77
|
+
options.environments &&
|
|
78
|
+
typeof options.environments === "object" &&
|
|
79
|
+
!Array.isArray(options.environments)
|
|
80
|
+
? { ...options.environments }
|
|
81
|
+
: {};
|
|
82
|
+
delete raw.dev;
|
|
83
|
+
const environments: NonNullable<ResolveFeatureRuntimeOptions["environments"]> =
|
|
84
|
+
{
|
|
85
|
+
dev: {
|
|
86
|
+
when: mode !== "production",
|
|
87
|
+
},
|
|
88
|
+
...raw,
|
|
89
|
+
};
|
|
90
|
+
if (Object.keys(environments).length < 2) {
|
|
91
|
+
environments.prod = environments.prod ?? {
|
|
92
|
+
when: mode === "production",
|
|
93
|
+
flags: {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ...options, environments };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createVirtualModuleSource(
|
|
100
|
+
runtime: ResolvedFeatureRuntime,
|
|
101
|
+
css?: DevOutlineCssOptions,
|
|
102
|
+
flagsByEnvironment: Record<string, FeatureFlagMap> = {},
|
|
103
|
+
devBootstrap?: string,
|
|
104
|
+
): string {
|
|
105
|
+
const flagNames = Object.keys(runtime.flags);
|
|
106
|
+
const flagTokens = flagNames.map((name) => toToken(name));
|
|
107
|
+
const enumEntries = flagNames
|
|
108
|
+
.map((name) => ` ${toEnumKey(name)}: ${JSON.stringify(name)}`)
|
|
109
|
+
.join(",\n");
|
|
110
|
+
const tokenEntries = flagNames
|
|
111
|
+
.map((name) => ` ${toEnumKey(name)}: ${JSON.stringify(toToken(name))}`)
|
|
112
|
+
.join(",\n");
|
|
113
|
+
const styles = createFeatureFlagStyles(runtime, css);
|
|
114
|
+
const colorsJson = JSON.stringify(runtime.flagColorsByToken, null, 2);
|
|
115
|
+
const flagNameToToken = JSON.stringify(
|
|
116
|
+
Object.fromEntries(flagNames.map((name) => [name, toToken(name)])),
|
|
117
|
+
null,
|
|
118
|
+
2,
|
|
119
|
+
);
|
|
120
|
+
const defaultNonDevEnvironment =
|
|
121
|
+
primaryNonDevEnvironmentKey(flagsByEnvironment);
|
|
122
|
+
const bootstrap =
|
|
123
|
+
devBootstrap ??
|
|
124
|
+
buildAffDevBootstrapScript(
|
|
125
|
+
flagTokens,
|
|
126
|
+
{ ...runtime.flagColorsByToken },
|
|
127
|
+
{ ...runtime.flagOutlineDefaultsByToken },
|
|
128
|
+
{ ...runtime.flagBadgeDefaultsByToken },
|
|
129
|
+
{ ...runtime.routeFlags },
|
|
130
|
+
Object.fromEntries(flagNames.map((name) => [name, toToken(name)])),
|
|
131
|
+
runtime.namespace,
|
|
132
|
+
);
|
|
133
|
+
const affRoutePatternToPrefix = routePrefixJsHelper("affRoutePatternToPrefix");
|
|
134
|
+
|
|
135
|
+
return `${affRoutePatternToPrefix}
|
|
136
|
+
|
|
137
|
+
export const FeatureFlag = Object.freeze({
|
|
138
|
+
${enumEntries}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const FeatureToken = Object.freeze({
|
|
142
|
+
${tokenEntries}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const featureFlagTokens = Object.freeze(${JSON.stringify(flagTokens)});
|
|
146
|
+
export const featureFlagColors = Object.freeze(${colorsJson});
|
|
147
|
+
export const featureFlags = Object.freeze(${JSON.stringify(runtime.flags, null, 2)});
|
|
148
|
+
export const featureFlagsByEnvironment = Object.freeze(${JSON.stringify(flagsByEnvironment, null, 2)});
|
|
149
|
+
export const defaultNonDevEnvironment = ${JSON.stringify(defaultNonDevEnvironment)};
|
|
150
|
+
export const featureRouteFlags = Object.freeze(${JSON.stringify(runtime.routeFlags, null, 2)});
|
|
151
|
+
export const featureNamespace = ${JSON.stringify(runtime.namespace)};
|
|
152
|
+
/** True when Astro is running the dev server or dev build (import.meta.env.DEV). Not the same as the reserved \`dev\` environment layer — see \`activeEnvironmentKey\`. */
|
|
153
|
+
export const isAstroDev = import.meta.env.DEV;
|
|
154
|
+
export const activeEnvironmentKey = ${JSON.stringify(runtime.activeEnvironment)};
|
|
155
|
+
|
|
156
|
+
export const affDevBootstrap = import.meta.env.DEV ? ${JSON.stringify(bootstrap)} : '';
|
|
157
|
+
|
|
158
|
+
export function isFeatureEnabled(flag) {
|
|
159
|
+
return Boolean(featureFlags[flag]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function shouldRenderFeature(flag) {
|
|
163
|
+
return isFeatureEnabled(flag);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isFeatureRoute(pathname) {
|
|
167
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
168
|
+
return Object.keys(featureRouteFlags).some((route) => {
|
|
169
|
+
const candidate = affRoutePatternToPrefix(route);
|
|
170
|
+
return normalized === candidate || normalized.startsWith(candidate);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function shouldIncludePath(pathname) {
|
|
175
|
+
if (import.meta.env.DEV) return true;
|
|
176
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
177
|
+
for (const [route, flags] of Object.entries(featureRouteFlags)) {
|
|
178
|
+
const candidate = affRoutePatternToPrefix(route);
|
|
179
|
+
if (normalized === candidate || normalized.startsWith(candidate)) {
|
|
180
|
+
return flags.every((flag) => isFeatureEnabled(flag));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function flagsForEnvironment(envName) {
|
|
187
|
+
var F = featureFlagsByEnvironment[envName];
|
|
188
|
+
return F != null ? F : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function isFeatureEnabledForEnvironment(flag, envName) {
|
|
192
|
+
var F = featureFlagsByEnvironment[envName];
|
|
193
|
+
return !!(F && F[flag]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function shouldIncludePathForEnvironment(pathname, envName) {
|
|
197
|
+
var F = featureFlagsByEnvironment[envName];
|
|
198
|
+
if (!F) return true;
|
|
199
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
200
|
+
for (const [route, flagList] of Object.entries(featureRouteFlags)) {
|
|
201
|
+
const candidate = affRoutePatternToPrefix(route);
|
|
202
|
+
if (normalized === candidate || normalized.startsWith(candidate)) {
|
|
203
|
+
return flagList.every((flag) => Boolean(F[flag]));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const flagNameToToken = Object.freeze(${flagNameToToken});
|
|
210
|
+
|
|
211
|
+
export function matchedFeatureRoutePrefix(pathname) {
|
|
212
|
+
if (!pathname) return null;
|
|
213
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
214
|
+
let best = null;
|
|
215
|
+
let bestLen = -1;
|
|
216
|
+
for (const routePrefix of Object.keys(featureRouteFlags)) {
|
|
217
|
+
const route = affRoutePatternToPrefix(routePrefix);
|
|
218
|
+
if (normalized === route || normalized.startsWith(route)) {
|
|
219
|
+
if (route.length > bestLen) {
|
|
220
|
+
bestLen = route.length;
|
|
221
|
+
best = routePrefix;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return best;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function routeFeatureTokenForPath(pathname) {
|
|
229
|
+
const prefix = matchedFeatureRoutePrefix(pathname);
|
|
230
|
+
if (!prefix) return undefined;
|
|
231
|
+
const flagNames = featureRouteFlags[prefix] || [];
|
|
232
|
+
const first = flagNames[0];
|
|
233
|
+
return first != null ? flagNameToToken[first] : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function routeFeatureTokensForPath(pathname) {
|
|
237
|
+
if (!pathname) return [];
|
|
238
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
239
|
+
const out = [];
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const [routePrefix, flagNames] of Object.entries(featureRouteFlags)) {
|
|
242
|
+
const route = affRoutePatternToPrefix(routePrefix);
|
|
243
|
+
if (!(normalized === route || normalized.startsWith(route))) continue;
|
|
244
|
+
for (const name of flagNames || []) {
|
|
245
|
+
const token = flagNameToToken[name];
|
|
246
|
+
if (!token || seen.has(token)) continue;
|
|
247
|
+
seen.add(token);
|
|
248
|
+
out.push(token);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function routeFeatureMatchesForPath(pathname) {
|
|
255
|
+
if (!pathname) return [];
|
|
256
|
+
const normalized = pathname.endsWith('/') ? pathname : pathname + '/';
|
|
257
|
+
var byFlag = {};
|
|
258
|
+
for (const [routePattern, flagNames] of Object.entries(featureRouteFlags)) {
|
|
259
|
+
const candidate = affRoutePatternToPrefix(routePattern);
|
|
260
|
+
if (!(normalized === candidate || normalized.startsWith(candidate))) continue;
|
|
261
|
+
for (const flagName of flagNames || []) {
|
|
262
|
+
const token = flagNameToToken[flagName];
|
|
263
|
+
if (!token) continue;
|
|
264
|
+
if (!byFlag[flagName]) byFlag[flagName] = { flag: flagName, token, routes: [] };
|
|
265
|
+
byFlag[flagName].routes.push(routePattern);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return Object.values(byFlag);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const featureFlagStyles = import.meta.env.DEV ? ${JSON.stringify(styles)} : "";
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function getResolvedFeatures(
|
|
276
|
+
options: ResolveFeatureRuntimeOptions = {},
|
|
277
|
+
): ResolvedFeatureRuntime {
|
|
278
|
+
return resolveFeatureRuntime(withDefaultEnvironments(options));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function featureRouteIncluded(
|
|
282
|
+
pathname: string,
|
|
283
|
+
runtime: ResolvedFeatureRuntime,
|
|
284
|
+
): boolean {
|
|
285
|
+
return shouldIncludeRoute({
|
|
286
|
+
pathname,
|
|
287
|
+
routeFlags: runtime.routeFlags,
|
|
288
|
+
flags: runtime.flags,
|
|
289
|
+
isDev: runtime.isDev,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export type { FeatureFlagMap } from "./runtime";
|
|
294
|
+
export {
|
|
295
|
+
longestMatchingRoutePrefix,
|
|
296
|
+
mergeFlagsWithProcessEnvOverrides,
|
|
297
|
+
resolveFeatureFlagsByEnvironment,
|
|
298
|
+
routePatternToPrefix,
|
|
299
|
+
} from "./runtime";
|
|
300
|
+
|
|
301
|
+
export default function astroFeatureFlags(
|
|
302
|
+
options: AstroFeatureFlagsOptions = {},
|
|
303
|
+
): any {
|
|
304
|
+
const { css, staticMinify = true, ...flagOpts } = options;
|
|
305
|
+
const opts = withDefaultEnvironments(flagOpts);
|
|
306
|
+
const mode = opts.mode ?? process.env.NODE_ENV ?? "development";
|
|
307
|
+
|
|
308
|
+
const runtime = resolveFeatureRuntime({
|
|
309
|
+
...opts,
|
|
310
|
+
mode,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
name: "astro-feature-flags",
|
|
315
|
+
hooks: {
|
|
316
|
+
"astro:config:setup": ({
|
|
317
|
+
updateConfig,
|
|
318
|
+
addDevToolbarApp,
|
|
319
|
+
command,
|
|
320
|
+
injectScript,
|
|
321
|
+
}: {
|
|
322
|
+
updateConfig: (config: unknown) => void;
|
|
323
|
+
addDevToolbarApp?: (opts: {
|
|
324
|
+
id: string;
|
|
325
|
+
name: string;
|
|
326
|
+
icon: string;
|
|
327
|
+
entrypoint: string;
|
|
328
|
+
}) => void;
|
|
329
|
+
command?: string;
|
|
330
|
+
injectScript?: (stage: string, content: string) => void;
|
|
331
|
+
}) => {
|
|
332
|
+
const flagNames = Object.keys(runtime.flags);
|
|
333
|
+
const flagTokens = flagNames.map((name) => toToken(name));
|
|
334
|
+
const flagsByEnvironment = resolveFeatureFlagsByEnvironment(opts);
|
|
335
|
+
const flagNameToToken = Object.fromEntries(
|
|
336
|
+
flagNames.map((name) => [name, toToken(name)]),
|
|
337
|
+
);
|
|
338
|
+
const bootstrap = buildAffDevBootstrapScript(
|
|
339
|
+
flagTokens,
|
|
340
|
+
{ ...runtime.flagColorsByToken },
|
|
341
|
+
{ ...runtime.flagOutlineDefaultsByToken },
|
|
342
|
+
{ ...runtime.flagBadgeDefaultsByToken },
|
|
343
|
+
{ ...runtime.routeFlags },
|
|
344
|
+
flagNameToToken,
|
|
345
|
+
runtime.namespace,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
command === "dev" &&
|
|
350
|
+
runtime.isDev &&
|
|
351
|
+
typeof addDevToolbarApp === "function"
|
|
352
|
+
) {
|
|
353
|
+
addDevToolbarApp({
|
|
354
|
+
id: "astro-feature-flags",
|
|
355
|
+
name: "Feature flags",
|
|
356
|
+
icon: DEV_TOOLBAR_FLAG_ICON_SVG,
|
|
357
|
+
entrypoint: fileURLToPath(
|
|
358
|
+
new URL("./dev-toolbar-app.mjs", import.meta.url),
|
|
359
|
+
),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
command === "dev" &&
|
|
364
|
+
runtime.isDev &&
|
|
365
|
+
typeof injectScript === "function"
|
|
366
|
+
) {
|
|
367
|
+
const styles = createFeatureFlagStyles(runtime, css);
|
|
368
|
+
injectScript(
|
|
369
|
+
"head-inline",
|
|
370
|
+
buildAffDevHeadInline({
|
|
371
|
+
runtime,
|
|
372
|
+
featureFlagStyles: styles,
|
|
373
|
+
affDevBootstrap: bootstrap,
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
updateConfig({
|
|
378
|
+
vite: {
|
|
379
|
+
plugins: [
|
|
380
|
+
{
|
|
381
|
+
name: "astro-feature-flags:virtual-module",
|
|
382
|
+
resolveId(id: string) {
|
|
383
|
+
if (id === "virtual:astro-feature-flags")
|
|
384
|
+
return "\0virtual:astro-feature-flags";
|
|
385
|
+
return null;
|
|
386
|
+
},
|
|
387
|
+
load(id: string) {
|
|
388
|
+
if (id === "\0virtual:astro-feature-flags") {
|
|
389
|
+
return createVirtualModuleSource(
|
|
390
|
+
runtime,
|
|
391
|
+
css,
|
|
392
|
+
flagsByEnvironment,
|
|
393
|
+
bootstrap,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
"astro:build:done": ({ dir }: { dir: URL }) => {
|
|
404
|
+
if (runtime.isDev || !staticMinify) return;
|
|
405
|
+
const outDir = fileURLToPath(dir);
|
|
406
|
+
const prunePaths = routePathsToPrune({
|
|
407
|
+
routeFlags: runtime.routeFlags,
|
|
408
|
+
flags: runtime.flags,
|
|
409
|
+
});
|
|
410
|
+
for (const routePath of prunePaths) {
|
|
411
|
+
rmSync(join(outDir, routePath), { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
applyProductionHtmlCullToDist(outDir, runtime);
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function compactInlineScript(source: string): string {
|
|
2
|
+
const lines = source
|
|
3
|
+
.split("\n")
|
|
4
|
+
.map((line) => line.trim())
|
|
5
|
+
.filter((line, idx, arr) => line.length > 0 || (idx > 0 && idx < arr.length - 1));
|
|
6
|
+
return lines.join("\n").trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function inlineInvoke<Payload>(
|
|
10
|
+
fn: (payload: Payload) => void,
|
|
11
|
+
payload: Payload,
|
|
12
|
+
): string {
|
|
13
|
+
return compactInlineScript(`(${fn.toString()})(${JSON.stringify(payload)});`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse, type HTMLElement } from "node-html-parser";
|
|
4
|
+
import type { ResolvedFeatureRuntime } from "./runtime";
|
|
5
|
+
import { toToken } from "./runtime";
|
|
6
|
+
|
|
7
|
+
function walkHtmlFiles(dir: string): string[] {
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
for (const ent of entries) {
|
|
11
|
+
const p = join(dir, ent.name);
|
|
12
|
+
if (ent.isDirectory()) out.push(...walkHtmlFiles(p));
|
|
13
|
+
else if (ent.isFile() && ent.name.endsWith(".html")) out.push(p);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function namespaceAttrName(namespace: string): string {
|
|
19
|
+
return `data-${toToken(namespace) || "ff"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function elementFlagSlugs(
|
|
23
|
+
el: HTMLElement,
|
|
24
|
+
nsAttr: string,
|
|
25
|
+
knownTokens: readonly string[],
|
|
26
|
+
): string[] {
|
|
27
|
+
const slugSet = new Set<string>();
|
|
28
|
+
const combined = el.getAttribute(nsAttr)?.trim();
|
|
29
|
+
if (combined) {
|
|
30
|
+
for (const part of combined.split(/\s+/).filter(Boolean)) {
|
|
31
|
+
slugSet.add(part);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const tk of knownTokens) {
|
|
35
|
+
if (el.hasAttribute(`${nsAttr}-${tk}`)) slugSet.add(tk);
|
|
36
|
+
}
|
|
37
|
+
return [...slugSet];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function slugToFlagNames(slug: string, flagNames: readonly string[]): string[] {
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
for (const name of flagNames) {
|
|
43
|
+
if (slug === name || slug === toToken(name)) out.push(name);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shouldCullElement(
|
|
49
|
+
el: HTMLElement,
|
|
50
|
+
runtime: ResolvedFeatureRuntime,
|
|
51
|
+
nsAttr: string,
|
|
52
|
+
knownTokens: readonly string[],
|
|
53
|
+
flagNames: readonly string[],
|
|
54
|
+
): boolean {
|
|
55
|
+
const slugs = elementFlagSlugs(el, nsAttr, knownTokens);
|
|
56
|
+
if (slugs.length === 0) return false;
|
|
57
|
+
for (const slug of slugs) {
|
|
58
|
+
const names = slugToFlagNames(slug, flagNames);
|
|
59
|
+
if (names.length === 0) return false;
|
|
60
|
+
}
|
|
61
|
+
for (const slug of slugs) {
|
|
62
|
+
const names = slugToFlagNames(slug, flagNames);
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
if (!runtime.flags[name]) return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function elementDepth(el: HTMLElement): number {
|
|
71
|
+
let d = 0;
|
|
72
|
+
let n: HTMLElement | null = el.parentNode as HTMLElement | null;
|
|
73
|
+
while (n) {
|
|
74
|
+
d++;
|
|
75
|
+
n = n.parentNode as HTMLElement | null;
|
|
76
|
+
}
|
|
77
|
+
return d;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Production static HTML: remove nodes gated by disabled flags (same rules as dev
|
|
82
|
+
* `data-*` / `data-*-<token>`), strip dev-only route attributes on `<html>`, and drop
|
|
83
|
+
* empty `<style>` tags left after `featureFlagStyles` is empty.
|
|
84
|
+
*/
|
|
85
|
+
export function cullProductionHtml(
|
|
86
|
+
html: string,
|
|
87
|
+
runtime: ResolvedFeatureRuntime,
|
|
88
|
+
): string {
|
|
89
|
+
const root = parse(html) as HTMLElement;
|
|
90
|
+
const nsAttr = namespaceAttrName(runtime.namespace);
|
|
91
|
+
const flagNames = Object.keys(runtime.flags);
|
|
92
|
+
const knownTokens = flagNames.map((n) => toToken(n));
|
|
93
|
+
|
|
94
|
+
const candidates = root.querySelectorAll("*") as unknown as HTMLElement[];
|
|
95
|
+
const toRemove: HTMLElement[] = [];
|
|
96
|
+
for (const el of candidates) {
|
|
97
|
+
if (shouldCullElement(el, runtime, nsAttr, knownTokens, flagNames)) {
|
|
98
|
+
toRemove.push(el);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
toRemove.sort((a, b) => elementDepth(b) - elementDepth(a));
|
|
102
|
+
for (const el of toRemove) {
|
|
103
|
+
el.remove();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const htmlEl = root.querySelector("html") as HTMLElement | null;
|
|
107
|
+
if (htmlEl) {
|
|
108
|
+
htmlEl.removeAttribute("data-ff-route");
|
|
109
|
+
htmlEl.removeAttribute("data-ff-route-label");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const styles = root.querySelectorAll("style") as unknown as HTMLElement[];
|
|
113
|
+
for (const st of styles) {
|
|
114
|
+
const text = st.textContent ?? "";
|
|
115
|
+
if (!text.trim()) st.remove();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return root.outerHTML;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Walk `outDir` (e.g. Astro `dist/`) and apply {@link cullProductionHtml} to every `.html` file.
|
|
123
|
+
*/
|
|
124
|
+
export function applyProductionHtmlCullToDist(
|
|
125
|
+
outDir: string,
|
|
126
|
+
runtime: ResolvedFeatureRuntime,
|
|
127
|
+
): void {
|
|
128
|
+
for (const file of walkHtmlFiles(outDir)) {
|
|
129
|
+
const before = readFileSync(file, "utf8");
|
|
130
|
+
const after = cullProductionHtml(before, runtime);
|
|
131
|
+
if (after !== before) writeFileSync(file, after, "utf8");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JS helper source used by generated browser scripts.
|
|
3
|
+
* Keep this in sync with runtime.routePatternToPrefix semantics.
|
|
4
|
+
*/
|
|
5
|
+
export function routePrefixJsHelper(functionName: string): string {
|
|
6
|
+
return `function ${functionName}(pattern){var p=String(pattern||'').trim();if(p.endsWith('/**'))p=p.slice(0,-3);else if(p.endsWith('/*'))p=p.slice(0,-2);else if(p.length>1&&p.endsWith('*'))p=p.slice(0,-1);return p.endsWith('/')?p:p+'/';}`;
|
|
7
|
+
}
|