@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/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { t as DEV_TOOLBAR_FLAG_ICON_SVG } from "./dev-toolbar-flag-icon-BHCQJ53N.mjs";
1
+ import { t as DEV_TOOLBAR_FLAG_ICON_SVG } from "./dev-toolbar-flag-icon-MQsIFekv.mjs";
2
2
  import { longestMatchingRoutePrefix, mergeFlagsWithProcessEnvOverrides, resolveFeatureFlagsByEnvironment, resolveFeatureRuntime, routePathsToPrune, routePatternToPrefix, shouldIncludeRoute, toEnumKey, toToken } from "./runtime.mjs";
3
3
  import { readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
4
+ import { basename, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parse } from "node-html-parser";
7
7
  //#region src/badge-layout.ts
@@ -101,8 +101,18 @@ function affHeadInlineRuntime(payload) {
101
101
  return p.endsWith("/") ? p : `${p}/`;
102
102
  };
103
103
  try {
104
- const { featureFlagStyles, routeFlags: RF, flagNameToToken: M } = payload;
104
+ const { featureFlagStyles, routeFlags: RF, flagNameToToken: M, devLayer: L } = payload;
105
+ const ensureLayerOrder = () => {
106
+ if (document.querySelector("style[data-astro-feature-flags-layer]")) return;
107
+ const head = document.head || document.documentElement;
108
+ const s = document.createElement("style");
109
+ s.setAttribute("data-astro-feature-flags-layer", "");
110
+ s.setAttribute("data-astro-transition-persist", "astro-feature-flags-layer");
111
+ s.textContent = `@layer ${L};`;
112
+ head.insertBefore(s, head.firstChild);
113
+ };
105
114
  const ensureFeatureFlagStyles = () => {
115
+ ensureLayerOrder();
106
116
  if (document.querySelector("style[data-astro-feature-flags]")) return;
107
117
  const s = document.createElement("style");
108
118
  s.setAttribute("data-astro-feature-flags", "");
@@ -403,6 +413,12 @@ function affDevBootstrapRuntime(payload) {
403
413
  }
404
414
  //#endregion
405
415
  //#region src/dev-outline-css.ts
416
+ /**
417
+ * Cascade layer for the declarations the app must be able to override without
418
+ * writing `!important`. Named rather than anonymous so the order statement in
419
+ * {@link affHeadInlineRuntime} can name it too.
420
+ */
421
+ const DEV_LAYER = "aff-dev";
406
422
  const defaultDevOutlineCssOptions = {
407
423
  outlineWidth: "2px",
408
424
  outlineColor: "rgb(220 38 38)",
@@ -441,6 +457,28 @@ function featureColorVar(token, namespace) {
441
457
  * Dev-only styles: `data-ff="token"` (or space-separated tokens) gates outlines/badges.
442
458
  * `html[data-ff-route="<token>"]` (dev) adds a fixed top-right route badge (label only).
443
459
  * Toolbar toggles: `data-ff-enabled-*`, `data-ff-outline-*`, `data-ff-badge-*`, `--<namespace>-c-*`.
460
+ *
461
+ * The badge is an absolutely positioned `::before`, so its host has to be a
462
+ * containing block, and the host also wants rounding for the outline to follow.
463
+ * Both of those are the app's business, not ours: a flagged element that the app
464
+ * positions itself, or rounds itself, must keep doing so. Marking a flagged
465
+ * element must not move it.
466
+ *
467
+ * So `position` and `border-radius` are emitted inside `:where()` *and* inside
468
+ * the `aff-dev` cascade layer. `:where()` alone is not enough: an unlayered rule
469
+ * beats a layered one before specificity is ever consulted, so a zero-specificity
470
+ * unlayered rule still overrides Tailwind's `.rounded-full` and `.absolute`,
471
+ * which live in `@layer utilities`. Layering ours is what lets the app win.
472
+ *
473
+ * Layer priority follows the order layers are first declared, so the layer only
474
+ * sorts below the app's own layers if `@layer aff-dev;` appears before them in
475
+ * document order. The sheet opens with that statement for the case where it
476
+ * lands first, and {@link affHeadInlineRuntime} also prepends a style element
477
+ * carrying it as the first child of `<head>`, which is what actually guarantees
478
+ * the order at runtime.
479
+ *
480
+ * Everything else here is dev chrome the app has no opinion about, and stays at
481
+ * its natural specificity.
444
482
  */
445
483
  function createFeatureFlagStyles(runtime, css) {
446
484
  const nsAttr = `data-${toToken(runtime.namespace) || "ff"}`;
@@ -474,11 +512,15 @@ html {
474
512
  html:not([data-ff-outline-${token}="off"]) {
475
513
  --aff-outline-c-${token}: var(${v}, ${col});
476
514
  }
515
+ @layer ${DEV_LAYER} {
516
+ :where(${selOutline}) {
517
+ position: relative;
518
+ border-radius: ${opts.borderRadius};
519
+ }
520
+ }
477
521
  ${selOutline} {
478
- position: relative;
479
522
  outline: ${opts.outlineWidth} solid var(--aff-outline-c-${token});
480
523
  outline-offset: ${opts.outlineOffset};
481
- border-radius: ${opts.borderRadius};
482
524
  }
483
525
  html:not([data-ff-badge-${token}="off"]) ${selIsSingleBadge}::before {
484
526
  box-sizing: border-box;
@@ -560,11 +602,15 @@ html[data-ff-enabled-${token}="off"] ${selIs} {
560
602
  `);
561
603
  }
562
604
  chunks.push(`
605
+ @layer ${DEV_LAYER} {
606
+ :where([${nsAttr}*=" "]) {
607
+ position: relative;
608
+ border-radius: ${opts.borderRadius};
609
+ }
610
+ }
563
611
  [${nsAttr}*=" "] {
564
- position: relative;
565
612
  outline: none !important;
566
613
  outline-offset: 0 !important;
567
- border-radius: ${opts.borderRadius};
568
614
  }
569
615
  html [${nsAttr}*=" "]::after {
570
616
  content: "";
@@ -732,7 +778,7 @@ html[data-ff-outline-${token}="off"] ${selIs}[${nsAttr}*=" "]::after {
732
778
  }
733
779
  `);
734
780
  }
735
- return "\n" + chunks.join("\n") + "\n";
781
+ return `\n@layer ${DEV_LAYER};\n` + chunks.join("\n") + "\n";
736
782
  }
737
783
  /**
738
784
  * CSS-only hiding for disabled flags. The Astro integration no longer injects this into
@@ -842,6 +888,134 @@ function applyProductionHtmlCullToDist(outDir, runtime) {
842
888
  }
843
889
  }
844
890
  //#endregion
891
+ //#region src/sitemap-prune.ts
892
+ /**
893
+ * `@astrojs/sitemap` names its output `sitemap-0.xml`, `sitemap-1.xml`, … alongside a
894
+ * `sitemap-index.xml`. Other generators write a plain `sitemap.xml`. Match the family.
895
+ */
896
+ const SITEMAP_FILE = /^sitemap[\w.-]*\.xml$/i;
897
+ function walkSitemapFiles(dir) {
898
+ const out = [];
899
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
900
+ const p = join(dir, ent.name);
901
+ if (ent.isDirectory()) out.push(...walkSitemapFiles(p));
902
+ else if (ent.isFile() && SITEMAP_FILE.test(ent.name)) out.push(p);
903
+ }
904
+ return out;
905
+ }
906
+ /**
907
+ * `<loc>` is XML, so the five predefined entities and numeric references are all
908
+ * legal in it. `&amp;` is the one that actually turns up (a query string with two
909
+ * parameters), but a path that came through an escaper wholesale can carry the
910
+ * others, and an entity left undecoded turns into a pathname that matches no
911
+ * route and is silently kept.
912
+ */
913
+ function decodeXmlEntities(value) {
914
+ return value.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
915
+ }
916
+ function locPathname(entry) {
917
+ const loc = /<loc>\s*([\s\S]*?)\s*<\/loc>/i.exec(entry)?.[1];
918
+ if (!loc) return null;
919
+ const href = decodeXmlEntities(loc).trim();
920
+ try {
921
+ return new URL(href).pathname;
922
+ } catch {
923
+ return href.startsWith("/") ? href : null;
924
+ }
925
+ }
926
+ /**
927
+ * Drop every `<url>` whose `<loc>` points at a route this runtime prunes.
928
+ *
929
+ * The alternative is asking every site to duplicate the flag decision in its own
930
+ * `sitemap({ filter })`, which is what the docs used to say and what the site this
931
+ * package was written for got subtly wrong: a substring test culled `/blog/about-x/`
932
+ * along with `/about/`. Deciding it here, from the same runtime that deletes the
933
+ * files, means the two answers cannot drift.
934
+ *
935
+ * String surgery rather than an XML parse, so the untouched entries come back
936
+ * byte-identical and the diff of a rebuild stays readable.
937
+ */
938
+ function pruneSitemapXml(xml, runtime) {
939
+ return xml.replace(/[ \t]*<url>[\s\S]*?<\/url>\s*/gi, (entry) => {
940
+ const pathname = locPathname(entry);
941
+ if (!pathname) return entry;
942
+ return shouldIncludeRoute({
943
+ pathname,
944
+ routeFlags: runtime.routeFlags,
945
+ flags: runtime.flags,
946
+ isDev: false
947
+ }) ? entry : "";
948
+ });
949
+ }
950
+ /** Number of `<url>` entries left in a urlset. */
951
+ function sitemapUrlCount(xml) {
952
+ return (xml.match(/<url>/gi) ?? []).length;
953
+ }
954
+ /**
955
+ * Remove the `<sitemap>` entries of an index that point at files which no longer exist.
956
+ */
957
+ function pruneSitemapIndexXml(xml, removedFiles) {
958
+ if (!removedFiles.length) return xml;
959
+ const removed = new Set(removedFiles);
960
+ return xml.replace(/[ \t]*<sitemap>[\s\S]*?<\/sitemap>\s*/gi, (entry) => {
961
+ const pathname = locPathname(entry);
962
+ if (!pathname) return entry;
963
+ return removed.has(basename(pathname)) ? "" : entry;
964
+ });
965
+ }
966
+ /**
967
+ * Walk `outDir` (Astro `dist/`) and take the pruned routes out of every sitemap.
968
+ *
969
+ * Runs in `astro:build:done`, which means this integration has to sit **after**
970
+ * `@astrojs/sitemap` in `integrations` — Astro runs the hook in array order, and a
971
+ * sitemap written after this pass would keep its dead URLs.
972
+ */
973
+ function applySitemapPruneToDist(outDir, runtime) {
974
+ const result = {
975
+ rewritten: [],
976
+ removed: [],
977
+ found: false
978
+ };
979
+ let files;
980
+ try {
981
+ files = walkSitemapFiles(outDir);
982
+ } catch {
983
+ return result;
984
+ }
985
+ result.found = files.length > 0;
986
+ const indexes = [];
987
+ for (const file of files) {
988
+ const before = readFileSync(file, "utf8");
989
+ if (!/<urlset[\s>]/i.test(before)) {
990
+ if (/<sitemapindex[\s>]/i.test(before)) indexes.push(file);
991
+ continue;
992
+ }
993
+ const after = pruneSitemapXml(before, runtime);
994
+ if (after === before) continue;
995
+ if (sitemapUrlCount(after) === 0) {
996
+ rmSync(file, { force: true });
997
+ result.removed.push(file);
998
+ } else {
999
+ writeFileSync(file, after, "utf8");
1000
+ result.rewritten.push(file);
1001
+ }
1002
+ }
1003
+ const removedNames = result.removed.map((file) => basename(file));
1004
+ for (const file of indexes) {
1005
+ const before = readFileSync(file, "utf8");
1006
+ const after = pruneSitemapIndexXml(before, removedNames);
1007
+ if (after === before) continue;
1008
+ if (!/<sitemap>/i.test(after)) {
1009
+ rmSync(file, { force: true });
1010
+ result.removed.push(file);
1011
+ } else {
1012
+ writeFileSync(file, after, "utf8");
1013
+ result.rewritten.push(file);
1014
+ }
1015
+ }
1016
+ return result;
1017
+ }
1018
+ //#endregion
845
1019
  //#region src/dev-head-inject.ts
846
1020
  /**
847
1021
  * Single `injectScript('head-inline', …)` payload for `astro dev`: dev-only outline CSS,
@@ -854,7 +1028,8 @@ function buildAffDevHeadInline(args) {
854
1028
  return `${inlineInvoke(affHeadInlineRuntime, {
855
1029
  featureFlagStyles,
856
1030
  routeFlags: runtime.routeFlags,
857
- flagNameToToken
1031
+ flagNameToToken,
1032
+ devLayer: DEV_LAYER
858
1033
  })}\n${affDevBootstrap}`;
859
1034
  }
860
1035
  //#endregion
@@ -1052,17 +1227,23 @@ function featureRouteIncluded(pathname, runtime) {
1052
1227
  });
1053
1228
  }
1054
1229
  function astroFeatureFlags(options = {}) {
1055
- const { css, staticMinify = true, ...flagOpts } = options;
1230
+ const { css, staticMinify = true, pruneSitemap = true, ...flagOpts } = options;
1056
1231
  const opts = withDefaultEnvironments(flagOpts);
1057
1232
  const mode = opts.mode ?? process.env.NODE_ENV ?? "development";
1058
1233
  const runtime = resolveFeatureRuntime({
1059
1234
  ...opts,
1060
1235
  mode
1061
1236
  });
1237
+ /**
1238
+ * Set in `astro:config:setup`, read in `astro:build:done`, so that a sitemap this
1239
+ * pass never saw can be reported as an ordering mistake rather than silently skipped.
1240
+ */
1241
+ let sitemapIntegrationPresent = false;
1062
1242
  return {
1063
1243
  name: "astro-feature-flags",
1064
1244
  hooks: {
1065
- "astro:config:setup": ({ updateConfig, addDevToolbarApp, command, injectScript }) => {
1245
+ "astro:config:setup": ({ config, updateConfig, addDevToolbarApp, command, injectScript }) => {
1246
+ sitemapIntegrationPresent = (config?.integrations ?? []).some((integration) => integration?.name === "@astrojs/sitemap");
1066
1247
  const flagNames = Object.keys(runtime.flags);
1067
1248
  const flagTokens = flagNames.map((name) => toToken(name));
1068
1249
  const flagsByEnvironment = resolveFeatureFlagsByEnvironment(opts);
@@ -1074,11 +1255,14 @@ function astroFeatureFlags(options = {}) {
1074
1255
  icon: DEV_TOOLBAR_FLAG_ICON_SVG,
1075
1256
  entrypoint: fileURLToPath(new URL("./dev-toolbar-app.mjs", import.meta.url))
1076
1257
  });
1077
- if (command === "dev" && runtime.isDev && typeof injectScript === "function") injectScript("head-inline", buildAffDevHeadInline({
1078
- runtime,
1079
- featureFlagStyles: createFeatureFlagStyles(runtime, css),
1080
- affDevBootstrap: bootstrap
1081
- }));
1258
+ if (command === "dev" && runtime.isDev && typeof injectScript === "function") {
1259
+ const styles = createFeatureFlagStyles(runtime, css);
1260
+ injectScript("head-inline", buildAffDevHeadInline({
1261
+ runtime,
1262
+ featureFlagStyles: styles,
1263
+ affDevBootstrap: bootstrap
1264
+ }));
1265
+ }
1082
1266
  updateConfig({ vite: { plugins: [{
1083
1267
  name: "astro-feature-flags:virtual-module",
1084
1268
  resolveId(id) {
@@ -1091,7 +1275,7 @@ function astroFeatureFlags(options = {}) {
1091
1275
  }
1092
1276
  }] } });
1093
1277
  },
1094
- "astro:build:done": ({ dir }) => {
1278
+ "astro:build:done": ({ dir, logger }) => {
1095
1279
  if (runtime.isDev || !staticMinify) return;
1096
1280
  const outDir = fileURLToPath(dir);
1097
1281
  const prunePaths = routePathsToPrune({
@@ -1103,11 +1287,13 @@ function astroFeatureFlags(options = {}) {
1103
1287
  force: true
1104
1288
  });
1105
1289
  applyProductionHtmlCullToDist(outDir, runtime);
1290
+ if (!pruneSitemap) return;
1291
+ if (!applySitemapPruneToDist(outDir, runtime).found && sitemapIntegrationPresent && prunePaths.length) logger?.warn("@astrojs/sitemap is configured but no sitemap was on disk yet, so pruned routes may still be listed. Move astroFeatureFlags() after sitemap() in `integrations`.");
1106
1292
  }
1107
1293
  }
1108
1294
  };
1109
1295
  }
1110
1296
  //#endregion
1111
- export { applyProductionHtmlCullToDist, createFeatureFlagStyles, createProductionGateStyles, createVirtualModuleSource, cullProductionHtml, astroFeatureFlags as default, elementBadgePositionBlock, featureRouteIncluded, getResolvedFeatures, longestMatchingRoutePrefix, mergeFlagsWithProcessEnvOverrides, normalizeElementBadgeLayout, primaryNonDevEnvironmentKey, resolveFeatureFlagsByEnvironment, routePatternToPrefix };
1297
+ export { DEV_LAYER, applyProductionHtmlCullToDist, applySitemapPruneToDist, createFeatureFlagStyles, createProductionGateStyles, createVirtualModuleSource, cullProductionHtml, astroFeatureFlags as default, elementBadgePositionBlock, featureRouteIncluded, getResolvedFeatures, longestMatchingRoutePrefix, mergeFlagsWithProcessEnvOverrides, normalizeElementBadgeLayout, primaryNonDevEnvironmentKey, pruneSitemapIndexXml, pruneSitemapXml, resolveFeatureFlagsByEnvironment, routePatternToPrefix, sitemapUrlCount };
1112
1298
 
1113
1299
  //# sourceMappingURL=index.mjs.map