@at-flux/astro-feature-flags 1.0.3 → 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 +13 -12
- package/dist/index.d.mts +74 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +195 -12
- package/dist/index.mjs.map +1 -1
- package/docs/how-to/hide-from-sitemaps.md +56 -10
- package/package.json +1 -1
- package/src/dev-head-inject.ts +2 -0
- package/src/dev-inline-runtimes.ts +29 -1
- package/src/dev-outline-css.ts +42 -5
- package/src/index.ts +49 -3
- package/src/sitemap-prune.ts +171 -0
package/src/dev-outline-css.ts
CHANGED
|
@@ -8,6 +8,13 @@ import { affDevBootstrapRuntime } from "./dev-inline-runtimes";
|
|
|
8
8
|
import type { ResolvedFeatureRuntime } from "./runtime";
|
|
9
9
|
import { toToken } from "./runtime";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Cascade layer for the declarations the app must be able to override without
|
|
13
|
+
* writing `!important`. Named rather than anonymous so the order statement in
|
|
14
|
+
* {@link affHeadInlineRuntime} can name it too.
|
|
15
|
+
*/
|
|
16
|
+
export const DEV_LAYER = "aff-dev";
|
|
17
|
+
|
|
11
18
|
export type DevOutlineHiddenStrategy = "visibility" | "display";
|
|
12
19
|
|
|
13
20
|
export interface DevOutlineCssOptions extends ElementBadgeLayoutOptions {
|
|
@@ -99,6 +106,28 @@ function featureColorVar(token: string, namespace: string): string {
|
|
|
99
106
|
* Dev-only styles: `data-ff="token"` (or space-separated tokens) gates outlines/badges.
|
|
100
107
|
* `html[data-ff-route="<token>"]` (dev) adds a fixed top-right route badge (label only).
|
|
101
108
|
* Toolbar toggles: `data-ff-enabled-*`, `data-ff-outline-*`, `data-ff-badge-*`, `--<namespace>-c-*`.
|
|
109
|
+
*
|
|
110
|
+
* The badge is an absolutely positioned `::before`, so its host has to be a
|
|
111
|
+
* containing block, and the host also wants rounding for the outline to follow.
|
|
112
|
+
* Both of those are the app's business, not ours: a flagged element that the app
|
|
113
|
+
* positions itself, or rounds itself, must keep doing so. Marking a flagged
|
|
114
|
+
* element must not move it.
|
|
115
|
+
*
|
|
116
|
+
* So `position` and `border-radius` are emitted inside `:where()` *and* inside
|
|
117
|
+
* the `aff-dev` cascade layer. `:where()` alone is not enough: an unlayered rule
|
|
118
|
+
* beats a layered one before specificity is ever consulted, so a zero-specificity
|
|
119
|
+
* unlayered rule still overrides Tailwind's `.rounded-full` and `.absolute`,
|
|
120
|
+
* which live in `@layer utilities`. Layering ours is what lets the app win.
|
|
121
|
+
*
|
|
122
|
+
* Layer priority follows the order layers are first declared, so the layer only
|
|
123
|
+
* sorts below the app's own layers if `@layer aff-dev;` appears before them in
|
|
124
|
+
* document order. The sheet opens with that statement for the case where it
|
|
125
|
+
* lands first, and {@link affHeadInlineRuntime} also prepends a style element
|
|
126
|
+
* carrying it as the first child of `<head>`, which is what actually guarantees
|
|
127
|
+
* the order at runtime.
|
|
128
|
+
*
|
|
129
|
+
* Everything else here is dev chrome the app has no opinion about, and stays at
|
|
130
|
+
* its natural specificity.
|
|
102
131
|
*/
|
|
103
132
|
export function createFeatureFlagStyles(
|
|
104
133
|
runtime: ResolvedFeatureRuntime,
|
|
@@ -142,11 +171,15 @@ html {
|
|
|
142
171
|
html:not([data-ff-outline-${token}="off"]) {
|
|
143
172
|
--aff-outline-c-${token}: var(${v}, ${col});
|
|
144
173
|
}
|
|
174
|
+
@layer ${DEV_LAYER} {
|
|
175
|
+
:where(${selOutline}) {
|
|
176
|
+
position: relative;
|
|
177
|
+
border-radius: ${opts.borderRadius};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
145
180
|
${selOutline} {
|
|
146
|
-
position: relative;
|
|
147
181
|
outline: ${opts.outlineWidth} solid var(--aff-outline-c-${token});
|
|
148
182
|
outline-offset: ${opts.outlineOffset};
|
|
149
|
-
border-radius: ${opts.borderRadius};
|
|
150
183
|
}
|
|
151
184
|
html:not([data-ff-badge-${token}="off"]) ${selIsSingleBadge}::before {
|
|
152
185
|
box-sizing: border-box;
|
|
@@ -230,11 +263,15 @@ html[data-ff-enabled-${token}="off"] ${selIs} {
|
|
|
230
263
|
|
|
231
264
|
// Multi-token value on a single element: show combined badge text and a gradient outline.
|
|
232
265
|
chunks.push(`
|
|
266
|
+
@layer ${DEV_LAYER} {
|
|
267
|
+
:where([${nsAttr}*=" "]) {
|
|
268
|
+
position: relative;
|
|
269
|
+
border-radius: ${opts.borderRadius};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
233
272
|
[${nsAttr}*=" "] {
|
|
234
|
-
position: relative;
|
|
235
273
|
outline: none !important;
|
|
236
274
|
outline-offset: 0 !important;
|
|
237
|
-
border-radius: ${opts.borderRadius};
|
|
238
275
|
}
|
|
239
276
|
html [${nsAttr}*=" "]::after {
|
|
240
277
|
content: "";
|
|
@@ -408,7 +445,7 @@ html[data-ff-outline-${token}="off"] ${selIs}[${nsAttr}*=" "]::after {
|
|
|
408
445
|
`);
|
|
409
446
|
}
|
|
410
447
|
|
|
411
|
-
return
|
|
448
|
+
return `\n@layer ${DEV_LAYER};\n` + chunks.join("\n") + "\n";
|
|
412
449
|
}
|
|
413
450
|
|
|
414
451
|
/**
|
package/src/index.ts
CHANGED
|
@@ -42,21 +42,38 @@ import {
|
|
|
42
42
|
buildAffDevBootstrapScript,
|
|
43
43
|
createFeatureFlagStyles,
|
|
44
44
|
createProductionGateStyles,
|
|
45
|
+
DEV_LAYER,
|
|
45
46
|
} from "./dev-outline-css";
|
|
46
47
|
import { applyProductionHtmlCullToDist } from "./production-html-cull";
|
|
48
|
+
import { applySitemapPruneToDist } from "./sitemap-prune";
|
|
47
49
|
import { buildAffDevHeadInline } from "./dev-head-inject";
|
|
48
50
|
import { routePrefixJsHelper } from "./route-prefix-js";
|
|
49
51
|
|
|
50
|
-
export { createFeatureFlagStyles, createProductionGateStyles };
|
|
52
|
+
export { createFeatureFlagStyles, createProductionGateStyles, DEV_LAYER };
|
|
51
53
|
export {
|
|
52
54
|
cullProductionHtml,
|
|
53
55
|
applyProductionHtmlCullToDist,
|
|
54
56
|
} from "./production-html-cull";
|
|
57
|
+
export type { SitemapPruneResult } from "./sitemap-prune";
|
|
58
|
+
export {
|
|
59
|
+
applySitemapPruneToDist,
|
|
60
|
+
pruneSitemapIndexXml,
|
|
61
|
+
pruneSitemapXml,
|
|
62
|
+
sitemapUrlCount,
|
|
63
|
+
} from "./sitemap-prune";
|
|
55
64
|
|
|
56
65
|
export interface AstroFeatureFlagsOptions extends ResolveFeatureRuntimeOptions {
|
|
57
66
|
css?: DevOutlineCssOptions;
|
|
58
67
|
/** When false, keeps static build output untouched (no route pruning / HTML cull). */
|
|
59
68
|
staticMinify?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* When false, leaves generated sitemaps alone. On by default: a pruned route that is
|
|
71
|
+
* still advertised in `sitemap-0.xml` is a 404 handed to every crawler that reads it.
|
|
72
|
+
*
|
|
73
|
+
* Needs this integration to sit **after** `@astrojs/sitemap` in `integrations`, because
|
|
74
|
+
* Astro runs `astro:build:done` in array order.
|
|
75
|
+
*/
|
|
76
|
+
pruneSitemap?: boolean;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/** Prefer `prod` if present; else first non-`dev` key (sorted); else `"prod"`. */
|
|
@@ -304,7 +321,12 @@ export {
|
|
|
304
321
|
export default function astroFeatureFlags(
|
|
305
322
|
options: AstroFeatureFlagsOptions = {},
|
|
306
323
|
): any {
|
|
307
|
-
const {
|
|
324
|
+
const {
|
|
325
|
+
css,
|
|
326
|
+
staticMinify = true,
|
|
327
|
+
pruneSitemap = true,
|
|
328
|
+
...flagOpts
|
|
329
|
+
} = options;
|
|
308
330
|
const opts = withDefaultEnvironments(flagOpts);
|
|
309
331
|
const mode = opts.mode ?? process.env.NODE_ENV ?? "development";
|
|
310
332
|
|
|
@@ -313,15 +335,23 @@ export default function astroFeatureFlags(
|
|
|
313
335
|
mode,
|
|
314
336
|
});
|
|
315
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Set in `astro:config:setup`, read in `astro:build:done`, so that a sitemap this
|
|
340
|
+
* pass never saw can be reported as an ordering mistake rather than silently skipped.
|
|
341
|
+
*/
|
|
342
|
+
let sitemapIntegrationPresent = false;
|
|
343
|
+
|
|
316
344
|
return {
|
|
317
345
|
name: "astro-feature-flags",
|
|
318
346
|
hooks: {
|
|
319
347
|
"astro:config:setup": ({
|
|
348
|
+
config,
|
|
320
349
|
updateConfig,
|
|
321
350
|
addDevToolbarApp,
|
|
322
351
|
command,
|
|
323
352
|
injectScript,
|
|
324
353
|
}: {
|
|
354
|
+
config?: { integrations?: { name?: string }[] };
|
|
325
355
|
updateConfig: (config: unknown) => void;
|
|
326
356
|
addDevToolbarApp?: (opts: {
|
|
327
357
|
id: string;
|
|
@@ -332,6 +362,9 @@ export default function astroFeatureFlags(
|
|
|
332
362
|
command?: string;
|
|
333
363
|
injectScript?: (stage: string, content: string) => void;
|
|
334
364
|
}) => {
|
|
365
|
+
sitemapIntegrationPresent = (config?.integrations ?? []).some(
|
|
366
|
+
(integration) => integration?.name === "@astrojs/sitemap",
|
|
367
|
+
);
|
|
335
368
|
const flagNames = Object.keys(runtime.flags);
|
|
336
369
|
const flagTokens = flagNames.map((name) => toToken(name));
|
|
337
370
|
const flagsByEnvironment = resolveFeatureFlagsByEnvironment(opts);
|
|
@@ -403,7 +436,13 @@ export default function astroFeatureFlags(
|
|
|
403
436
|
},
|
|
404
437
|
});
|
|
405
438
|
},
|
|
406
|
-
"astro:build:done": ({
|
|
439
|
+
"astro:build:done": ({
|
|
440
|
+
dir,
|
|
441
|
+
logger,
|
|
442
|
+
}: {
|
|
443
|
+
dir: URL;
|
|
444
|
+
logger?: { warn: (message: string) => void };
|
|
445
|
+
}) => {
|
|
407
446
|
if (runtime.isDev || !staticMinify) return;
|
|
408
447
|
const outDir = fileURLToPath(dir);
|
|
409
448
|
const prunePaths = routePathsToPrune({
|
|
@@ -414,6 +453,13 @@ export default function astroFeatureFlags(
|
|
|
414
453
|
rmSync(join(outDir, routePath), { recursive: true, force: true });
|
|
415
454
|
}
|
|
416
455
|
applyProductionHtmlCullToDist(outDir, runtime);
|
|
456
|
+
if (!pruneSitemap) return;
|
|
457
|
+
const sitemaps = applySitemapPruneToDist(outDir, runtime);
|
|
458
|
+
if (!sitemaps.found && sitemapIntegrationPresent && prunePaths.length) {
|
|
459
|
+
logger?.warn(
|
|
460
|
+
"@astrojs/sitemap is configured but no sitemap was on disk yet, so pruned routes may still be listed. Move astroFeatureFlags() after sitemap() in `integrations`.",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
417
463
|
},
|
|
418
464
|
},
|
|
419
465
|
};
|
|
@@ -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
|
+
}
|