@at-flux/astro-feature-flags 1.0.2 → 1.0.4
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/README.md +32 -31
- package/dist/dev-toolbar-app.d.mts +0 -1
- package/dist/dev-toolbar-app.d.mts.map +1 -1
- package/dist/dev-toolbar-app.mjs +1 -1
- package/dist/dev-toolbar-app.mjs.map +1 -1
- package/dist/{dev-toolbar-flag-icon-BHCQJ53N.mjs → dev-toolbar-flag-icon-MQsIFekv.mjs} +1 -1
- package/dist/{dev-toolbar-flag-icon-BHCQJ53N.mjs.map → dev-toolbar-flag-icon-MQsIFekv.mjs.map} +1 -1
- package/dist/index.d.mts +74 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +204 -18
- package/dist/index.mjs.map +1 -1
- package/dist/runtime.d.mts +4 -11
- package/dist/runtime.d.mts.map +1 -1
- package/dist/runtime.mjs.map +1 -1
- package/docs/how-to/hide-from-sitemaps.md +56 -10
- package/package.json +7 -7
- package/src/dev-head-inject.ts +2 -0
- package/src/dev-inline-runtimes.ts +77 -15
- package/src/dev-outline-css.ts +48 -7
- package/src/dev-toolbar-app.ts +2 -4
- package/src/index.ts +60 -11
- package/src/inline-script.ts +3 -1
- package/src/runtime.ts +18 -11
- package/src/sitemap-prune.ts +171 -0
package/src/runtime.ts
CHANGED
|
@@ -246,7 +246,9 @@ function normalizeDeclarativeConfig(
|
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
function assertEnvironmentShape(
|
|
249
|
+
function assertEnvironmentShape(
|
|
250
|
+
environments: Record<string, NormalizedEnvEntry>,
|
|
251
|
+
) {
|
|
250
252
|
const keys = Object.keys(environments);
|
|
251
253
|
if (!environments.dev) {
|
|
252
254
|
throw new Error(
|
|
@@ -260,7 +262,9 @@ function assertEnvironmentShape(environments: Record<string, NormalizedEnvEntry>
|
|
|
260
262
|
}
|
|
261
263
|
}
|
|
262
264
|
|
|
263
|
-
function countActiveWhen(
|
|
265
|
+
function countActiveWhen(
|
|
266
|
+
environments: Record<string, NormalizedEnvEntry>,
|
|
267
|
+
): number {
|
|
264
268
|
return Object.values(environments).filter((e) => e.when === true).length;
|
|
265
269
|
}
|
|
266
270
|
|
|
@@ -305,13 +309,11 @@ function resolveActiveEnvironmentName(
|
|
|
305
309
|
throw new Error("astro-feature-flags: internal error resolving environment.");
|
|
306
310
|
}
|
|
307
311
|
|
|
308
|
-
function readBaseMergedRaw(
|
|
312
|
+
function readBaseMergedRaw(
|
|
313
|
+
options: ResolveFeatureRuntimeOptions,
|
|
314
|
+
): Record<string, unknown> {
|
|
309
315
|
const configRoot = options.configRoot ?? process.cwd();
|
|
310
|
-
const {
|
|
311
|
-
tokenNamespace = "ff",
|
|
312
|
-
flags = {},
|
|
313
|
-
environments = {},
|
|
314
|
-
} = options;
|
|
316
|
+
const { tokenNamespace = "ff", flags = {}, environments = {} } = options;
|
|
315
317
|
const inlineRaw: Record<string, unknown> = {
|
|
316
318
|
tokenNamespace,
|
|
317
319
|
flags,
|
|
@@ -320,7 +322,10 @@ function readBaseMergedRaw(options: ResolveFeatureRuntimeOptions): Record<string
|
|
|
320
322
|
let merged = { ...inlineRaw };
|
|
321
323
|
if (options.jsonConfigPath) {
|
|
322
324
|
const p = resolvePath(options.jsonConfigPath, configRoot);
|
|
323
|
-
merged = mergeRecords(
|
|
325
|
+
merged = mergeRecords(
|
|
326
|
+
merged as Record<string, unknown>,
|
|
327
|
+
readJsonIfExists(p),
|
|
328
|
+
);
|
|
324
329
|
}
|
|
325
330
|
return merged;
|
|
326
331
|
}
|
|
@@ -433,7 +438,8 @@ export function resolveFeatureFlagsByEnvironment(
|
|
|
433
438
|
const mode = options.mode ?? process.env.NODE_ENV ?? "development";
|
|
434
439
|
const normalized = normalizeDeclarativeConfig(baseRaw, mode);
|
|
435
440
|
const envKeys = Object.keys(normalized.environments);
|
|
436
|
-
const proc =
|
|
441
|
+
const proc =
|
|
442
|
+
options.env ?? (process.env as Record<string, string | undefined>);
|
|
437
443
|
const out: Record<string, FeatureFlagMap> = {};
|
|
438
444
|
for (const envName of envKeys) {
|
|
439
445
|
const cfg = loadFeatureConfig({ ...options, forceEnvironment: envName });
|
|
@@ -474,7 +480,8 @@ export interface ResolvedFeatureRuntime {
|
|
|
474
480
|
export function resolveFeatureRuntime(
|
|
475
481
|
options: ResolveFeatureRuntimeOptions = {},
|
|
476
482
|
): ResolvedFeatureRuntime {
|
|
477
|
-
const env =
|
|
483
|
+
const env =
|
|
484
|
+
options.env ?? (process.env as Record<string, string | undefined>);
|
|
478
485
|
const config = loadFeatureConfig(options);
|
|
479
486
|
const isDevLayer = config.activeEnvironment === "dev";
|
|
480
487
|
const resolvedFlags = isDevLayer
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { ResolvedFeatureRuntime } from "./runtime";
|
|
4
|
+
import { shouldIncludeRoute } from "./runtime";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `@astrojs/sitemap` names its output `sitemap-0.xml`, `sitemap-1.xml`, … alongside a
|
|
8
|
+
* `sitemap-index.xml`. Other generators write a plain `sitemap.xml`. Match the family.
|
|
9
|
+
*/
|
|
10
|
+
const SITEMAP_FILE = /^sitemap[\w.-]*\.xml$/i;
|
|
11
|
+
|
|
12
|
+
function walkSitemapFiles(dir: string): string[] {
|
|
13
|
+
const out: string[] = [];
|
|
14
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
15
|
+
const p = join(dir, ent.name);
|
|
16
|
+
if (ent.isDirectory()) out.push(...walkSitemapFiles(p));
|
|
17
|
+
else if (ent.isFile() && SITEMAP_FILE.test(ent.name)) out.push(p);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `<loc>` is XML, so the five predefined entities and numeric references are all
|
|
24
|
+
* legal in it. `&` is the one that actually turns up (a query string with two
|
|
25
|
+
* parameters), but a path that came through an escaper wholesale can carry the
|
|
26
|
+
* others, and an entity left undecoded turns into a pathname that matches no
|
|
27
|
+
* route and is silently kept.
|
|
28
|
+
*/
|
|
29
|
+
function decodeXmlEntities(value: string): string {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) =>
|
|
32
|
+
String.fromCodePoint(Number.parseInt(hex, 16)),
|
|
33
|
+
)
|
|
34
|
+
.replace(/&#(\d+);/g, (_, dec: string) =>
|
|
35
|
+
String.fromCodePoint(Number.parseInt(dec, 10)),
|
|
36
|
+
)
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, "'")
|
|
41
|
+
.replace(/&/g, "&");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function locPathname(entry: string): string | null {
|
|
45
|
+
const loc = /<loc>\s*([\s\S]*?)\s*<\/loc>/i.exec(entry)?.[1];
|
|
46
|
+
if (!loc) return null;
|
|
47
|
+
const href = decodeXmlEntities(loc).trim();
|
|
48
|
+
try {
|
|
49
|
+
return new URL(href).pathname;
|
|
50
|
+
} catch {
|
|
51
|
+
return href.startsWith("/") ? href : null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop every `<url>` whose `<loc>` points at a route this runtime prunes.
|
|
57
|
+
*
|
|
58
|
+
* The alternative is asking every site to duplicate the flag decision in its own
|
|
59
|
+
* `sitemap({ filter })`, which is what the docs used to say and what the site this
|
|
60
|
+
* package was written for got subtly wrong: a substring test culled `/blog/about-x/`
|
|
61
|
+
* along with `/about/`. Deciding it here, from the same runtime that deletes the
|
|
62
|
+
* files, means the two answers cannot drift.
|
|
63
|
+
*
|
|
64
|
+
* String surgery rather than an XML parse, so the untouched entries come back
|
|
65
|
+
* byte-identical and the diff of a rebuild stays readable.
|
|
66
|
+
*/
|
|
67
|
+
export function pruneSitemapXml(
|
|
68
|
+
xml: string,
|
|
69
|
+
runtime: ResolvedFeatureRuntime,
|
|
70
|
+
): string {
|
|
71
|
+
return xml.replace(/[ \t]*<url>[\s\S]*?<\/url>\s*/gi, (entry) => {
|
|
72
|
+
const pathname = locPathname(entry);
|
|
73
|
+
if (!pathname) return entry;
|
|
74
|
+
const keep = shouldIncludeRoute({
|
|
75
|
+
pathname,
|
|
76
|
+
routeFlags: runtime.routeFlags,
|
|
77
|
+
flags: runtime.flags,
|
|
78
|
+
isDev: false,
|
|
79
|
+
});
|
|
80
|
+
return keep ? entry : "";
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Number of `<url>` entries left in a urlset. */
|
|
85
|
+
export function sitemapUrlCount(xml: string): number {
|
|
86
|
+
return (xml.match(/<url>/gi) ?? []).length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Remove the `<sitemap>` entries of an index that point at files which no longer exist.
|
|
91
|
+
*/
|
|
92
|
+
export function pruneSitemapIndexXml(
|
|
93
|
+
xml: string,
|
|
94
|
+
removedFiles: readonly string[],
|
|
95
|
+
): string {
|
|
96
|
+
if (!removedFiles.length) return xml;
|
|
97
|
+
const removed = new Set(removedFiles);
|
|
98
|
+
return xml.replace(/[ \t]*<sitemap>[\s\S]*?<\/sitemap>\s*/gi, (entry) => {
|
|
99
|
+
const pathname = locPathname(entry);
|
|
100
|
+
if (!pathname) return entry;
|
|
101
|
+
return removed.has(basename(pathname)) ? "" : entry;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SitemapPruneResult {
|
|
106
|
+
/** Sitemap files rewritten with fewer entries. */
|
|
107
|
+
rewritten: string[];
|
|
108
|
+
/** Sitemap files deleted because nothing was left in them. */
|
|
109
|
+
removed: string[];
|
|
110
|
+
/** Whether any sitemap file was found at all. */
|
|
111
|
+
found: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Walk `outDir` (Astro `dist/`) and take the pruned routes out of every sitemap.
|
|
116
|
+
*
|
|
117
|
+
* Runs in `astro:build:done`, which means this integration has to sit **after**
|
|
118
|
+
* `@astrojs/sitemap` in `integrations` — Astro runs the hook in array order, and a
|
|
119
|
+
* sitemap written after this pass would keep its dead URLs.
|
|
120
|
+
*/
|
|
121
|
+
export function applySitemapPruneToDist(
|
|
122
|
+
outDir: string,
|
|
123
|
+
runtime: ResolvedFeatureRuntime,
|
|
124
|
+
): SitemapPruneResult {
|
|
125
|
+
const result: SitemapPruneResult = {
|
|
126
|
+
rewritten: [],
|
|
127
|
+
removed: [],
|
|
128
|
+
found: false,
|
|
129
|
+
};
|
|
130
|
+
let files: string[];
|
|
131
|
+
try {
|
|
132
|
+
files = walkSitemapFiles(outDir);
|
|
133
|
+
} catch {
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
result.found = files.length > 0;
|
|
137
|
+
|
|
138
|
+
const indexes: string[] = [];
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
const before = readFileSync(file, "utf8");
|
|
141
|
+
if (!/<urlset[\s>]/i.test(before)) {
|
|
142
|
+
if (/<sitemapindex[\s>]/i.test(before)) indexes.push(file);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const after = pruneSitemapXml(before, runtime);
|
|
146
|
+
if (after === before) continue;
|
|
147
|
+
if (sitemapUrlCount(after) === 0) {
|
|
148
|
+
rmSync(file, { force: true });
|
|
149
|
+
result.removed.push(file);
|
|
150
|
+
} else {
|
|
151
|
+
writeFileSync(file, after, "utf8");
|
|
152
|
+
result.rewritten.push(file);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const removedNames = result.removed.map((file) => basename(file));
|
|
157
|
+
for (const file of indexes) {
|
|
158
|
+
const before = readFileSync(file, "utf8");
|
|
159
|
+
const after = pruneSitemapIndexXml(before, removedNames);
|
|
160
|
+
if (after === before) continue;
|
|
161
|
+
if (!/<sitemap>/i.test(after)) {
|
|
162
|
+
rmSync(file, { force: true });
|
|
163
|
+
result.removed.push(file);
|
|
164
|
+
} else {
|
|
165
|
+
writeFileSync(file, after, "utf8");
|
|
166
|
+
result.rewritten.push(file);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|