@jsenv/core 41.4.3 → 41.4.5

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.
@@ -333,6 +333,7 @@ ${reason}`,
333
333
  }
334
334
  return createFailedToResolveUrlError({
335
335
  reason: `An error occured during specifier resolution`,
336
+ ...detailsFromInjectionsOnOwner(reference),
336
337
  ...detailsFromValueThrown(error),
337
338
  });
338
339
  };
@@ -394,6 +395,7 @@ ${reason}`,
394
395
  return createFailedToFetchUrlContentError({
395
396
  code: "NOT_FOUND",
396
397
  reason: "no entry on filesystem",
398
+ ...detailsFromInjectionsOnOwner(urlInfo.firstReference),
397
399
  });
398
400
  }
399
401
  }
@@ -626,6 +628,32 @@ const getFirstReferenceInProject = (reference) => {
626
628
  return getFirstReferenceInProject(firstReference);
627
629
  };
628
630
 
631
+ // An url written by an injection cannot be resolved: the placeholder is still there
632
+ // when references are analyzed. Rather than guessing what a placeholder looks like
633
+ // (the key is free-form), tell the file it comes from: injections are configured for it.
634
+ const detailsFromInjectionsOnOwner = (reference) => {
635
+ if (!reference) {
636
+ return {};
637
+ }
638
+ const ownerUrlInfo = reference.ownerUrlInfo;
639
+ if (ownerUrlInfo.type !== "html") {
640
+ // "jsenv-ignore" is an html attribute
641
+ return {};
642
+ }
643
+ const { hasInjections } = ownerUrlInfo.context;
644
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
645
+ return {};
646
+ }
647
+ const { node, attributeName } = reference.astInfo || {};
648
+ if (!node || !attributeName) {
649
+ return {};
650
+ }
651
+ return {
652
+ suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, add "jsenv-ignore" so jsenv leaves that url alone:
653
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
654
+ };
655
+ };
656
+
629
657
  const detailsFromPluginController = (jsenvPluginsController) => {
630
658
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
631
659
  if (!currentPlugin) {
@@ -2364,11 +2392,21 @@ ${urlInfo.url}`,
2364
2392
 
2365
2393
  const injectionSymbol = Symbol.for("jsenv_injection");
2366
2394
  const INJECTIONS = {
2395
+ /**
2396
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
2397
+ * (into a script for html, into the module itself for js) instead of
2398
+ * replacing a placeholder: the value is read at runtime as a global.
2399
+ */
2367
2400
  global: (value) => {
2368
2401
  return { [injectionSymbol]: "global", value };
2369
2402
  },
2403
+ /**
2404
+ * Replace the placeholder when the file contains it, stay silent when it does not
2405
+ * (without this a missing placeholder is reported as a warning).
2406
+ */
2370
2407
  optional: (value) => {
2371
- if (value && value[injectionSymbol] === "optional") {
2408
+ if (value && value[injectionSymbol]) {
2409
+ // a global injection is not a placeholder, it can't be missing from the file
2372
2410
  return value;
2373
2411
  }
2374
2412
  return { [injectionSymbol]: "optional", value };
@@ -2473,12 +2511,7 @@ return {
2473
2511
  magicSource.replace({
2474
2512
  start,
2475
2513
  end,
2476
- replacement:
2477
- urlInfo.type === "js_classic" ||
2478
- urlInfo.type === "js_module" ||
2479
- urlInfo.type === "html"
2480
- ? JSON.stringify(value, null, " ")
2481
- : value,
2514
+ replacement: asReplacement(value, urlInfo),
2482
2515
  });
2483
2516
  index = content.indexOf(key, end);
2484
2517
  }
@@ -2486,6 +2519,19 @@ return {
2486
2519
  return magicSource.toContentAndSourcemap();
2487
2520
  };
2488
2521
 
2522
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
2523
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
2524
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
2525
+ const asReplacement = (value, urlInfo) => {
2526
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2527
+ return JSON.stringify(value, null, " ");
2528
+ }
2529
+ if (typeof value === "string") {
2530
+ return value;
2531
+ }
2532
+ return JSON.stringify(value, null, " ");
2533
+ };
2534
+
2489
2535
  const injectGlobals = (content, globals, urlInfo) => {
2490
2536
  if (urlInfo.type === "html") {
2491
2537
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -2493,7 +2539,14 @@ const injectGlobals = (content, globals, urlInfo) => {
2493
2539
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2494
2540
  return globalsInjectorOnJs(content, globals, urlInfo);
2495
2541
  }
2496
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
2542
+ throw new Error(
2543
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
2544
+ file: urlInfo.url,
2545
+ ...(urlInfo.isInline
2546
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
2547
+ : {}),
2548
+ }),
2549
+ );
2497
2550
  };
2498
2551
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
2499
2552
  // ideally we would inject an importmap but browser support is too low
@@ -6560,7 +6613,9 @@ const jsenvPluginVersionSearchParam = () => {
6560
6613
  * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks
6561
6614
  * gets the same answer.
6562
6615
  *
6563
- * Each page comes with what kind of page it is, read from where it sits and
6616
+ * Each page comes with where its file is (so it can be opened in an editor as
6617
+ * well as in the browser) and with what kind of page it is, read from where it
6618
+ * sits and
6564
6619
  * what it is called — the two conventions this repo already follows:
6565
6620
  * - "experiment": something tried out, under a lab/ directory or named
6566
6621
  * *_experiment.html;
@@ -6659,6 +6714,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
6659
6714
  );
6660
6715
  return {
6661
6716
  url: `/${relativeUrl}`,
6717
+ // Where the file actually is, so whoever wants to open it in an editor
6718
+ // rather than in the browser has what GET /.internal/open_file/* asks
6719
+ // for (a file url) without having to know the root directory.
6720
+ fileUrl,
6662
6721
  kind: readKind(meta),
6663
6722
  // Relative to the root and without its trailing slash, which is how a
6664
6723
  // tree names its own nodes.
@@ -7212,6 +7271,10 @@ const jsenvPluginFsRedirection = ({
7212
7271
  }
7213
7272
  const { requestedUrl, rootDirectoryUrl, mainFilePath } =
7214
7273
  reference.ownerUrlInfo.context;
7274
+ if (!requestedUrl) {
7275
+ // the SPA fallback answers a request; during build there is none
7276
+ return null;
7277
+ }
7215
7278
  const closestHtmlRootFile = getClosestHtmlRootFile(
7216
7279
  requestedUrl,
7217
7280
  rootDirectoryUrl,
@@ -7624,18 +7687,52 @@ const jsenvPluginInjections = (rawAssociations) => {
7624
7687
  { injectionsGetter: rawAssociations },
7625
7688
  context.rootDirectoryUrl,
7626
7689
  );
7627
- getInjections = (urlInfo) => {
7690
+ const findInjectionsGetterForUrl = (url) => {
7628
7691
  const { injectionsGetter } = URL_META.applyAssociations({
7629
- url: asUrlWithoutSearch(urlInfo.url),
7692
+ url: asUrlWithoutSearch(url),
7630
7693
  associations: resolvedAssociations,
7631
7694
  });
7632
- if (!injectionsGetter) {
7695
+ return injectionsGetter;
7696
+ };
7697
+ // an url written by an injection cannot be resolved during reference analysis;
7698
+ // errors use this to tell the file holds injections and suggest "jsenv-ignore"
7699
+ context.hasInjections = (url) => {
7700
+ return Boolean(findInjectionsGetterForUrl(url));
7701
+ };
7702
+ const findInjectionsGetter = (urlInfo) => {
7703
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
7704
+ if (injectionsGetter) {
7705
+ return { injectionsGetter, isInherited: false };
7706
+ }
7707
+ if (urlInfo.isInline) {
7708
+ // content inlined into a file (a <script> inside html) is authored in that
7709
+ // file, so injections configured for the file must reach it too
7710
+ const found = findInjectionsGetter(
7711
+ urlInfo.firstReference.ownerUrlInfo,
7712
+ );
7713
+ if (found) {
7714
+ return {
7715
+ injectionsGetter: found.injectionsGetter,
7716
+ isInherited: true,
7717
+ };
7718
+ }
7719
+ }
7720
+ return null;
7721
+ };
7722
+ getInjections = async (urlInfo) => {
7723
+ const found = findInjectionsGetter(urlInfo);
7724
+ if (!found) {
7633
7725
  return null;
7634
7726
  }
7727
+ const { injectionsGetter, isInherited } = found;
7635
7728
  if (typeof injectionsGetter !== "function") {
7636
7729
  throw new TypeError("injectionsGetter must be a function");
7637
7730
  }
7638
- return injectionsGetter(urlInfo);
7731
+ const injections = await injectionsGetter(urlInfo);
7732
+ if (!injections || !isInherited) {
7733
+ return injections;
7734
+ }
7735
+ return asInheritedInjections(injections);
7639
7736
  };
7640
7737
  }
7641
7738
  },
@@ -7646,13 +7743,12 @@ const jsenvPluginInjections = (rawAssociations) => {
7646
7743
  contentInjections: defaultInjections,
7647
7744
  };
7648
7745
  }
7649
- const injectionsResult = getInjections(urlInfo);
7650
- if (!injectionsResult) {
7746
+ const injections = await getInjections(urlInfo);
7747
+ if (!injections) {
7651
7748
  return {
7652
7749
  contentInjections: defaultInjections,
7653
7750
  };
7654
7751
  }
7655
- const injections = await injectionsResult;
7656
7752
  return {
7657
7753
  contentInjections: {
7658
7754
  ...defaultInjections,
@@ -7663,6 +7759,26 @@ const jsenvPluginInjections = (rawAssociations) => {
7663
7759
  };
7664
7760
  };
7665
7761
 
7762
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
7763
+ // and inherits its injections, with two adjustments:
7764
+ // - a global belongs to the file itself, injecting it into each inline content would
7765
+ // repeat it and reach types that cannot receive globals (css)
7766
+ // - a placeholder configured for the file is expected in one of its inline contents,
7767
+ // not in each, so a missing one is not worth a warning
7768
+ const asInheritedInjections = (injections) => {
7769
+ const inheritedInjections = {};
7770
+ for (const key of Object.keys(injections)) {
7771
+ const value = injections[key];
7772
+ if (isPlaceholderInjection(value)) {
7773
+ inheritedInjections[key] = INJECTIONS.optional(value);
7774
+ }
7775
+ }
7776
+ if (Object.keys(inheritedInjections).length === 0) {
7777
+ return null;
7778
+ }
7779
+ return inheritedInjections;
7780
+ };
7781
+
7666
7782
  /*
7667
7783
  * Some code uses globals specific to Node.js in code meant to run in browsers...
7668
7784
  * This plugin will replace some node globals to things compatible with web:
@@ -11064,6 +11180,11 @@ const createBuildSpecifierManager = ({
11064
11180
  registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
11065
11181
  visitHtmlNodes(htmlAst, {
11066
11182
  link: (node) => {
11183
+ if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
11184
+ // reference analysis skipped this node, so it has no urlInfo in the graph
11185
+ // and there is nothing to resync
11186
+ return;
11187
+ }
11067
11188
  const href = getHtmlNodeAttribute(node, "href");
11068
11189
  if (href === undefined || href.startsWith("data:")) {
11069
11190
  return;
@@ -11902,6 +12023,25 @@ const jsenvPluginMappings = (mappings) => {
11902
12023
  * How URLs are versioned for this entry point (defaults to "search_param")
11903
12024
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
11904
12025
  * Sourcemap generation strategy for this entry point (defaults to "none")
12026
+ * @param {object} [entryPoint.injections]
12027
+ * Values to inject into files, as { urlPattern: getInjections }.
12028
+ * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
12029
+ * values are functions receiving urlInfo and returning (or resolving to)
12030
+ * an object of placeholders to replace, named `__LIKE_THIS__` by convention:
12031
+ *
12032
+ * injections: {
12033
+ * "./index.html": () => ({ __BACKEND_URL__: "https://api.example.com" }),
12034
+ * }
12035
+ *
12036
+ * In JS files the value is injected as a JS literal (a string value brings its own quotes),
12037
+ * everywhere else it is injected as-is, so it can be concatenated:
12038
+ * `href="__BACKEND_URL__/users/me"`.
12039
+ * An html url pattern also covers what is inlined in that html: an inline
12040
+ * `<script>window.backendUrl = __BACKEND_URL__;</script>` gets the JS literal,
12041
+ * which is how a value is shared with every js file of the page.
12042
+ * Use INJECTIONS.optional(value) for a placeholder that may be absent from the file
12043
+ * and INJECTIONS.global(value) to inject `Object.assign(window, { ... })` instead of
12044
+ * replacing a placeholder.
11905
12045
  *
11906
12046
  * @return {Promise<Object>} buildReturnValue
11907
12047
  * @return {Promise<Object>} [buildReturnValue.buildInlineContents]
@@ -11939,6 +12079,17 @@ const build = async ({
11939
12079
  {
11940
12080
  const unexpectedParamNames = Object.keys(rest);
11941
12081
  if (unexpectedParamNames.length > 0) {
12082
+ const entryPointParamNames = unexpectedParamNames.filter((name) =>
12083
+ Object.hasOwn(entryPointDefaultParams, name),
12084
+ );
12085
+ if (entryPointParamNames.length > 0) {
12086
+ throw new TypeError(
12087
+ `${entryPointParamNames.join(",")}: param(s) configured per entry point, move them into entryPoints, as in:
12088
+ entryPoints: {
12089
+ "./index.html": { ${entryPointParamNames.map((name) => `${name}: ...`).join(", ")} },
12090
+ }`,
12091
+ );
12092
+ }
11942
12093
  throw new TypeError(
11943
12094
  `${unexpectedParamNames.join(",")}: there is no such param`,
11944
12095
  );
@@ -8,7 +8,7 @@
8
8
 
9
9
  <body>
10
10
  <p>Syntax error: <strong>${reasonCode}</strong></p>
11
- <a jsenv-ignore="" href="${errorLinkHref}">${errorLinkText}</a>
11
+ <a href="${errorLinkHref}">${errorLinkText}</a>
12
12
  ${syntaxErrorHTML}
13
13
  </body>
14
14
  </html>
@@ -2,6 +2,11 @@
2
2
  * cmd+K / ctrl+K on any dev-served page: the .html files the server serves, as a
3
3
  * tree one walks, filter as you type, Enter to go there.
4
4
  *
5
+ * cmd+E / ctrl+E is the other half of the same question: instead of going to a
6
+ * page, open its file in the editor (the server does it, see
7
+ * GET /.internal/open_file/*). Pressed on the page itself it opens the page one
8
+ * is on; pressed inside the switcher it opens the row one is looking at.
9
+ *
5
10
  * A tree rather than a list of paths, because the paths are mostly the same
6
11
  * path: folding every directory that holds a single thing turns
7
12
  * "packages/frontend/navi/src/layout/demos/…" repeated forty times into one
@@ -25,6 +30,10 @@
25
30
  // another injected client. A function scope owes nothing to anybody.
26
31
  (() => {
27
32
  const PAGES_ENDPOINT = "/.internal/pages.json";
33
+ // The server asks the OS to open a file in whatever editor is configured
34
+ // (VSCode here) — it takes a file url, which is why the page list carries one
35
+ // per page (see html_pages.js).
36
+ const OPEN_FILE_ENDPOINT = "/.internal/open_file/";
28
37
  const STORAGE_KEY = "jsenv_page_switcher";
29
38
  // Open across a reload: this is a dev tool on a page one is editing, and a hot
30
39
  // reload in the middle of looking for the next page should not close what one
@@ -56,15 +65,20 @@
56
65
  }
57
66
  };
58
67
 
59
- const isSwitcherKey = (event) => {
60
- if (event.key !== "k" && event.key !== "K") {
61
- return false;
62
- }
63
- // cmd on mac, ctrl elsewhere — the same split every editor makes.
64
- return window.navigator.platform.toLowerCase().includes("mac")
68
+ // cmd on mac, ctrl elsewhere — the same split every editor makes.
69
+ const isCommandKey = (event) =>
70
+ window.navigator.platform.toLowerCase().includes("mac")
65
71
  ? event.metaKey && !event.ctrlKey
66
72
  : event.ctrlKey && !event.metaKey;
67
- };
73
+
74
+ const isSwitcherKey = (event) =>
75
+ (event.key === "k" || event.key === "K") && isCommandKey(event);
76
+ // E for edit, next to K for the same reason the two belong together: K asks
77
+ // "which page", E asks "where does this page live". Outside the switcher it
78
+ // means the page one is on; inside it, the row one is looking at — so the
79
+ // same press reads the same way in both places.
80
+ const isEditorKey = (event) =>
81
+ (event.key === "e" || event.key === "E") && isCommandKey(event);
68
82
 
69
83
  const STYLE_TEXT = /* css */ `
70
84
  :host {
@@ -230,6 +244,32 @@
230
244
  }
231
245
  `;
232
246
 
247
+ const FLASH_STYLE_TEXT = /* css */ `
248
+ :host {
249
+ position: fixed;
250
+ right: 16px;
251
+ bottom: 16px;
252
+ /* Above the switcher's own panel: it is the switcher that triggers it. */
253
+ z-index: 2147483647;
254
+ display: block;
255
+ font-family: system-ui, sans-serif;
256
+ pointer-events: none;
257
+ }
258
+ .flash {
259
+ padding: 8px 14px;
260
+ color: light-dark(#0f172a, #e2e8f0);
261
+ font-size: 13px;
262
+ background: light-dark(white, #1e293b);
263
+ border-radius: 8px;
264
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
265
+ color-scheme: light dark;
266
+ }
267
+ .flash[data-error] {
268
+ color: light-dark(#991b1b, #fecaca);
269
+ background: light-dark(#fee2e2, #7f1d1d);
270
+ }
271
+ `;
272
+
233
273
  let pagesPromise = null;
234
274
  const loadPages = () => {
235
275
  // Once per page load: the list is a filesystem scan behind a short cache
@@ -240,6 +280,54 @@
240
280
  return pagesPromise;
241
281
  };
242
282
 
283
+ // Opening a file in an editor happens in another application, on another
284
+ // screen sometimes: without a word here, a press that failed and a press that
285
+ // worked look exactly the same. Its own host and its own shadow root, so it
286
+ // can be shown whether or not the switcher is open.
287
+ let flashHost = null;
288
+ let flashBox = null;
289
+ let flashTimeout = null;
290
+ const flash = (message, isError) => {
291
+ if (!flashHost) {
292
+ flashHost = document.createElement("div");
293
+ const shadow = flashHost.attachShadow({ mode: "open" });
294
+ const style = document.createElement("style");
295
+ style.textContent = FLASH_STYLE_TEXT;
296
+ flashBox = document.createElement("div");
297
+ flashBox.className = "flash";
298
+ shadow.append(style, flashBox);
299
+ }
300
+ flashBox.textContent = message;
301
+ flashBox.toggleAttribute("data-error", Boolean(isError));
302
+ // Appended last every time, so it sits above the switcher's host when both
303
+ // are on the page and they share the same z-index.
304
+ document.body.append(flashHost);
305
+ window.clearTimeout(flashTimeout);
306
+ flashTimeout = window.setTimeout(() => flashHost.remove(), 2500);
307
+ };
308
+
309
+ const openInEditor = async (file) => {
310
+ if (!file || !file.fileUrl) {
311
+ flash("This page is not a file the server lists.", true);
312
+ return;
313
+ }
314
+ flash(`Opening ${file.name || file.url} in editor…`);
315
+ try {
316
+ const response = await fetch(
317
+ `${OPEN_FILE_ENDPOINT}${encodeURIComponent(file.fileUrl)}`,
318
+ );
319
+ if (response.status === 404) {
320
+ // The route exists only when the server is willing to expose the
321
+ // machine it runs on (see start_server.js).
322
+ flash("This server does not open files in an editor.", true);
323
+ } else if (!response.ok) {
324
+ flash(`Editor said no (${response.status}).`, true);
325
+ }
326
+ } catch {
327
+ flash("Could not reach the dev server.", true);
328
+ }
329
+ };
330
+
243
331
  const readStoredState = () => {
244
332
  try {
245
333
  const stored = JSON.parse(
@@ -396,7 +484,11 @@
396
484
  panel.className = "panel";
397
485
  const input = document.createElement("input");
398
486
  input.type = "search";
399
- input.placeholder = "Go to page…";
487
+ // The other key is written where one is already looking: a shortcut nobody
488
+ // is told about is a shortcut nobody presses. Both names, not the one this
489
+ // platform uses — the reader knows which of the two their keyboard has, and
490
+ // it keeps what the panel says the same everywhere.
491
+ input.placeholder = "Go to page… (cmd/ctrl+E to open in editor)";
400
492
  input.setAttribute("aria-label", "Go to page");
401
493
  const kindsRow = document.createElement("div");
402
494
  kindsRow.className = "kinds";
@@ -664,6 +756,21 @@
664
756
  toggleCollapsed(row.node.path);
665
757
  return;
666
758
  }
759
+ if (isEditorKey(event)) {
760
+ // Taken whatever the row is: let go of on a directory it would reach
761
+ // the page below and open the page one came from, which is not what a
762
+ // key pressed inside an open switcher can be asking for.
763
+ stop();
764
+ const row = rows[currentIndex];
765
+ if (!row || row.type !== "file") {
766
+ return;
767
+ }
768
+ // Done with the switcher: the answer to "where does this live" arrives
769
+ // in the editor, not here.
770
+ close();
771
+ openInEditor(row.file);
772
+ return;
773
+ }
667
774
  if (isSwitcherKey(event)) {
668
775
  stop();
669
776
  close();
@@ -708,15 +815,31 @@
708
815
  // preventDefault, the key was theirs and nothing happens here.
709
816
  const listenSwitcherKey = () => {
710
817
  window.addEventListener("keydown", (event) => {
711
- if (event.defaultPrevented || !isSwitcherKey(event)) {
818
+ if (event.defaultPrevented) {
712
819
  return;
713
820
  }
714
- // Ours now: the browser has its own use for cmd+K (the address bar), which
715
- // it must not get.
716
- event.preventDefault();
717
- openSwitcher();
821
+ if (isSwitcherKey(event)) {
822
+ // Ours now: the browser has its own use for cmd+K (the address bar),
823
+ // which it must not get.
824
+ event.preventDefault();
825
+ openSwitcher();
826
+ return;
827
+ }
828
+ if (isEditorKey(event)) {
829
+ event.preventDefault();
830
+ openCurrentPageInEditor();
831
+ }
718
832
  });
719
833
  };
834
+ // The page one is looking at, in the editor. The list is where the file url
835
+ // comes from, so a page the server does not list (an @fs url, something under
836
+ // node_modules) says so rather than opening the wrong thing.
837
+ const openCurrentPageInEditor = async () => {
838
+ const here = currentPageUrl();
839
+ const pages = await loadPages();
840
+ const page = pages.find((candidate) => candidate.url === here);
841
+ openInEditor(page && { ...page, name: here.split("/").pop() });
842
+ };
720
843
  const setup = () => {
721
844
  listenSwitcherKey();
722
845
  if (wasOpen()) {
@@ -3,11 +3,21 @@ import "@jsenv/sourcemap";
3
3
 
4
4
  const injectionSymbol = Symbol.for("jsenv_injection");
5
5
  const INJECTIONS = {
6
+ /**
7
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
8
+ * (into a script for html, into the module itself for js) instead of
9
+ * replacing a placeholder: the value is read at runtime as a global.
10
+ */
6
11
  global: (value) => {
7
12
  return { [injectionSymbol]: "global", value };
8
13
  },
14
+ /**
15
+ * Replace the placeholder when the file contains it, stay silent when it does not
16
+ * (without this a missing placeholder is reported as a warning).
17
+ */
9
18
  optional: (value) => {
10
- if (value && value[injectionSymbol] === "optional") {
19
+ if (value && value[injectionSymbol]) {
20
+ // a global injection is not a placeholder, it can't be missing from the file
11
21
  return value;
12
22
  }
13
23
  return { [injectionSymbol]: "optional", value };