@jsenv/core 41.4.3 → 41.4.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.
@@ -2364,11 +2364,21 @@ ${urlInfo.url}`,
2364
2364
 
2365
2365
  const injectionSymbol = Symbol.for("jsenv_injection");
2366
2366
  const INJECTIONS = {
2367
+ /**
2368
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
2369
+ * (into a script for html, into the module itself for js) instead of
2370
+ * replacing a placeholder: the value is read at runtime as a global.
2371
+ */
2367
2372
  global: (value) => {
2368
2373
  return { [injectionSymbol]: "global", value };
2369
2374
  },
2375
+ /**
2376
+ * Replace the placeholder when the file contains it, stay silent when it does not
2377
+ * (without this a missing placeholder is reported as a warning).
2378
+ */
2370
2379
  optional: (value) => {
2371
- if (value && value[injectionSymbol] === "optional") {
2380
+ if (value && value[injectionSymbol]) {
2381
+ // a global injection is not a placeholder, it can't be missing from the file
2372
2382
  return value;
2373
2383
  }
2374
2384
  return { [injectionSymbol]: "optional", value };
@@ -2473,12 +2483,7 @@ return {
2473
2483
  magicSource.replace({
2474
2484
  start,
2475
2485
  end,
2476
- replacement:
2477
- urlInfo.type === "js_classic" ||
2478
- urlInfo.type === "js_module" ||
2479
- urlInfo.type === "html"
2480
- ? JSON.stringify(value, null, " ")
2481
- : value,
2486
+ replacement: asReplacement(value, urlInfo),
2482
2487
  });
2483
2488
  index = content.indexOf(key, end);
2484
2489
  }
@@ -2486,6 +2491,19 @@ return {
2486
2491
  return magicSource.toContentAndSourcemap();
2487
2492
  };
2488
2493
 
2494
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
2495
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
2496
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
2497
+ const asReplacement = (value, urlInfo) => {
2498
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2499
+ return JSON.stringify(value, null, " ");
2500
+ }
2501
+ if (typeof value === "string") {
2502
+ return value;
2503
+ }
2504
+ return JSON.stringify(value, null, " ");
2505
+ };
2506
+
2489
2507
  const injectGlobals = (content, globals, urlInfo) => {
2490
2508
  if (urlInfo.type === "html") {
2491
2509
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -6560,7 +6578,9 @@ const jsenvPluginVersionSearchParam = () => {
6560
6578
  * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks
6561
6579
  * gets the same answer.
6562
6580
  *
6563
- * Each page comes with what kind of page it is, read from where it sits and
6581
+ * Each page comes with where its file is (so it can be opened in an editor as
6582
+ * well as in the browser) and with what kind of page it is, read from where it
6583
+ * sits and
6564
6584
  * what it is called — the two conventions this repo already follows:
6565
6585
  * - "experiment": something tried out, under a lab/ directory or named
6566
6586
  * *_experiment.html;
@@ -6659,6 +6679,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
6659
6679
  );
6660
6680
  return {
6661
6681
  url: `/${relativeUrl}`,
6682
+ // Where the file actually is, so whoever wants to open it in an editor
6683
+ // rather than in the browser has what GET /.internal/open_file/* asks
6684
+ // for (a file url) without having to know the root directory.
6685
+ fileUrl,
6662
6686
  kind: readKind(meta),
6663
6687
  // Relative to the root and without its trailing slash, which is how a
6664
6688
  // tree names its own nodes.
@@ -7212,6 +7236,10 @@ const jsenvPluginFsRedirection = ({
7212
7236
  }
7213
7237
  const { requestedUrl, rootDirectoryUrl, mainFilePath } =
7214
7238
  reference.ownerUrlInfo.context;
7239
+ if (!requestedUrl) {
7240
+ // the SPA fallback answers a request; during build there is none
7241
+ return null;
7242
+ }
7215
7243
  const closestHtmlRootFile = getClosestHtmlRootFile(
7216
7244
  requestedUrl,
7217
7245
  rootDirectoryUrl,
@@ -7624,18 +7652,45 @@ const jsenvPluginInjections = (rawAssociations) => {
7624
7652
  { injectionsGetter: rawAssociations },
7625
7653
  context.rootDirectoryUrl,
7626
7654
  );
7627
- getInjections = (urlInfo) => {
7655
+ const findInjectionsGetter = (urlInfo) => {
7628
7656
  const { injectionsGetter } = URL_META.applyAssociations({
7629
7657
  url: asUrlWithoutSearch(urlInfo.url),
7630
7658
  associations: resolvedAssociations,
7631
7659
  });
7632
- if (!injectionsGetter) {
7660
+ if (injectionsGetter) {
7661
+ return { injectionsGetter, isInherited: false };
7662
+ }
7663
+ if (urlInfo.isInline) {
7664
+ // content inlined into a file (a <script> inside html) is authored in that
7665
+ // file, so injections configured for the file must reach it too
7666
+ const found = findInjectionsGetter(
7667
+ urlInfo.firstReference.ownerUrlInfo,
7668
+ );
7669
+ if (found) {
7670
+ return {
7671
+ injectionsGetter: found.injectionsGetter,
7672
+ isInherited: true,
7673
+ };
7674
+ }
7675
+ }
7676
+ return null;
7677
+ };
7678
+ getInjections = async (urlInfo) => {
7679
+ const found = findInjectionsGetter(urlInfo);
7680
+ if (!found) {
7633
7681
  return null;
7634
7682
  }
7683
+ const { injectionsGetter, isInherited } = found;
7635
7684
  if (typeof injectionsGetter !== "function") {
7636
7685
  throw new TypeError("injectionsGetter must be a function");
7637
7686
  }
7638
- return injectionsGetter(urlInfo);
7687
+ const injections = await injectionsGetter(urlInfo);
7688
+ if (!injections || !isInherited) {
7689
+ return injections;
7690
+ }
7691
+ // the file holds several inline contents; a placeholder configured for the file
7692
+ // is expected in one of them, not in each
7693
+ return asOptionalInjections(injections);
7639
7694
  };
7640
7695
  }
7641
7696
  },
@@ -7646,13 +7701,12 @@ const jsenvPluginInjections = (rawAssociations) => {
7646
7701
  contentInjections: defaultInjections,
7647
7702
  };
7648
7703
  }
7649
- const injectionsResult = getInjections(urlInfo);
7650
- if (!injectionsResult) {
7704
+ const injections = await getInjections(urlInfo);
7705
+ if (!injections) {
7651
7706
  return {
7652
7707
  contentInjections: defaultInjections,
7653
7708
  };
7654
7709
  }
7655
- const injections = await injectionsResult;
7656
7710
  return {
7657
7711
  contentInjections: {
7658
7712
  ...defaultInjections,
@@ -7663,6 +7717,14 @@ const jsenvPluginInjections = (rawAssociations) => {
7663
7717
  };
7664
7718
  };
7665
7719
 
7720
+ const asOptionalInjections = (injections) => {
7721
+ const optionalInjections = {};
7722
+ for (const key of Object.keys(injections)) {
7723
+ optionalInjections[key] = INJECTIONS.optional(injections[key]);
7724
+ }
7725
+ return optionalInjections;
7726
+ };
7727
+
7666
7728
  /*
7667
7729
  * Some code uses globals specific to Node.js in code meant to run in browsers...
7668
7730
  * This plugin will replace some node globals to things compatible with web:
@@ -11064,6 +11126,11 @@ const createBuildSpecifierManager = ({
11064
11126
  registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
11065
11127
  visitHtmlNodes(htmlAst, {
11066
11128
  link: (node) => {
11129
+ if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
11130
+ // reference analysis skipped this node, so it has no urlInfo in the graph
11131
+ // and there is nothing to resync
11132
+ return;
11133
+ }
11067
11134
  const href = getHtmlNodeAttribute(node, "href");
11068
11135
  if (href === undefined || href.startsWith("data:")) {
11069
11136
  return;
@@ -11902,6 +11969,25 @@ const jsenvPluginMappings = (mappings) => {
11902
11969
  * How URLs are versioned for this entry point (defaults to "search_param")
11903
11970
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
11904
11971
  * Sourcemap generation strategy for this entry point (defaults to "none")
11972
+ * @param {object} [entryPoint.injections]
11973
+ * Values to inject into files, as { urlPattern: getInjections }.
11974
+ * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
11975
+ * values are functions receiving urlInfo and returning (or resolving to)
11976
+ * an object of placeholders to replace, named `__LIKE_THIS__` by convention:
11977
+ *
11978
+ * injections: {
11979
+ * "./index.html": () => ({ __BACKEND_URL__: "https://api.example.com" }),
11980
+ * }
11981
+ *
11982
+ * In JS files the value is injected as a JS literal (a string value brings its own quotes),
11983
+ * everywhere else it is injected as-is, so it can be concatenated:
11984
+ * `href="__BACKEND_URL__/users/me"`.
11985
+ * An html url pattern also covers what is inlined in that html: an inline
11986
+ * `<script>window.backendUrl = __BACKEND_URL__;</script>` gets the JS literal,
11987
+ * which is how a value is shared with every js file of the page.
11988
+ * Use INJECTIONS.optional(value) for a placeholder that may be absent from the file
11989
+ * and INJECTIONS.global(value) to inject `Object.assign(window, { ... })` instead of
11990
+ * replacing a placeholder.
11905
11991
  *
11906
11992
  * @return {Promise<Object>} buildReturnValue
11907
11993
  * @return {Promise<Object>} [buildReturnValue.buildInlineContents]
@@ -11939,6 +12025,17 @@ const build = async ({
11939
12025
  {
11940
12026
  const unexpectedParamNames = Object.keys(rest);
11941
12027
  if (unexpectedParamNames.length > 0) {
12028
+ const entryPointParamNames = unexpectedParamNames.filter((name) =>
12029
+ Object.hasOwn(entryPointDefaultParams, name),
12030
+ );
12031
+ if (entryPointParamNames.length > 0) {
12032
+ throw new TypeError(
12033
+ `${entryPointParamNames.join(",")}: param(s) configured per entry point, move them into entryPoints, as in:
12034
+ entryPoints: {
12035
+ "./index.html": { ${entryPointParamNames.map((name) => `${name}: ...`).join(", ")} },
12036
+ }`,
12037
+ );
12038
+ }
11942
12039
  throw new TypeError(
11943
12040
  `${unexpectedParamNames.join(",")}: there is no such param`,
11944
12041
  );
@@ -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 };
@@ -1321,7 +1321,9 @@ const jsenvPluginClientMonitoring = () => {
1321
1321
 
1322
1322
  /*
1323
1323
  * cmd+K (ctrl+K elsewhere) on any page the dev server serves opens a list of
1324
- * the .html files it serves, filter as you type, Enter to go there.
1324
+ * the .html files it serves, filter as you type, Enter to go there. cmd+E
1325
+ * (ctrl+E elsewhere) opens a page's file in the editor instead of going to it:
1326
+ * the current page from anywhere, the selected row from inside the switcher.
1325
1327
  *
1326
1328
  * The list is the one the filesystem plugin already publishes for everyone
1327
1329
  * (GET /.internal/pages.json, see protocol_file/html_pages.js) — this only adds
@@ -3327,7 +3329,9 @@ const FILE_AND_SERVER_URLS_CONVERTER = {
3327
3329
  * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks
3328
3330
  * gets the same answer.
3329
3331
  *
3330
- * Each page comes with what kind of page it is, read from where it sits and
3332
+ * Each page comes with where its file is (so it can be opened in an editor as
3333
+ * well as in the browser) and with what kind of page it is, read from where it
3334
+ * sits and
3331
3335
  * what it is called — the two conventions this repo already follows:
3332
3336
  * - "experiment": something tried out, under a lab/ directory or named
3333
3337
  * *_experiment.html;
@@ -3426,6 +3430,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
3426
3430
  );
3427
3431
  return {
3428
3432
  url: `/${relativeUrl}`,
3433
+ // Where the file actually is, so whoever wants to open it in an editor
3434
+ // rather than in the browser has what GET /.internal/open_file/* asks
3435
+ // for (a file url) without having to know the root directory.
3436
+ fileUrl,
3429
3437
  kind: readKind(meta),
3430
3438
  // Relative to the root and without its trailing slash, which is how a
3431
3439
  // tree names its own nodes.
@@ -4092,6 +4100,10 @@ const jsenvPluginFsRedirection = ({
4092
4100
  }
4093
4101
  const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4094
4102
  reference.ownerUrlInfo.context;
4103
+ if (!requestedUrl) {
4104
+ // the SPA fallback answers a request; during build there is none
4105
+ return null;
4106
+ }
4095
4107
  const closestHtmlRootFile = getClosestHtmlRootFile(
4096
4108
  requestedUrl,
4097
4109
  rootDirectoryUrl,
@@ -4986,11 +4998,21 @@ const jsenvPluginDirectoryReferenceEffect = (
4986
4998
 
4987
4999
  const injectionSymbol = Symbol.for("jsenv_injection");
4988
5000
  const INJECTIONS = {
5001
+ /**
5002
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
5003
+ * (into a script for html, into the module itself for js) instead of
5004
+ * replacing a placeholder: the value is read at runtime as a global.
5005
+ */
4989
5006
  global: (value) => {
4990
5007
  return { [injectionSymbol]: "global", value };
4991
5008
  },
5009
+ /**
5010
+ * Replace the placeholder when the file contains it, stay silent when it does not
5011
+ * (without this a missing placeholder is reported as a warning).
5012
+ */
4992
5013
  optional: (value) => {
4993
- if (value && value[injectionSymbol] === "optional") {
5014
+ if (value && value[injectionSymbol]) {
5015
+ // a global injection is not a placeholder, it can't be missing from the file
4994
5016
  return value;
4995
5017
  }
4996
5018
  return { [injectionSymbol]: "optional", value };
@@ -5095,12 +5117,7 @@ return {
5095
5117
  magicSource.replace({
5096
5118
  start,
5097
5119
  end,
5098
- replacement:
5099
- urlInfo.type === "js_classic" ||
5100
- urlInfo.type === "js_module" ||
5101
- urlInfo.type === "html"
5102
- ? JSON.stringify(value, null, " ")
5103
- : value,
5120
+ replacement: asReplacement(value, urlInfo),
5104
5121
  });
5105
5122
  index = content.indexOf(key, end);
5106
5123
  }
@@ -5108,6 +5125,19 @@ return {
5108
5125
  return magicSource.toContentAndSourcemap();
5109
5126
  };
5110
5127
 
5128
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
5129
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
5130
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
5131
+ const asReplacement = (value, urlInfo) => {
5132
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
5133
+ return JSON.stringify(value, null, " ");
5134
+ }
5135
+ if (typeof value === "string") {
5136
+ return value;
5137
+ }
5138
+ return JSON.stringify(value, null, " ");
5139
+ };
5140
+
5111
5141
  const injectGlobals = (content, globals, urlInfo) => {
5112
5142
  if (urlInfo.type === "html") {
5113
5143
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -5181,18 +5211,45 @@ const jsenvPluginInjections = (rawAssociations) => {
5181
5211
  { injectionsGetter: rawAssociations },
5182
5212
  context.rootDirectoryUrl,
5183
5213
  );
5184
- getInjections = (urlInfo) => {
5214
+ const findInjectionsGetter = (urlInfo) => {
5185
5215
  const { injectionsGetter } = URL_META.applyAssociations({
5186
5216
  url: asUrlWithoutSearch(urlInfo.url),
5187
5217
  associations: resolvedAssociations,
5188
5218
  });
5189
- if (!injectionsGetter) {
5219
+ if (injectionsGetter) {
5220
+ return { injectionsGetter, isInherited: false };
5221
+ }
5222
+ if (urlInfo.isInline) {
5223
+ // content inlined into a file (a <script> inside html) is authored in that
5224
+ // file, so injections configured for the file must reach it too
5225
+ const found = findInjectionsGetter(
5226
+ urlInfo.firstReference.ownerUrlInfo,
5227
+ );
5228
+ if (found) {
5229
+ return {
5230
+ injectionsGetter: found.injectionsGetter,
5231
+ isInherited: true,
5232
+ };
5233
+ }
5234
+ }
5235
+ return null;
5236
+ };
5237
+ getInjections = async (urlInfo) => {
5238
+ const found = findInjectionsGetter(urlInfo);
5239
+ if (!found) {
5190
5240
  return null;
5191
5241
  }
5242
+ const { injectionsGetter, isInherited } = found;
5192
5243
  if (typeof injectionsGetter !== "function") {
5193
5244
  throw new TypeError("injectionsGetter must be a function");
5194
5245
  }
5195
- return injectionsGetter(urlInfo);
5246
+ const injections = await injectionsGetter(urlInfo);
5247
+ if (!injections || !isInherited) {
5248
+ return injections;
5249
+ }
5250
+ // the file holds several inline contents; a placeholder configured for the file
5251
+ // is expected in one of them, not in each
5252
+ return asOptionalInjections(injections);
5196
5253
  };
5197
5254
  }
5198
5255
  },
@@ -5203,13 +5260,12 @@ const jsenvPluginInjections = (rawAssociations) => {
5203
5260
  contentInjections: defaultInjections,
5204
5261
  };
5205
5262
  }
5206
- const injectionsResult = getInjections(urlInfo);
5207
- if (!injectionsResult) {
5263
+ const injections = await getInjections(urlInfo);
5264
+ if (!injections) {
5208
5265
  return {
5209
5266
  contentInjections: defaultInjections,
5210
5267
  };
5211
5268
  }
5212
- const injections = await injectionsResult;
5213
5269
  return {
5214
5270
  contentInjections: {
5215
5271
  ...defaultInjections,
@@ -5220,6 +5276,14 @@ const jsenvPluginInjections = (rawAssociations) => {
5220
5276
  };
5221
5277
  };
5222
5278
 
5279
+ const asOptionalInjections = (injections) => {
5280
+ const optionalInjections = {};
5281
+ for (const key of Object.keys(injections)) {
5282
+ optionalInjections[key] = INJECTIONS.optional(injections[key]);
5283
+ }
5284
+ return optionalInjections;
5285
+ };
5286
+
5223
5287
  const jsenvPluginInliningAsDataUrl = () => {
5224
5288
  return {
5225
5289
  name: "jsenv:inlining_as_data_url",
@@ -11434,6 +11498,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11434
11498
  * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
11435
11499
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11436
11500
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11501
+ * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
11437
11502
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
11438
11503
  * @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
11439
11504
  * @param {AbortSignal} [params.signal] - Abort to stop the server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.3",
3
+ "version": "41.4.4",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -133,6 +133,25 @@ import { jsenvPluginMappings } from "./jsenv_plugin_mappings.js";
133
133
  * How URLs are versioned for this entry point (defaults to "search_param")
134
134
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
135
135
  * Sourcemap generation strategy for this entry point (defaults to "none")
136
+ * @param {object} [entryPoint.injections]
137
+ * Values to inject into files, as { urlPattern: getInjections }.
138
+ * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
139
+ * values are functions receiving urlInfo and returning (or resolving to)
140
+ * an object of placeholders to replace, named `__LIKE_THIS__` by convention:
141
+ *
142
+ * injections: {
143
+ * "./index.html": () => ({ __BACKEND_URL__: "https://api.example.com" }),
144
+ * }
145
+ *
146
+ * In JS files the value is injected as a JS literal (a string value brings its own quotes),
147
+ * everywhere else it is injected as-is, so it can be concatenated:
148
+ * `href="__BACKEND_URL__/users/me"`.
149
+ * An html url pattern also covers what is inlined in that html: an inline
150
+ * `<script>window.backendUrl = __BACKEND_URL__;</script>` gets the JS literal,
151
+ * which is how a value is shared with every js file of the page.
152
+ * Use INJECTIONS.optional(value) for a placeholder that may be absent from the file
153
+ * and INJECTIONS.global(value) to inject `Object.assign(window, { ... })` instead of
154
+ * replacing a placeholder.
136
155
  *
137
156
  * @return {Promise<Object>} buildReturnValue
138
157
  * @return {Promise<Object>} [buildReturnValue.buildInlineContents]
@@ -170,6 +189,17 @@ export const build = async ({
170
189
  {
171
190
  const unexpectedParamNames = Object.keys(rest);
172
191
  if (unexpectedParamNames.length > 0) {
192
+ const entryPointParamNames = unexpectedParamNames.filter((name) =>
193
+ Object.hasOwn(entryPointDefaultParams, name),
194
+ );
195
+ if (entryPointParamNames.length > 0) {
196
+ throw new TypeError(
197
+ `${entryPointParamNames.join(",")}: param(s) configured per entry point, move them into entryPoints, as in:
198
+ entryPoints: {
199
+ "./index.html": { ${entryPointParamNames.map((name) => `${name}: ...`).join(", ")} },
200
+ }`,
201
+ );
202
+ }
173
203
  throw new TypeError(
174
204
  `${unexpectedParamNames.join(",")}: there is no such param`,
175
205
  );
@@ -898,6 +898,11 @@ export const createBuildSpecifierManager = ({
898
898
  registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
899
899
  visitHtmlNodes(htmlAst, {
900
900
  link: (node) => {
901
+ if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
902
+ // reference analysis skipped this node, so it has no urlInfo in the graph
903
+ // and there is nothing to resync
904
+ return;
905
+ }
901
906
  const href = getHtmlNodeAttribute(node, "href");
902
907
  if (href === undefined || href.startsWith("data:")) {
903
908
  return;
@@ -51,6 +51,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
51
51
  * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
52
52
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
53
53
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
54
+ * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
54
55
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
55
56
  * @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
56
57
  * @param {AbortSignal} [params.signal] - Abort to stop the server.
@@ -3,11 +3,21 @@ import { composeTwoSourcemaps, createMagicSource } from "@jsenv/sourcemap";
3
3
 
4
4
  const injectionSymbol = Symbol.for("jsenv_injection");
5
5
  export 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 };
@@ -112,12 +122,7 @@ return {
112
122
  magicSource.replace({
113
123
  start,
114
124
  end,
115
- replacement:
116
- urlInfo.type === "js_classic" ||
117
- urlInfo.type === "js_module" ||
118
- urlInfo.type === "html"
119
- ? JSON.stringify(value, null, " ")
120
- : value,
125
+ replacement: asReplacement(value, urlInfo),
121
126
  });
122
127
  index = content.indexOf(key, end);
123
128
  }
@@ -125,6 +130,19 @@ return {
125
130
  return magicSource.toContentAndSourcemap();
126
131
  };
127
132
 
133
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
134
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
135
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
136
+ const asReplacement = (value, urlInfo) => {
137
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
138
+ return JSON.stringify(value, null, " ");
139
+ }
140
+ if (typeof value === "string") {
141
+ return value;
142
+ }
143
+ return JSON.stringify(value, null, " ");
144
+ };
145
+
128
146
  export const injectGlobals = (content, globals, urlInfo) => {
129
147
  if (urlInfo.type === "html") {
130
148
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -26,18 +26,45 @@ export const jsenvPluginInjections = (rawAssociations) => {
26
26
  { injectionsGetter: rawAssociations },
27
27
  context.rootDirectoryUrl,
28
28
  );
29
- getInjections = (urlInfo) => {
29
+ const findInjectionsGetter = (urlInfo) => {
30
30
  const { injectionsGetter } = URL_META.applyAssociations({
31
31
  url: asUrlWithoutSearch(urlInfo.url),
32
32
  associations: resolvedAssociations,
33
33
  });
34
- if (!injectionsGetter) {
34
+ if (injectionsGetter) {
35
+ return { injectionsGetter, isInherited: false };
36
+ }
37
+ if (urlInfo.isInline) {
38
+ // content inlined into a file (a <script> inside html) is authored in that
39
+ // file, so injections configured for the file must reach it too
40
+ const found = findInjectionsGetter(
41
+ urlInfo.firstReference.ownerUrlInfo,
42
+ );
43
+ if (found) {
44
+ return {
45
+ injectionsGetter: found.injectionsGetter,
46
+ isInherited: true,
47
+ };
48
+ }
49
+ }
50
+ return null;
51
+ };
52
+ getInjections = async (urlInfo) => {
53
+ const found = findInjectionsGetter(urlInfo);
54
+ if (!found) {
35
55
  return null;
36
56
  }
57
+ const { injectionsGetter, isInherited } = found;
37
58
  if (typeof injectionsGetter !== "function") {
38
59
  throw new TypeError("injectionsGetter must be a function");
39
60
  }
40
- return injectionsGetter(urlInfo);
61
+ const injections = await injectionsGetter(urlInfo);
62
+ if (!injections || !isInherited) {
63
+ return injections;
64
+ }
65
+ // the file holds several inline contents; a placeholder configured for the file
66
+ // is expected in one of them, not in each
67
+ return asOptionalInjections(injections);
41
68
  };
42
69
  }
43
70
  },
@@ -48,13 +75,12 @@ export const jsenvPluginInjections = (rawAssociations) => {
48
75
  contentInjections: defaultInjections,
49
76
  };
50
77
  }
51
- const injectionsResult = getInjections(urlInfo);
52
- if (!injectionsResult) {
78
+ const injections = await getInjections(urlInfo);
79
+ if (!injections) {
53
80
  return {
54
81
  contentInjections: defaultInjections,
55
82
  };
56
83
  }
57
- const injections = await injectionsResult;
58
84
  return {
59
85
  contentInjections: {
60
86
  ...defaultInjections,
@@ -64,3 +90,11 @@ export const jsenvPluginInjections = (rawAssociations) => {
64
90
  },
65
91
  };
66
92
  };
93
+
94
+ const asOptionalInjections = (injections) => {
95
+ const optionalInjections = {};
96
+ for (const key of Object.keys(injections)) {
97
+ optionalInjections[key] = INJECTIONS.optional(injections[key]);
98
+ }
99
+ return optionalInjections;
100
+ };
@@ -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()) {
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * cmd+K (ctrl+K elsewhere) on any page the dev server serves opens a list of
3
- * the .html files it serves, filter as you type, Enter to go there.
3
+ * the .html files it serves, filter as you type, Enter to go there. cmd+E
4
+ * (ctrl+E elsewhere) opens a page's file in the editor instead of going to it:
5
+ * the current page from anywhere, the selected row from inside the switcher.
4
6
  *
5
7
  * The list is the one the filesystem plugin already publishes for everyone
6
8
  * (GET /.internal/pages.json, see protocol_file/html_pages.js) — this only adds
@@ -6,7 +6,9 @@
6
6
  * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks
7
7
  * gets the same answer.
8
8
  *
9
- * Each page comes with what kind of page it is, read from where it sits and
9
+ * Each page comes with where its file is (so it can be opened in an editor as
10
+ * well as in the browser) and with what kind of page it is, read from where it
11
+ * sits and
10
12
  * what it is called — the two conventions this repo already follows:
11
13
  * - "experiment": something tried out, under a lab/ directory or named
12
14
  * *_experiment.html;
@@ -107,6 +109,10 @@ export const createHtmlPageLister = ({ rootDirectoryUrl }) => {
107
109
  );
108
110
  return {
109
111
  url: `/${relativeUrl}`,
112
+ // Where the file actually is, so whoever wants to open it in an editor
113
+ // rather than in the browser has what GET /.internal/open_file/* asks
114
+ // for (a file url) without having to know the root directory.
115
+ fileUrl,
110
116
  kind: readKind(meta),
111
117
  // Relative to the root and without its trailing slash, which is how a
112
118
  // tree names its own nodes.
@@ -98,6 +98,10 @@ export const jsenvPluginFsRedirection = ({
98
98
  }
99
99
  const { requestedUrl, rootDirectoryUrl, mainFilePath } =
100
100
  reference.ownerUrlInfo.context;
101
+ if (!requestedUrl) {
102
+ // the SPA fallback answers a request; during build there is none
103
+ return null;
104
+ }
101
105
  const closestHtmlRootFile = getClosestHtmlRootFile(
102
106
  requestedUrl,
103
107
  rootDirectoryUrl,