@jsenv/core 41.4.3 → 41.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -4551,6 +4563,7 @@ ${reason}`,
4551
4563
  }
4552
4564
  return createFailedToResolveUrlError({
4553
4565
  reason: `An error occured during specifier resolution`,
4566
+ ...detailsFromInjectionsOnOwner(reference),
4554
4567
  ...detailsFromValueThrown(error),
4555
4568
  });
4556
4569
  };
@@ -4612,6 +4625,7 @@ ${reason}`,
4612
4625
  return createFailedToFetchUrlContentError({
4613
4626
  code: "NOT_FOUND",
4614
4627
  reason: "no entry on filesystem",
4628
+ ...detailsFromInjectionsOnOwner(urlInfo.firstReference),
4615
4629
  });
4616
4630
  }
4617
4631
  }
@@ -4844,6 +4858,32 @@ const getFirstReferenceInProject = (reference) => {
4844
4858
  return getFirstReferenceInProject(firstReference);
4845
4859
  };
4846
4860
 
4861
+ // An url written by an injection cannot be resolved: the placeholder is still there
4862
+ // when references are analyzed. Rather than guessing what a placeholder looks like
4863
+ // (the key is free-form), tell the file it comes from: injections are configured for it.
4864
+ const detailsFromInjectionsOnOwner = (reference) => {
4865
+ if (!reference) {
4866
+ return {};
4867
+ }
4868
+ const ownerUrlInfo = reference.ownerUrlInfo;
4869
+ if (ownerUrlInfo.type !== "html") {
4870
+ // "jsenv-ignore" is an html attribute
4871
+ return {};
4872
+ }
4873
+ const { hasInjections } = ownerUrlInfo.context;
4874
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
4875
+ return {};
4876
+ }
4877
+ const { node, attributeName } = reference.astInfo || {};
4878
+ if (!node || !attributeName) {
4879
+ return {};
4880
+ }
4881
+ return {
4882
+ suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, add "jsenv-ignore" so jsenv leaves that url alone:
4883
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
4884
+ };
4885
+ };
4886
+
4847
4887
  const detailsFromPluginController = (jsenvPluginsController) => {
4848
4888
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
4849
4889
  if (!currentPlugin) {
@@ -4986,11 +5026,21 @@ const jsenvPluginDirectoryReferenceEffect = (
4986
5026
 
4987
5027
  const injectionSymbol = Symbol.for("jsenv_injection");
4988
5028
  const INJECTIONS = {
5029
+ /**
5030
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
5031
+ * (into a script for html, into the module itself for js) instead of
5032
+ * replacing a placeholder: the value is read at runtime as a global.
5033
+ */
4989
5034
  global: (value) => {
4990
5035
  return { [injectionSymbol]: "global", value };
4991
5036
  },
5037
+ /**
5038
+ * Replace the placeholder when the file contains it, stay silent when it does not
5039
+ * (without this a missing placeholder is reported as a warning).
5040
+ */
4992
5041
  optional: (value) => {
4993
- if (value && value[injectionSymbol] === "optional") {
5042
+ if (value && value[injectionSymbol]) {
5043
+ // a global injection is not a placeholder, it can't be missing from the file
4994
5044
  return value;
4995
5045
  }
4996
5046
  return { [injectionSymbol]: "optional", value };
@@ -5095,12 +5145,7 @@ return {
5095
5145
  magicSource.replace({
5096
5146
  start,
5097
5147
  end,
5098
- replacement:
5099
- urlInfo.type === "js_classic" ||
5100
- urlInfo.type === "js_module" ||
5101
- urlInfo.type === "html"
5102
- ? JSON.stringify(value, null, " ")
5103
- : value,
5148
+ replacement: asReplacement(value, urlInfo),
5104
5149
  });
5105
5150
  index = content.indexOf(key, end);
5106
5151
  }
@@ -5108,6 +5153,19 @@ return {
5108
5153
  return magicSource.toContentAndSourcemap();
5109
5154
  };
5110
5155
 
5156
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
5157
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
5158
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
5159
+ const asReplacement = (value, urlInfo) => {
5160
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
5161
+ return JSON.stringify(value, null, " ");
5162
+ }
5163
+ if (typeof value === "string") {
5164
+ return value;
5165
+ }
5166
+ return JSON.stringify(value, null, " ");
5167
+ };
5168
+
5111
5169
  const injectGlobals = (content, globals, urlInfo) => {
5112
5170
  if (urlInfo.type === "html") {
5113
5171
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -5115,7 +5173,14 @@ const injectGlobals = (content, globals, urlInfo) => {
5115
5173
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
5116
5174
  return globalsInjectorOnJs(content, globals, urlInfo);
5117
5175
  }
5118
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
5176
+ throw new Error(
5177
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
5178
+ file: urlInfo.url,
5179
+ ...(urlInfo.isInline
5180
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
5181
+ : {}),
5182
+ }),
5183
+ );
5119
5184
  };
5120
5185
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
5121
5186
  // ideally we would inject an importmap but browser support is too low
@@ -5181,18 +5246,52 @@ const jsenvPluginInjections = (rawAssociations) => {
5181
5246
  { injectionsGetter: rawAssociations },
5182
5247
  context.rootDirectoryUrl,
5183
5248
  );
5184
- getInjections = (urlInfo) => {
5249
+ const findInjectionsGetterForUrl = (url) => {
5185
5250
  const { injectionsGetter } = URL_META.applyAssociations({
5186
- url: asUrlWithoutSearch(urlInfo.url),
5251
+ url: asUrlWithoutSearch(url),
5187
5252
  associations: resolvedAssociations,
5188
5253
  });
5189
- if (!injectionsGetter) {
5254
+ return injectionsGetter;
5255
+ };
5256
+ // an url written by an injection cannot be resolved during reference analysis;
5257
+ // errors use this to tell the file holds injections and suggest "jsenv-ignore"
5258
+ context.hasInjections = (url) => {
5259
+ return Boolean(findInjectionsGetterForUrl(url));
5260
+ };
5261
+ const findInjectionsGetter = (urlInfo) => {
5262
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
5263
+ if (injectionsGetter) {
5264
+ return { injectionsGetter, isInherited: false };
5265
+ }
5266
+ if (urlInfo.isInline) {
5267
+ // content inlined into a file (a <script> inside html) is authored in that
5268
+ // file, so injections configured for the file must reach it too
5269
+ const found = findInjectionsGetter(
5270
+ urlInfo.firstReference.ownerUrlInfo,
5271
+ );
5272
+ if (found) {
5273
+ return {
5274
+ injectionsGetter: found.injectionsGetter,
5275
+ isInherited: true,
5276
+ };
5277
+ }
5278
+ }
5279
+ return null;
5280
+ };
5281
+ getInjections = async (urlInfo) => {
5282
+ const found = findInjectionsGetter(urlInfo);
5283
+ if (!found) {
5190
5284
  return null;
5191
5285
  }
5286
+ const { injectionsGetter, isInherited } = found;
5192
5287
  if (typeof injectionsGetter !== "function") {
5193
5288
  throw new TypeError("injectionsGetter must be a function");
5194
5289
  }
5195
- return injectionsGetter(urlInfo);
5290
+ const injections = await injectionsGetter(urlInfo);
5291
+ if (!injections || !isInherited) {
5292
+ return injections;
5293
+ }
5294
+ return asInheritedInjections(injections);
5196
5295
  };
5197
5296
  }
5198
5297
  },
@@ -5203,13 +5302,12 @@ const jsenvPluginInjections = (rawAssociations) => {
5203
5302
  contentInjections: defaultInjections,
5204
5303
  };
5205
5304
  }
5206
- const injectionsResult = getInjections(urlInfo);
5207
- if (!injectionsResult) {
5305
+ const injections = await getInjections(urlInfo);
5306
+ if (!injections) {
5208
5307
  return {
5209
5308
  contentInjections: defaultInjections,
5210
5309
  };
5211
5310
  }
5212
- const injections = await injectionsResult;
5213
5311
  return {
5214
5312
  contentInjections: {
5215
5313
  ...defaultInjections,
@@ -5220,6 +5318,26 @@ const jsenvPluginInjections = (rawAssociations) => {
5220
5318
  };
5221
5319
  };
5222
5320
 
5321
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
5322
+ // and inherits its injections, with two adjustments:
5323
+ // - a global belongs to the file itself, injecting it into each inline content would
5324
+ // repeat it and reach types that cannot receive globals (css)
5325
+ // - a placeholder configured for the file is expected in one of its inline contents,
5326
+ // not in each, so a missing one is not worth a warning
5327
+ const asInheritedInjections = (injections) => {
5328
+ const inheritedInjections = {};
5329
+ for (const key of Object.keys(injections)) {
5330
+ const value = injections[key];
5331
+ if (isPlaceholderInjection(value)) {
5332
+ inheritedInjections[key] = INJECTIONS.optional(value);
5333
+ }
5334
+ }
5335
+ if (Object.keys(inheritedInjections).length === 0) {
5336
+ return null;
5337
+ }
5338
+ return inheritedInjections;
5339
+ };
5340
+
5223
5341
  const jsenvPluginInliningAsDataUrl = () => {
5224
5342
  return {
5225
5343
  name: "jsenv:inlining_as_data_url",
@@ -11434,6 +11552,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11434
11552
  * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
11435
11553
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11436
11554
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11555
+ * @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
11556
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
11438
11557
  * @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
11439
11558
  * @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.5",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -72,12 +72,12 @@
72
72
  "test:snapshot_clear": "npx @jsenv/filesystem clear **/tests/**/side_effects/"
73
73
  },
74
74
  "dependencies": {
75
- "@jsenv/ast": "6.8.4",
76
- "@jsenv/js-module-fallback": "1.4.37",
75
+ "@jsenv/ast": "6.8.5",
76
+ "@jsenv/js-module-fallback": "1.4.38",
77
77
  "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.5",
79
- "@jsenv/plugin-supervisor": "1.8.8",
80
- "@jsenv/plugin-transpilation": "1.5.78",
78
+ "@jsenv/plugin-minification": "1.7.6",
79
+ "@jsenv/plugin-supervisor": "1.8.9",
80
+ "@jsenv/plugin-transpilation": "1.5.79",
81
81
  "@jsenv/server": "17.6.0",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
@@ -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.
@@ -78,6 +78,7 @@ ${reason}`,
78
78
  }
79
79
  return createFailedToResolveUrlError({
80
80
  reason: `An error occured during specifier resolution`,
81
+ ...detailsFromInjectionsOnOwner(reference),
81
82
  ...detailsFromValueThrown(error),
82
83
  });
83
84
  };
@@ -139,6 +140,7 @@ ${reason}`,
139
140
  return createFailedToFetchUrlContentError({
140
141
  code: "NOT_FOUND",
141
142
  reason: "no entry on filesystem",
143
+ ...detailsFromInjectionsOnOwner(urlInfo.firstReference),
142
144
  });
143
145
  }
144
146
  }
@@ -371,6 +373,32 @@ const getFirstReferenceInProject = (reference) => {
371
373
  return getFirstReferenceInProject(firstReference);
372
374
  };
373
375
 
376
+ // An url written by an injection cannot be resolved: the placeholder is still there
377
+ // when references are analyzed. Rather than guessing what a placeholder looks like
378
+ // (the key is free-form), tell the file it comes from: injections are configured for it.
379
+ const detailsFromInjectionsOnOwner = (reference) => {
380
+ if (!reference) {
381
+ return {};
382
+ }
383
+ const ownerUrlInfo = reference.ownerUrlInfo;
384
+ if (ownerUrlInfo.type !== "html") {
385
+ // "jsenv-ignore" is an html attribute
386
+ return {};
387
+ }
388
+ const { hasInjections } = ownerUrlInfo.context;
389
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
390
+ return {};
391
+ }
392
+ const { node, attributeName } = reference.astInfo || {};
393
+ if (!node || !attributeName) {
394
+ return {};
395
+ }
396
+ return {
397
+ suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, add "jsenv-ignore" so jsenv leaves that url alone:
398
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
399
+ };
400
+ };
401
+
374
402
  const detailsFromPluginController = (jsenvPluginsController) => {
375
403
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
376
404
  if (!currentPlugin) {
@@ -1,13 +1,24 @@
1
1
  import { injectJsenvScript, parseHtml, stringifyHtmlAst } from "@jsenv/ast";
2
+ import { createDetailedMessage } from "@jsenv/humanize";
2
3
  import { composeTwoSourcemaps, createMagicSource } from "@jsenv/sourcemap";
3
4
 
4
5
  const injectionSymbol = Symbol.for("jsenv_injection");
5
6
  export const INJECTIONS = {
7
+ /**
8
+ * Inject `Object.assign(window, { [key]: value })` at the top of the file
9
+ * (into a script for html, into the module itself for js) instead of
10
+ * replacing a placeholder: the value is read at runtime as a global.
11
+ */
6
12
  global: (value) => {
7
13
  return { [injectionSymbol]: "global", value };
8
14
  },
15
+ /**
16
+ * Replace the placeholder when the file contains it, stay silent when it does not
17
+ * (without this a missing placeholder is reported as a warning).
18
+ */
9
19
  optional: (value) => {
10
- if (value && value[injectionSymbol] === "optional") {
20
+ if (value && value[injectionSymbol]) {
21
+ // a global injection is not a placeholder, it can't be missing from the file
11
22
  return value;
12
23
  }
13
24
  return { [injectionSymbol]: "optional", value };
@@ -112,12 +123,7 @@ return {
112
123
  magicSource.replace({
113
124
  start,
114
125
  end,
115
- replacement:
116
- urlInfo.type === "js_classic" ||
117
- urlInfo.type === "js_module" ||
118
- urlInfo.type === "html"
119
- ? JSON.stringify(value, null, " ")
120
- : value,
126
+ replacement: asReplacement(value, urlInfo),
121
127
  });
122
128
  index = content.indexOf(key, end);
123
129
  }
@@ -125,6 +131,19 @@ return {
125
131
  return magicSource.toContentAndSourcemap();
126
132
  };
127
133
 
134
+ // In JS the placeholder stands for a value, so it must be substituted by a literal.
135
+ // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
136
+ // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
137
+ const asReplacement = (value, urlInfo) => {
138
+ if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
139
+ return JSON.stringify(value, null, " ");
140
+ }
141
+ if (typeof value === "string") {
142
+ return value;
143
+ }
144
+ return JSON.stringify(value, null, " ");
145
+ };
146
+
128
147
  export const injectGlobals = (content, globals, urlInfo) => {
129
148
  if (urlInfo.type === "html") {
130
149
  return globalInjectorOnHtml(content, globals, urlInfo);
@@ -132,7 +151,14 @@ export const injectGlobals = (content, globals, urlInfo) => {
132
151
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
133
152
  return globalsInjectorOnJs(content, globals, urlInfo);
134
153
  }
135
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
154
+ throw new Error(
155
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
156
+ file: urlInfo.url,
157
+ ...(urlInfo.isInline
158
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
159
+ : {}),
160
+ }),
161
+ );
136
162
  };
137
163
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
138
164
  // ideally we would inject an importmap but browser support is too low
@@ -1,6 +1,9 @@
1
1
  import { URL_META } from "@jsenv/url-meta";
2
2
  import { asUrlWithoutSearch, urlToRelativeUrl } from "@jsenv/urls";
3
- import { INJECTIONS } from "../../kitchen/url_graph/url_info_injections.js";
3
+ import {
4
+ INJECTIONS,
5
+ isPlaceholderInjection,
6
+ } from "../../kitchen/url_graph/url_info_injections.js";
4
7
 
5
8
  export const jsenvPluginInjections = (rawAssociations) => {
6
9
  const getDefaultInjections = (urlInfo) => {
@@ -26,18 +29,52 @@ export const jsenvPluginInjections = (rawAssociations) => {
26
29
  { injectionsGetter: rawAssociations },
27
30
  context.rootDirectoryUrl,
28
31
  );
29
- getInjections = (urlInfo) => {
32
+ const findInjectionsGetterForUrl = (url) => {
30
33
  const { injectionsGetter } = URL_META.applyAssociations({
31
- url: asUrlWithoutSearch(urlInfo.url),
34
+ url: asUrlWithoutSearch(url),
32
35
  associations: resolvedAssociations,
33
36
  });
34
- if (!injectionsGetter) {
37
+ return injectionsGetter;
38
+ };
39
+ // an url written by an injection cannot be resolved during reference analysis;
40
+ // errors use this to tell the file holds injections and suggest "jsenv-ignore"
41
+ context.hasInjections = (url) => {
42
+ return Boolean(findInjectionsGetterForUrl(url));
43
+ };
44
+ const findInjectionsGetter = (urlInfo) => {
45
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
46
+ if (injectionsGetter) {
47
+ return { injectionsGetter, isInherited: false };
48
+ }
49
+ if (urlInfo.isInline) {
50
+ // content inlined into a file (a <script> inside html) is authored in that
51
+ // file, so injections configured for the file must reach it too
52
+ const found = findInjectionsGetter(
53
+ urlInfo.firstReference.ownerUrlInfo,
54
+ );
55
+ if (found) {
56
+ return {
57
+ injectionsGetter: found.injectionsGetter,
58
+ isInherited: true,
59
+ };
60
+ }
61
+ }
62
+ return null;
63
+ };
64
+ getInjections = async (urlInfo) => {
65
+ const found = findInjectionsGetter(urlInfo);
66
+ if (!found) {
35
67
  return null;
36
68
  }
69
+ const { injectionsGetter, isInherited } = found;
37
70
  if (typeof injectionsGetter !== "function") {
38
71
  throw new TypeError("injectionsGetter must be a function");
39
72
  }
40
- return injectionsGetter(urlInfo);
73
+ const injections = await injectionsGetter(urlInfo);
74
+ if (!injections || !isInherited) {
75
+ return injections;
76
+ }
77
+ return asInheritedInjections(injections);
41
78
  };
42
79
  }
43
80
  },
@@ -48,13 +85,12 @@ export const jsenvPluginInjections = (rawAssociations) => {
48
85
  contentInjections: defaultInjections,
49
86
  };
50
87
  }
51
- const injectionsResult = getInjections(urlInfo);
52
- if (!injectionsResult) {
88
+ const injections = await getInjections(urlInfo);
89
+ if (!injections) {
53
90
  return {
54
91
  contentInjections: defaultInjections,
55
92
  };
56
93
  }
57
- const injections = await injectionsResult;
58
94
  return {
59
95
  contentInjections: {
60
96
  ...defaultInjections,
@@ -64,3 +100,23 @@ export const jsenvPluginInjections = (rawAssociations) => {
64
100
  },
65
101
  };
66
102
  };
103
+
104
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
105
+ // and inherits its injections, with two adjustments:
106
+ // - a global belongs to the file itself, injecting it into each inline content would
107
+ // repeat it and reach types that cannot receive globals (css)
108
+ // - a placeholder configured for the file is expected in one of its inline contents,
109
+ // not in each, so a missing one is not worth a warning
110
+ const asInheritedInjections = (injections) => {
111
+ const inheritedInjections = {};
112
+ for (const key of Object.keys(injections)) {
113
+ const value = injections[key];
114
+ if (isPlaceholderInjection(value)) {
115
+ inheritedInjections[key] = INJECTIONS.optional(value);
116
+ }
117
+ }
118
+ if (Object.keys(inheritedInjections).length === 0) {
119
+ return null;
120
+ }
121
+ return inheritedInjections;
122
+ };