@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.
- package/dist/build/build.js +111 -14
- package/dist/js/page_switcher.js +136 -13
- package/dist/jsenv_core.js +11 -1
- package/dist/start_dev_server/start_dev_server.js +80 -15
- package/package.json +1 -1
- package/src/build/build.js +30 -0
- package/src/build/build_specifier_manager.js +5 -0
- package/src/dev/start_dev_server.js +1 -0
- package/src/kitchen/url_graph/url_info_injections.js +25 -7
- package/src/plugins/injections/jsenv_plugin_injections.js +40 -6
- package/src/plugins/page_switcher/client/page_switcher.js +136 -13
- package/src/plugins/page_switcher/jsenv_plugin_page_switcher.js +3 -1
- package/src/plugins/protocol_file/html_pages.js +7 -1
- package/src/plugins/protocol_file/jsenv_plugin_fs_redirection.js +4 -0
package/dist/build/build.js
CHANGED
|
@@ -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]
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
7650
|
-
if (!
|
|
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
|
);
|
package/dist/js/page_switcher.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
818
|
+
if (event.defaultPrevented) {
|
|
712
819
|
return;
|
|
713
820
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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()) {
|
package/dist/jsenv_core.js
CHANGED
|
@@ -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]
|
|
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
|
|
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]
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
5207
|
-
if (!
|
|
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
package/src/build/build.js
CHANGED
|
@@ -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]
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
52
|
-
if (!
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
818
|
+
if (event.defaultPrevented) {
|
|
712
819
|
return;
|
|
713
820
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
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,
|