@jsenv/core 41.4.5 → 41.4.7

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.
@@ -2208,6 +2208,25 @@ const isWebWorkerUrlInfo = (urlInfo) => {
2208
2208
  // return false
2209
2209
  // }
2210
2210
 
2211
+ // A bare specifier ("preact", "@jsenv/core/x.js") is resolved by node esm resolution,
2212
+ // everything else ("/a.js", "./a.js", "http://example.com/a.js") by url resolution
2213
+ const isBareSpecifier = (specifier) => {
2214
+ if (
2215
+ specifier[0] === "/" ||
2216
+ specifier.startsWith("./") ||
2217
+ specifier.startsWith("../")
2218
+ ) {
2219
+ return false;
2220
+ }
2221
+ try {
2222
+ // eslint-disable-next-line no-new
2223
+ new URL(specifier);
2224
+ return false;
2225
+ } catch {
2226
+ return true;
2227
+ }
2228
+ };
2229
+
2211
2230
  const jsenvPluginJsReferenceAnalysis = ({ inlineContent }) => {
2212
2231
  return [
2213
2232
  {
@@ -2304,7 +2323,7 @@ const parseAndTransformJsReferences = async (
2304
2323
  let filenameHint;
2305
2324
  if (
2306
2325
  externalReferenceInfo.subtype === "import_dynamic" &&
2307
- isBareSpecifier$1(externalReferenceInfo.specifier)
2326
+ isBareSpecifier(externalReferenceInfo.specifier)
2308
2327
  ) {
2309
2328
  filenameHint = `${externalReferenceInfo.specifier}.js`;
2310
2329
  }
@@ -2394,23 +2413,6 @@ const parseAndTransformJsReferences = async (
2394
2413
  return { content, sourcemap };
2395
2414
  };
2396
2415
 
2397
- const isBareSpecifier$1 = (specifier) => {
2398
- if (
2399
- specifier[0] === "/" ||
2400
- specifier.startsWith("./") ||
2401
- specifier.startsWith("../")
2402
- ) {
2403
- return false;
2404
- }
2405
- try {
2406
- // eslint-disable-next-line no-new
2407
- new URL(specifier);
2408
- return false;
2409
- } catch {
2410
- return true;
2411
- }
2412
- };
2413
-
2414
2416
  const jsenvPluginReferenceExpectedTypes = () => {
2415
2417
  const redirectJsReference = (reference) => {
2416
2418
  const urlObject = new URL(reference.url);
@@ -3080,23 +3082,6 @@ const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
3080
3082
  };
3081
3083
  };
3082
3084
 
3083
- const isBareSpecifier = (specifier) => {
3084
- if (
3085
- specifier[0] === "/" ||
3086
- specifier.startsWith("./") ||
3087
- specifier.startsWith("../")
3088
- ) {
3089
- return false;
3090
- }
3091
- try {
3092
- // eslint-disable-next-line no-new
3093
- new URL(specifier);
3094
- return false;
3095
- } catch {
3096
- return true;
3097
- }
3098
- };
3099
-
3100
3085
  const jsenvPluginNodeEsmResolution = ({
3101
3086
  packageDirectory,
3102
3087
  resolutionConfig = {},
@@ -3243,7 +3228,13 @@ const jsenvPluginWebResolution = () => {
3243
3228
  if (ownerUrlInfo.originalUrl?.startsWith("http")) {
3244
3229
  return new URL(resource, ownerUrlInfo.originalUrl);
3245
3230
  }
3246
- const url = new URL(resource.slice(1), ownerUrlInfo.entryUrlInfo.url);
3231
+ // "/x" is the web meaning of a root-relative url: the root of what is
3232
+ // served, which is the source directory — not the directory the entry
3233
+ // point happens to sit in.
3234
+ const url = new URL(
3235
+ resource.slice(1),
3236
+ ownerUrlInfo.context.rootDirectoryUrl,
3237
+ );
3247
3238
  return url;
3248
3239
  }
3249
3240
  // baseUrl happens second argument to new URL() is different from
@@ -4858,9 +4849,10 @@ const getFirstReferenceInProject = (reference) => {
4858
4849
  return getFirstReferenceInProject(firstReference);
4859
4850
  };
4860
4851
 
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.
4852
+ // Injections write urls in html attributes before references are analyzed, so an url
4853
+ // that still cannot be resolved may be a placeholder no injection replaced. Rather than
4854
+ // guessing what a placeholder looks like (the key is free-form), tell the file it comes
4855
+ // from: injections are configured for it.
4864
4856
  const detailsFromInjectionsOnOwner = (reference) => {
4865
4857
  if (!reference) {
4866
4858
  return {};
@@ -4879,7 +4871,7 @@ const detailsFromInjectionsOnOwner = (reference) => {
4879
4871
  return {};
4880
4872
  }
4881
4873
  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:
4874
+ suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, check the placeholder spelling, or add "jsenv-ignore" so jsenv leaves that url alone:
4883
4875
  <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
4884
4876
  };
4885
4877
  };
@@ -5047,6 +5039,13 @@ const INJECTIONS = {
5047
5039
  },
5048
5040
  };
5049
5041
 
5042
+ const readInjectionValue = (injection) => {
5043
+ if (injection && injection[injectionSymbol]) {
5044
+ return injection.value;
5045
+ }
5046
+ return injection;
5047
+ };
5048
+
5050
5049
  const isPlaceholderInjection = (value) => {
5051
5050
  return (
5052
5051
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -5120,7 +5119,7 @@ const injectPlaceholderReplacements = (
5120
5119
  for (const { key, isOptional, value } of placeholderReplacements) {
5121
5120
  let index = content.indexOf(key);
5122
5121
  if (index === -1) {
5123
- if (!isOptional) {
5122
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
5124
5123
  urlInfo.context.logger.warn(
5125
5124
  `placeholder "${key}" not found in ${urlInfo.url}.
5126
5125
  --- suggestion a ---
@@ -5145,7 +5144,7 @@ return {
5145
5144
  magicSource.replace({
5146
5145
  start,
5147
5146
  end,
5148
- replacement: asReplacement(value, urlInfo),
5147
+ replacement: asReplacement(value, urlInfo.type),
5149
5148
  });
5150
5149
  index = content.indexOf(key, end);
5151
5150
  }
@@ -5156,8 +5155,8 @@ return {
5156
5155
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
5157
5156
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
5158
5157
  // 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") {
5158
+ const asReplacement = (value, type) => {
5159
+ if (type === "js_classic" || type === "js_module") {
5161
5160
  return JSON.stringify(value, null, " ");
5162
5161
  }
5163
5162
  if (typeof value === "string") {
@@ -5235,6 +5234,7 @@ const jsenvPluginInjections = (rawAssociations) => {
5235
5234
  }
5236
5235
  return null;
5237
5236
  };
5237
+ const injectionsForUrlInfoMap = new WeakMap();
5238
5238
  let getInjections = null;
5239
5239
 
5240
5240
  return {
@@ -5253,8 +5253,8 @@ const jsenvPluginInjections = (rawAssociations) => {
5253
5253
  });
5254
5254
  return injectionsGetter;
5255
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"
5256
+ // errors and the build read this to know an unresolved url may come from
5257
+ // an injection, and word what they report accordingly
5258
5258
  context.hasInjections = (url) => {
5259
5259
  return Boolean(findInjectionsGetterForUrl(url));
5260
5260
  };
@@ -5308,16 +5308,61 @@ const jsenvPluginInjections = (rawAssociations) => {
5308
5308
  contentInjections: defaultInjections,
5309
5309
  };
5310
5310
  }
5311
+ injectionsForUrlInfoMap.set(urlInfo, injections);
5311
5312
  return {
5312
- contentInjections: {
5313
- ...defaultInjections,
5314
- ...injections,
5315
- },
5313
+ contentInjections: { ...defaultInjections, ...injections },
5316
5314
  };
5317
5315
  },
5316
+ // The content still holds the placeholders when references are analyzed: they are
5317
+ // replaced at the very end, once the type of every inline content is known.
5318
+ // A specifier must not wait for that, it would resolve "__BACKEND_URL__/users/me"
5319
+ // as a file sitting next to the document. Only the placeholder is resolved here,
5320
+ // then the specifier goes to whoever resolves this kind of url (node esm, web, ...)
5321
+ resolveReference: (reference) => {
5322
+ const { ownerUrlInfo } = reference;
5323
+ const injections = injectionsForUrlInfoMap.get(ownerUrlInfo);
5324
+ if (!injections) {
5325
+ return null;
5326
+ }
5327
+ const { specifier, keySet } = injectIntoSpecifier(
5328
+ reference.specifier,
5329
+ injections,
5330
+ );
5331
+ if (keySet.size === 0) {
5332
+ return null;
5333
+ }
5334
+ reference.specifier = specifier;
5335
+ for (const key of keySet) {
5336
+ // rewriting the specifier takes the placeholder out of the content,
5337
+ // so the injection step must not expect to find it there
5338
+ ownerUrlInfo.contentInjectionUsedKeySet.add(key);
5339
+ }
5340
+ return null;
5341
+ },
5318
5342
  };
5319
5343
  };
5320
5344
 
5345
+ const injectIntoSpecifier = (specifier, injections) => {
5346
+ let specifierInjected = specifier;
5347
+ const keySet = new Set();
5348
+ for (const key of Object.keys(injections)) {
5349
+ if (!specifierInjected.includes(key)) {
5350
+ continue;
5351
+ }
5352
+ const injection = injections[key];
5353
+ if (!isPlaceholderInjection(injection)) {
5354
+ continue;
5355
+ }
5356
+ const value = readInjectionValue(injection);
5357
+ if (typeof value !== "string") {
5358
+ continue;
5359
+ }
5360
+ specifierInjected = specifierInjected.replaceAll(key, value);
5361
+ keySet.add(key);
5362
+ }
5363
+ return { specifier: specifierInjected, keySet };
5364
+ };
5365
+
5321
5366
  // What a file inlines (a <script> or a <style> inside html) is authored in that file
5322
5367
  // and inherits its injections, with two adjustments:
5323
5368
  // - a global belongs to the file itself, injecting it into each inline content would
@@ -7645,8 +7690,10 @@ const getCorePlugins = ({
7645
7690
  ...(packageBundle
7646
7691
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
7647
7692
  : []),
7648
- jsenvPluginReferenceAnalysis(referenceAnalysis),
7693
+ // before reference analysis: an url written by an injection must hold its
7694
+ // final value when references are analyzed
7649
7695
  jsenvPluginInjections(injections),
7696
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
7650
7697
  jsenvPluginTranspilation(transpilation),
7651
7698
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
7652
7699
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -9309,6 +9356,9 @@ const createUrlInfo = (url, context) => {
9309
9356
  contentFinalized: false,
9310
9357
  contentSideEffects: [],
9311
9358
  contentInjections: {},
9359
+ // placeholders already consumed somewhere else than the content (in a specifier),
9360
+ // so that not finding them in the content is not worth a warning
9361
+ contentInjectionUsedKeySet: new Set(),
9312
9362
 
9313
9363
  sourcemap: null,
9314
9364
  sourcemapIsWrong: false,
@@ -11110,7 +11160,16 @@ const devServerPluginServeSourceFiles = ({
11110
11160
  }
11111
11161
  }
11112
11162
  });
11113
- const clientRuntimeCompat = { [runtimeName]: runtimeVersion };
11163
+ // A client we cannot identify (curl, fetch, a healthcheck, a proxy dropping
11164
+ // the user agent) is assumed to be one of the runtimes the project targets.
11165
+ // Treating it as a runtime supporting nothing would serve it the js module
11166
+ // fallback, breaking tooling perfectly capable of ES modules; an actual
11167
+ // ancient browser hiding its identity fails loudly instead, which is the
11168
+ // cheaper of the two mistakes.
11169
+ const clientRuntimeCompat =
11170
+ runtimeName === "unknown"
11171
+ ? runtimeCompat
11172
+ : { [runtimeName]: runtimeVersion };
11114
11173
 
11115
11174
  kitchen = createKitchen({
11116
11175
  name: runtimeId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.5",
3
+ "version": "41.4.7",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -69,6 +69,7 @@ import {
69
69
  createJsenvPluginsController,
70
70
  createJsenvPluginStore,
71
71
  } from "../plugins/jsenv_plugins_controller.js";
72
+ import { isBareSpecifier } from "../helpers/bare_specifier.js";
72
73
  import { getCorePlugins } from "../plugins/plugins.js";
73
74
  import { jsenvPluginReferenceAnalysis } from "../plugins/reference_analysis/jsenv_plugin_reference_analysis.js";
74
75
  import { renderBuildDoneLog } from "./build_content_report.js";
@@ -1248,6 +1249,7 @@ const prepareEntryPointBuild = async (
1248
1249
  base,
1249
1250
  assetsDirectory,
1250
1251
  buildUrlsGenerator,
1252
+ entryKey: sourceRelativeUrl,
1251
1253
 
1252
1254
  versioning,
1253
1255
  versioningMethod,
@@ -1488,7 +1490,10 @@ const prepareEntryPointBuild = async (
1488
1490
  const registerHtmlMutation = (callback) => {
1489
1491
  htmlMutationCallbackSet.add(callback);
1490
1492
  };
1491
- htmlRefine(htmlAst, { registerHtmlMutation });
1493
+ htmlRefine(htmlAst, {
1494
+ registerHtmlMutation,
1495
+ htmlUrlInfo: urlInfo,
1496
+ });
1492
1497
  for (const htmlMutationCallback of htmlMutationCallbackSet) {
1493
1498
  htmlMutationCallback();
1494
1499
  }
@@ -1572,20 +1577,3 @@ const prepareEntryPointBuild = async (
1572
1577
  },
1573
1578
  };
1574
1579
  };
1575
-
1576
- const isBareSpecifier = (specifier) => {
1577
- if (
1578
- specifier[0] === "/" ||
1579
- specifier.startsWith("./") ||
1580
- specifier.startsWith("../")
1581
- ) {
1582
- return false;
1583
- }
1584
- try {
1585
- // eslint-disable-next-line no-new
1586
- new URL(specifier);
1587
- return false;
1588
- } catch {
1589
- return true;
1590
- }
1591
- };
@@ -22,6 +22,7 @@ import {
22
22
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
23
23
  import { escapeRegexpSpecialChars } from "@jsenv/utils/src/string/escape_regexp_special_chars.js";
24
24
  import { createHash } from "node:crypto";
25
+ import { existsSync } from "node:fs";
25
26
  import { prependContent } from "../kitchen/prepend_content.js";
26
27
  import { GRAPH_VISITOR } from "../kitchen/url_graph/url_graph_visitor.js";
27
28
  import { isWebWorkerUrlInfo } from "../kitchen/web_workers.js";
@@ -38,6 +39,7 @@ export const createBuildSpecifierManager = ({
38
39
  buildDirectoryUrl,
39
40
  assetsDirectory,
40
41
  buildUrlsGenerator,
42
+ entryKey,
41
43
  base,
42
44
  length = 8,
43
45
 
@@ -76,6 +78,7 @@ export const createBuildSpecifierManager = ({
76
78
  urlInfo,
77
79
  ownerUrlInfo: reference.ownerUrlInfo,
78
80
  assetsDirectory,
81
+ entryKey,
79
82
  });
80
83
  }
81
84
 
@@ -895,7 +898,7 @@ export const createBuildSpecifierManager = ({
895
898
 
896
899
  prepareResyncResourceHints: ({ registerHtmlRefine }) => {
897
900
  const hintToInjectMap = new Map();
898
- registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
901
+ registerHtmlRefine((htmlAst, { registerHtmlMutation, htmlUrlInfo }) => {
899
902
  visitHtmlNodes(htmlAst, {
900
903
  link: (node) => {
901
904
  if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
@@ -919,12 +922,39 @@ export const createBuildSpecifierManager = ({
919
922
  return;
920
923
  }
921
924
  const rawUrl = href;
925
+ if (targetsAFileThatDoesNotExist(rawUrl)) {
926
+ // this hint never designated anything, so nothing can explain its
927
+ // removal; taking away markup written by hand on a guess is worse
928
+ // than leaving it there
929
+ logger.warn(
930
+ createDetailedMessage(
931
+ `${UNICODE.WARNING} resource hint kept as is: "${href}" cannot be resolved`,
932
+ {
933
+ "html file": htmlUrlInfo.url,
934
+ ...(rawKitchen.context.hasInjections
935
+ ? {
936
+ suggestion: `when that url is written by an injection, jsenv resolves it in html attributes before analyzing references; check the placeholder spelling`,
937
+ }
938
+ : {}),
939
+ },
940
+ ),
941
+ );
942
+ // the build url means nothing for something jsenv could not resolve,
943
+ // and it would leak a local path into the build
944
+ registerHtmlMutation(() => {
945
+ setHtmlNodeAttributes(node, {
946
+ href: urlToRelativeUrl(rawUrl, htmlUrlInfo.url),
947
+ });
948
+ });
949
+ return;
950
+ }
922
951
  const finalUrl = internalRedirections.get(rawUrl) || rawUrl;
923
952
  const urlInfo = finalKitchen.graph.getUrlInfo(finalUrl);
924
953
  if (!urlInfo) {
925
- if (rel === "preconnect" || rel === "dns-prefetch") {
926
- // preconnect/dns-prefetch hints refer to origins, not specific resources in the graph.
927
- // Keep them as-is the author knows what external domains will be used at runtime.
954
+ if (!href.startsWith("file:")) {
955
+ // the hint designates a resource jsenv does not own: an origin for
956
+ // preconnect/dns-prefetch, a remote file for the others. The author
957
+ // knows what the page will request at runtime, keep it as-is.
928
958
  return;
929
959
  }
930
960
  logger.warn(
@@ -1106,9 +1136,14 @@ export const createBuildSpecifierManager = ({
1106
1136
  let contentKey;
1107
1137
  // if to guard for html where versioned build specifier is not generated
1108
1138
  if (buildSpecifierVersioned) {
1109
- const buildUrlVersioned = asBuildUrlVersioned({
1110
- buildSpecifierVersioned,
1111
- buildDirectoryUrl,
1139
+ // The version goes on the build url, not on the specifier: a
1140
+ // specifier is written for one importer to read (base: "./" makes
1141
+ // it relative to that importer, base: "https://cdn…" makes it
1142
+ // absolute elsewhere) and says nothing about where the file lands.
1143
+ const buildUrlVersioned = injectVersionIntoBuildSpecifier({
1144
+ buildSpecifier: buildUrl,
1145
+ version: versionMap.get(urlInfo),
1146
+ versioningMethod,
1112
1147
  });
1113
1148
  const buildRelativeUrlVersioned = urlToRelativeUrl(
1114
1149
  buildUrlVersioned,
@@ -1352,21 +1387,14 @@ const injectVersionIntoBuildSpecifier = ({
1352
1387
  );
1353
1388
  };
1354
1389
 
1355
- const asBuildUrlVersioned = ({
1356
- buildSpecifierVersioned,
1357
- buildDirectoryUrl,
1358
- }) => {
1359
- if (buildSpecifierVersioned[0] === "/") {
1360
- return new URL(buildSpecifierVersioned.slice(1), buildDirectoryUrl).href;
1361
- }
1362
- const buildUrl = new URL(buildSpecifierVersioned, buildDirectoryUrl).href;
1363
- if (buildUrl.startsWith(buildDirectoryUrl)) {
1364
- return buildUrl;
1390
+ const targetsAFileThatDoesNotExist = (url) => {
1391
+ if (!url.startsWith("file:")) {
1392
+ return false;
1365
1393
  }
1366
- // it's likely "base" parameter was set to an url origin like "https://cdn.example.com"
1367
- // let's move url to build directory
1368
- const { pathname, search, hash } = new URL(buildSpecifierVersioned);
1369
- return `${buildDirectoryUrl}${pathname}${search}${hash}`;
1394
+ const urlObject = new URL(url);
1395
+ urlObject.search = "";
1396
+ urlObject.hash = "";
1397
+ return !existsSync(urlObject);
1370
1398
  };
1371
1399
 
1372
1400
  // export for unit tests
@@ -31,12 +31,41 @@ export const createBuildUrlsGenerator = ({
31
31
  };
32
32
 
33
33
  const nameSetPerDirectoryMap = new Map();
34
- const generate = (url, { urlInfo, ownerUrlInfo, assetsDirectory }) => {
35
- const buildUrlFromMap = buildUrlMap.get(url);
34
+ const reserveName = (directoryPath, urlName) => {
35
+ let nameSet = nameSetPerDirectoryMap.get(directoryPath);
36
+ if (!nameSet) {
37
+ nameSet = new Set();
38
+ nameSetPerDirectoryMap.set(directoryPath, nameSet);
39
+ }
40
+ let [basename, extension] = splitFileExtension(urlName);
41
+ extension = extensionMappings[extension] || extension;
42
+ let nameCandidate = `${basename}${extension}`; // reconstruct name in case extension was normalized
43
+ let integer = 1;
44
+ while (nameSet.has(nameCandidate)) {
45
+ integer++;
46
+ nameCandidate = `${basename}${integer}${extension}`;
47
+ }
48
+ nameSet.add(nameCandidate);
49
+ return nameCandidate;
50
+ };
51
+ const generate = (
52
+ url,
53
+ { urlInfo, ownerUrlInfo, assetsDirectory, entryKey },
54
+ ) => {
55
+ // A url already inside the build directory names a chunk a bundler just
56
+ // produced, and each entry point is bundled on its own: two of them
57
+ // routinely produce a chunk of the same name holding different code (the
58
+ // shared dependencies of THAT entry point, exported under names decided by
59
+ // THAT bundle). They are told apart by the entry point they come from —
60
+ // sharing the file would leave one entry importing exports the file on disk
61
+ // does not have.
62
+ const insideBuildDirectory = urlIsOrIsInsideOf(url, buildDirectoryUrl);
63
+ const key = insideBuildDirectory ? `${entryKey} ${url}` : url;
64
+ const buildUrlFromMap = buildUrlMap.get(key);
36
65
  if (buildUrlFromMap) {
37
66
  return buildUrlFromMap;
38
67
  }
39
- if (urlIsOrIsInsideOf(url, buildDirectoryUrl)) {
68
+ if (insideBuildDirectory) {
40
69
  if (ownerUrlInfo.searchParams.has("dynamic_import_id")) {
41
70
  const ownerDirectoryPath = determineDirectoryPath({
42
71
  sourceDirectoryUrl,
@@ -48,11 +77,18 @@ export const createBuildUrlsGenerator = ({
48
77
  buildUrl = injectQueryParams(buildUrl, {
49
78
  dynamic_import_id: undefined,
50
79
  });
51
- associateBuildUrl(url, buildUrl);
80
+ associateBuildUrl(key, buildUrl);
52
81
  return buildUrl;
53
82
  }
54
- associateBuildUrl(url, url);
55
- return url;
83
+ const urlObject = new URL(url);
84
+ const chunkDirectoryPath = urlToRelativeUrl(
85
+ new URL("./", urlObject),
86
+ buildDirectoryUrl,
87
+ );
88
+ const chunkName = reserveName(chunkDirectoryPath, urlToFilename(url));
89
+ const buildUrl = `${buildDirectoryUrl}${chunkDirectoryPath}${chunkName}${urlObject.search}${urlObject.hash}`;
90
+ associateBuildUrl(key, buildUrl);
91
+ return buildUrl;
56
92
  }
57
93
  if (urlInfo.type === "entry_build") {
58
94
  const buildUrl = new URL(urlInfo.filenameHint, buildDirectoryUrl).href;
@@ -84,25 +120,10 @@ export const createBuildUrlsGenerator = ({
84
120
  urlInfo,
85
121
  ownerUrlInfo,
86
122
  });
87
- let nameSet = nameSetPerDirectoryMap.get(directoryPath);
88
- if (!nameSet) {
89
- nameSet = new Set();
90
- nameSetPerDirectoryMap.set(directoryPath, nameSet);
91
- }
92
123
  const urlObject = new URL(url);
93
124
  injectQueryParams(urlObject, { dynamic_import_id: undefined });
94
125
  let { search, hash } = urlObject;
95
- let urlName = getUrlName(url, urlInfo);
96
- let [basename, extension] = splitFileExtension(urlName);
97
- extension = extensionMappings[extension] || extension;
98
- let nameCandidate = `${basename}${extension}`; // reconstruct name in case extension was normalized
99
- let integer = 1;
100
- while (nameSet.has(nameCandidate)) {
101
- integer++;
102
- nameCandidate = `${basename}${integer}${extension}`;
103
- }
104
- const name = nameCandidate;
105
- nameSet.add(name);
126
+ const name = reserveName(directoryPath, getUrlName(url, urlInfo));
106
127
  const buildUrl = `${buildDirectoryUrl}${directoryPath}${name}${search}${hash}`;
107
128
  associateBuildUrl(url, buildUrl);
108
129
  return buildUrl;
@@ -71,7 +71,16 @@ export const devServerPluginServeSourceFiles = ({
71
71
  }
72
72
  }
73
73
  });
74
- const clientRuntimeCompat = { [runtimeName]: runtimeVersion };
74
+ // A client we cannot identify (curl, fetch, a healthcheck, a proxy dropping
75
+ // the user agent) is assumed to be one of the runtimes the project targets.
76
+ // Treating it as a runtime supporting nothing would serve it the js module
77
+ // fallback, breaking tooling perfectly capable of ES modules; an actual
78
+ // ancient browser hiding its identity fails loudly instead, which is the
79
+ // cheaper of the two mistakes.
80
+ const clientRuntimeCompat =
81
+ runtimeName === "unknown"
82
+ ? runtimeCompat
83
+ : { [runtimeName]: runtimeVersion };
75
84
 
76
85
  kitchen = createKitchen({
77
86
  name: runtimeId,
@@ -0,0 +1,18 @@
1
+ // A bare specifier ("preact", "@jsenv/core/x.js") is resolved by node esm resolution,
2
+ // everything else ("/a.js", "./a.js", "http://example.com/a.js") by url resolution
3
+ export const isBareSpecifier = (specifier) => {
4
+ if (
5
+ specifier[0] === "/" ||
6
+ specifier.startsWith("./") ||
7
+ specifier.startsWith("../")
8
+ ) {
9
+ return false;
10
+ }
11
+ try {
12
+ // eslint-disable-next-line no-new
13
+ new URL(specifier);
14
+ return false;
15
+ } catch {
16
+ return true;
17
+ }
18
+ };
@@ -373,9 +373,10 @@ const getFirstReferenceInProject = (reference) => {
373
373
  return getFirstReferenceInProject(firstReference);
374
374
  };
375
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.
376
+ // Injections write urls in html attributes before references are analyzed, so an url
377
+ // that still cannot be resolved may be a placeholder no injection replaced. Rather than
378
+ // guessing what a placeholder looks like (the key is free-form), tell the file it comes
379
+ // from: injections are configured for it.
379
380
  const detailsFromInjectionsOnOwner = (reference) => {
380
381
  if (!reference) {
381
382
  return {};
@@ -394,7 +395,7 @@ const detailsFromInjectionsOnOwner = (reference) => {
394
395
  return {};
395
396
  }
396
397
  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
+ suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, check the placeholder spelling, or add "jsenv-ignore" so jsenv leaves that url alone:
398
399
  <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
399
400
  };
400
401
  };
@@ -214,6 +214,9 @@ const createUrlInfo = (url, context) => {
214
214
  contentFinalized: false,
215
215
  contentSideEffects: [],
216
216
  contentInjections: {},
217
+ // placeholders already consumed somewhere else than the content (in a specifier),
218
+ // so that not finding them in the content is not worth a warning
219
+ contentInjectionUsedKeySet: new Set(),
217
220
 
218
221
  sourcemap: null,
219
222
  sourcemapIsWrong: false,
@@ -25,6 +25,13 @@ export const INJECTIONS = {
25
25
  },
26
26
  };
27
27
 
28
+ export const readInjectionValue = (injection) => {
29
+ if (injection && injection[injectionSymbol]) {
30
+ return injection.value;
31
+ }
32
+ return injection;
33
+ };
34
+
28
35
  export const isPlaceholderInjection = (value) => {
29
36
  return (
30
37
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -98,7 +105,7 @@ export const injectPlaceholderReplacements = (
98
105
  for (const { key, isOptional, value } of placeholderReplacements) {
99
106
  let index = content.indexOf(key);
100
107
  if (index === -1) {
101
- if (!isOptional) {
108
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
102
109
  urlInfo.context.logger.warn(
103
110
  `placeholder "${key}" not found in ${urlInfo.url}.
104
111
  --- suggestion a ---
@@ -123,7 +130,7 @@ return {
123
130
  magicSource.replace({
124
131
  start,
125
132
  end,
126
- replacement: asReplacement(value, urlInfo),
133
+ replacement: asReplacement(value, urlInfo.type),
127
134
  });
128
135
  index = content.indexOf(key, end);
129
136
  }
@@ -134,8 +141,8 @@ return {
134
141
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
135
142
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
136
143
  // 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") {
144
+ export const asReplacement = (value, type) => {
145
+ if (type === "js_classic" || type === "js_module") {
139
146
  return JSON.stringify(value, null, " ");
140
147
  }
141
148
  if (typeof value === "string") {