@jsenv/core 41.4.5 → 41.4.6

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.
@@ -628,9 +628,10 @@ const getFirstReferenceInProject = (reference) => {
628
628
  return getFirstReferenceInProject(firstReference);
629
629
  };
630
630
 
631
- // An url written by an injection cannot be resolved: the placeholder is still there
632
- // when references are analyzed. Rather than guessing what a placeholder looks like
633
- // (the key is free-form), tell the file it comes from: injections are configured for it.
631
+ // Injections write urls in html attributes before references are analyzed, so an url
632
+ // that still cannot be resolved may be a placeholder no injection replaced. Rather than
633
+ // guessing what a placeholder looks like (the key is free-form), tell the file it comes
634
+ // from: injections are configured for it.
634
635
  const detailsFromInjectionsOnOwner = (reference) => {
635
636
  if (!reference) {
636
637
  return {};
@@ -649,7 +650,7 @@ const detailsFromInjectionsOnOwner = (reference) => {
649
650
  return {};
650
651
  }
651
652
  return {
652
- suggestion: `injections are configured for this file; when "${reference.specifier}" is meant to be written by one of them, add "jsenv-ignore" so jsenv leaves that url alone:
653
+ 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:
653
654
  <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
654
655
  };
655
656
  };
@@ -2101,6 +2102,9 @@ const createUrlInfo = (url, context) => {
2101
2102
  contentFinalized: false,
2102
2103
  contentSideEffects: [],
2103
2104
  contentInjections: {},
2105
+ // placeholders already consumed somewhere else than the content (in a specifier),
2106
+ // so that not finding them in the content is not worth a warning
2107
+ contentInjectionUsedKeySet: new Set(),
2104
2108
 
2105
2109
  sourcemap: null,
2106
2110
  sourcemapIsWrong: false,
@@ -2413,6 +2417,13 @@ const INJECTIONS = {
2413
2417
  },
2414
2418
  };
2415
2419
 
2420
+ const readInjectionValue = (injection) => {
2421
+ if (injection && injection[injectionSymbol]) {
2422
+ return injection.value;
2423
+ }
2424
+ return injection;
2425
+ };
2426
+
2416
2427
  const isPlaceholderInjection = (value) => {
2417
2428
  return (
2418
2429
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -2486,7 +2497,7 @@ const injectPlaceholderReplacements = (
2486
2497
  for (const { key, isOptional, value } of placeholderReplacements) {
2487
2498
  let index = content.indexOf(key);
2488
2499
  if (index === -1) {
2489
- if (!isOptional) {
2500
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
2490
2501
  urlInfo.context.logger.warn(
2491
2502
  `placeholder "${key}" not found in ${urlInfo.url}.
2492
2503
  --- suggestion a ---
@@ -2511,7 +2522,7 @@ return {
2511
2522
  magicSource.replace({
2512
2523
  start,
2513
2524
  end,
2514
- replacement: asReplacement(value, urlInfo),
2525
+ replacement: asReplacement(value, urlInfo.type),
2515
2526
  });
2516
2527
  index = content.indexOf(key, end);
2517
2528
  }
@@ -2522,8 +2533,8 @@ return {
2522
2533
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
2523
2534
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
2524
2535
  // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
2525
- const asReplacement = (value, urlInfo) => {
2526
- if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2536
+ const asReplacement = (value, type) => {
2537
+ if (type === "js_classic" || type === "js_module") {
2527
2538
  return JSON.stringify(value, null, " ");
2528
2539
  }
2529
2540
  if (typeof value === "string") {
@@ -4675,6 +4686,25 @@ const testAppliesDuring = (plugin, kitchen) => {
4675
4686
  );
4676
4687
  };
4677
4688
 
4689
+ // A bare specifier ("preact", "@jsenv/core/x.js") is resolved by node esm resolution,
4690
+ // everything else ("/a.js", "./a.js", "http://example.com/a.js") by url resolution
4691
+ const isBareSpecifier = (specifier) => {
4692
+ if (
4693
+ specifier[0] === "/" ||
4694
+ specifier.startsWith("./") ||
4695
+ specifier.startsWith("../")
4696
+ ) {
4697
+ return false;
4698
+ }
4699
+ try {
4700
+ // eslint-disable-next-line no-new
4701
+ new URL(specifier);
4702
+ return false;
4703
+ } catch {
4704
+ return true;
4705
+ }
4706
+ };
4707
+
4678
4708
  /*
4679
4709
  * https://github.com/parcel-bundler/parcel/blob/v2/packages/transformers/css/src/CSSTransformer.js
4680
4710
  */
@@ -5616,7 +5646,7 @@ const parseAndTransformJsReferences = async (
5616
5646
  let filenameHint;
5617
5647
  if (
5618
5648
  externalReferenceInfo.subtype === "import_dynamic" &&
5619
- isBareSpecifier$2(externalReferenceInfo.specifier)
5649
+ isBareSpecifier(externalReferenceInfo.specifier)
5620
5650
  ) {
5621
5651
  filenameHint = `${externalReferenceInfo.specifier}.js`;
5622
5652
  }
@@ -5706,23 +5736,6 @@ const parseAndTransformJsReferences = async (
5706
5736
  return { content, sourcemap };
5707
5737
  };
5708
5738
 
5709
- const isBareSpecifier$2 = (specifier) => {
5710
- if (
5711
- specifier[0] === "/" ||
5712
- specifier.startsWith("./") ||
5713
- specifier.startsWith("../")
5714
- ) {
5715
- return false;
5716
- }
5717
- try {
5718
- // eslint-disable-next-line no-new
5719
- new URL(specifier);
5720
- return false;
5721
- } catch {
5722
- return true;
5723
- }
5724
- };
5725
-
5726
5739
  const jsenvPluginReferenceExpectedTypes = () => {
5727
5740
  const redirectJsReference = (reference) => {
5728
5741
  const urlObject = new URL(reference.url);
@@ -6113,7 +6126,7 @@ const createBuildPackageConditions = (
6113
6126
  for (const key of keys) {
6114
6127
  const associatedValue = packageConditionsConfig[key];
6115
6128
 
6116
- if (!isBareSpecifier$1(key)) {
6129
+ if (!isBareSpecifier(key)) {
6117
6130
  const url = new URL(key, rootDirectoryUrl);
6118
6131
  associationsRaw[url] = associatedValue;
6119
6132
  continue;
@@ -6148,7 +6161,7 @@ const createBuildPackageConditions = (
6148
6161
  );
6149
6162
  resolveConditionsFromSpecifier = (specifier, importer, { resolver }) => {
6150
6163
  let associatedValue;
6151
- if (isBareSpecifier$1(specifier)) {
6164
+ if (isBareSpecifier(specifier)) {
6152
6165
  const { url } = resolver({
6153
6166
  specifier,
6154
6167
  parentUrl: importer,
@@ -6173,7 +6186,7 @@ const createBuildPackageConditions = (
6173
6186
  const nodeRuntimeEnabled = Object.keys(runtimeCompat).includes("node");
6174
6187
  // https://nodejs.org/api/esm.html#resolver-algorithm-specification
6175
6188
  const devResolver = (specifier, importer, { resolver }) => {
6176
- if (isBareSpecifier$1(specifier)) {
6189
+ if (isBareSpecifier(specifier)) {
6177
6190
  const { url } = resolver({
6178
6191
  specifier,
6179
6192
  parentUrl: importer,
@@ -6237,7 +6250,7 @@ const createBuildPackageConditions = (
6237
6250
  const associations = URL_META.resolveAssociations(
6238
6251
  { applies: value },
6239
6252
  (pattern) => {
6240
- if (isBareSpecifier$1(pattern)) {
6253
+ if (isBareSpecifier(pattern)) {
6241
6254
  try {
6242
6255
  if (pattern.endsWith("/")) {
6243
6256
  // avoid package path not exported
@@ -6260,7 +6273,7 @@ const createBuildPackageConditions = (
6260
6273
  },
6261
6274
  );
6262
6275
  customResolver = (specifier, importer, { resolver }) => {
6263
- if (isBareSpecifier$1(specifier)) {
6276
+ if (isBareSpecifier(specifier)) {
6264
6277
  const { url } = resolver({
6265
6278
  specifier,
6266
6279
  parentUrl: importer,
@@ -6392,23 +6405,6 @@ const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
6392
6405
  };
6393
6406
  };
6394
6407
 
6395
- const isBareSpecifier$1 = (specifier) => {
6396
- if (
6397
- specifier[0] === "/" ||
6398
- specifier.startsWith("./") ||
6399
- specifier.startsWith("../")
6400
- ) {
6401
- return false;
6402
- }
6403
- try {
6404
- // eslint-disable-next-line no-new
6405
- new URL(specifier);
6406
- return false;
6407
- } catch {
6408
- return true;
6409
- }
6410
- };
6411
-
6412
6408
  const jsenvPluginNodeEsmResolution = ({
6413
6409
  packageDirectory,
6414
6410
  resolutionConfig = {},
@@ -7676,6 +7672,7 @@ const jsenvPluginInjections = (rawAssociations) => {
7676
7672
  }
7677
7673
  return null;
7678
7674
  };
7675
+ const injectionsForUrlInfoMap = new WeakMap();
7679
7676
  let getInjections = null;
7680
7677
 
7681
7678
  return {
@@ -7694,8 +7691,8 @@ const jsenvPluginInjections = (rawAssociations) => {
7694
7691
  });
7695
7692
  return injectionsGetter;
7696
7693
  };
7697
- // an url written by an injection cannot be resolved during reference analysis;
7698
- // errors use this to tell the file holds injections and suggest "jsenv-ignore"
7694
+ // errors and the build read this to know an unresolved url may come from
7695
+ // an injection, and word what they report accordingly
7699
7696
  context.hasInjections = (url) => {
7700
7697
  return Boolean(findInjectionsGetterForUrl(url));
7701
7698
  };
@@ -7749,16 +7746,61 @@ const jsenvPluginInjections = (rawAssociations) => {
7749
7746
  contentInjections: defaultInjections,
7750
7747
  };
7751
7748
  }
7749
+ injectionsForUrlInfoMap.set(urlInfo, injections);
7752
7750
  return {
7753
- contentInjections: {
7754
- ...defaultInjections,
7755
- ...injections,
7756
- },
7751
+ contentInjections: { ...defaultInjections, ...injections },
7757
7752
  };
7758
7753
  },
7754
+ // The content still holds the placeholders when references are analyzed: they are
7755
+ // replaced at the very end, once the type of every inline content is known.
7756
+ // A specifier must not wait for that, it would resolve "__BACKEND_URL__/users/me"
7757
+ // as a file sitting next to the document. Only the placeholder is resolved here,
7758
+ // then the specifier goes to whoever resolves this kind of url (node esm, web, ...)
7759
+ resolveReference: (reference) => {
7760
+ const { ownerUrlInfo } = reference;
7761
+ const injections = injectionsForUrlInfoMap.get(ownerUrlInfo);
7762
+ if (!injections) {
7763
+ return null;
7764
+ }
7765
+ const { specifier, keySet } = injectIntoSpecifier(
7766
+ reference.specifier,
7767
+ injections,
7768
+ );
7769
+ if (keySet.size === 0) {
7770
+ return null;
7771
+ }
7772
+ reference.specifier = specifier;
7773
+ for (const key of keySet) {
7774
+ // rewriting the specifier takes the placeholder out of the content,
7775
+ // so the injection step must not expect to find it there
7776
+ ownerUrlInfo.contentInjectionUsedKeySet.add(key);
7777
+ }
7778
+ return null;
7779
+ },
7759
7780
  };
7760
7781
  };
7761
7782
 
7783
+ const injectIntoSpecifier = (specifier, injections) => {
7784
+ let specifierInjected = specifier;
7785
+ const keySet = new Set();
7786
+ for (const key of Object.keys(injections)) {
7787
+ if (!specifierInjected.includes(key)) {
7788
+ continue;
7789
+ }
7790
+ const injection = injections[key];
7791
+ if (!isPlaceholderInjection(injection)) {
7792
+ continue;
7793
+ }
7794
+ const value = readInjectionValue(injection);
7795
+ if (typeof value !== "string") {
7796
+ continue;
7797
+ }
7798
+ specifierInjected = specifierInjected.replaceAll(key, value);
7799
+ keySet.add(key);
7800
+ }
7801
+ return { specifier: specifierInjected, keySet };
7802
+ };
7803
+
7762
7804
  // What a file inlines (a <script> or a <style> inside html) is authored in that file
7763
7805
  // and inherits its injections, with two adjustments:
7764
7806
  // - a global belongs to the file itself, injecting it into each inline content would
@@ -9833,8 +9875,10 @@ const getCorePlugins = ({
9833
9875
  ...(packageBundle
9834
9876
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
9835
9877
  : []),
9836
- jsenvPluginReferenceAnalysis(referenceAnalysis),
9878
+ // before reference analysis: an url written by an injection must hold its
9879
+ // final value when references are analyzed
9837
9880
  jsenvPluginInjections(injections),
9881
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
9838
9882
  jsenvPluginTranspilation(transpilation),
9839
9883
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
9840
9884
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -11177,7 +11221,7 @@ const createBuildSpecifierManager = ({
11177
11221
 
11178
11222
  prepareResyncResourceHints: ({ registerHtmlRefine }) => {
11179
11223
  const hintToInjectMap = new Map();
11180
- registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
11224
+ registerHtmlRefine((htmlAst, { registerHtmlMutation, htmlUrlInfo }) => {
11181
11225
  visitHtmlNodes(htmlAst, {
11182
11226
  link: (node) => {
11183
11227
  if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
@@ -11201,12 +11245,39 @@ const createBuildSpecifierManager = ({
11201
11245
  return;
11202
11246
  }
11203
11247
  const rawUrl = href;
11248
+ if (targetsAFileThatDoesNotExist(rawUrl)) {
11249
+ // this hint never designated anything, so nothing can explain its
11250
+ // removal; taking away markup written by hand on a guess is worse
11251
+ // than leaving it there
11252
+ logger.warn(
11253
+ createDetailedMessage(
11254
+ `${UNICODE.WARNING} resource hint kept as is: "${href}" cannot be resolved`,
11255
+ {
11256
+ "html file": htmlUrlInfo.url,
11257
+ ...(rawKitchen.context.hasInjections
11258
+ ? {
11259
+ suggestion: `when that url is written by an injection, jsenv resolves it in html attributes before analyzing references; check the placeholder spelling`,
11260
+ }
11261
+ : {}),
11262
+ },
11263
+ ),
11264
+ );
11265
+ // the build url means nothing for something jsenv could not resolve,
11266
+ // and it would leak a local path into the build
11267
+ registerHtmlMutation(() => {
11268
+ setHtmlNodeAttributes(node, {
11269
+ href: urlToRelativeUrl(rawUrl, htmlUrlInfo.url),
11270
+ });
11271
+ });
11272
+ return;
11273
+ }
11204
11274
  const finalUrl = internalRedirections.get(rawUrl) || rawUrl;
11205
11275
  const urlInfo = finalKitchen.graph.getUrlInfo(finalUrl);
11206
11276
  if (!urlInfo) {
11207
- if (rel === "preconnect" || rel === "dns-prefetch") {
11208
- // preconnect/dns-prefetch hints refer to origins, not specific resources in the graph.
11209
- // Keep them as-is the author knows what external domains will be used at runtime.
11277
+ if (!href.startsWith("file:")) {
11278
+ // the hint designates a resource jsenv does not own: an origin for
11279
+ // preconnect/dns-prefetch, a remote file for the others. The author
11280
+ // knows what the page will request at runtime, keep it as-is.
11210
11281
  return;
11211
11282
  }
11212
11283
  logger.warn(
@@ -11651,6 +11722,16 @@ const asBuildUrlVersioned = ({
11651
11722
  return `${buildDirectoryUrl}${pathname}${search}${hash}`;
11652
11723
  };
11653
11724
 
11725
+ const targetsAFileThatDoesNotExist = (url) => {
11726
+ if (!url.startsWith("file:")) {
11727
+ return false;
11728
+ }
11729
+ const urlObject = new URL(url);
11730
+ urlObject.search = "";
11731
+ urlObject.hash = "";
11732
+ return !existsSync(urlObject);
11733
+ };
11734
+
11654
11735
  // import { ANSI } from "@jsenv/humanize";
11655
11736
 
11656
11737
  const createBuildUrlsGenerator = ({
@@ -13374,7 +13455,10 @@ const prepareEntryPointBuild = async (
13374
13455
  const registerHtmlMutation = (callback) => {
13375
13456
  htmlMutationCallbackSet.add(callback);
13376
13457
  };
13377
- htmlRefine(htmlAst, { registerHtmlMutation });
13458
+ htmlRefine(htmlAst, {
13459
+ registerHtmlMutation,
13460
+ htmlUrlInfo: urlInfo,
13461
+ });
13378
13462
  for (const htmlMutationCallback of htmlMutationCallbackSet) {
13379
13463
  htmlMutationCallback();
13380
13464
  }
@@ -13459,21 +13543,4 @@ const prepareEntryPointBuild = async (
13459
13543
  };
13460
13544
  };
13461
13545
 
13462
- const isBareSpecifier = (specifier) => {
13463
- if (
13464
- specifier[0] === "/" ||
13465
- specifier.startsWith("./") ||
13466
- specifier.startsWith("../")
13467
- ) {
13468
- return false;
13469
- }
13470
- try {
13471
- // eslint-disable-next-line no-new
13472
- new URL(specifier);
13473
- return false;
13474
- } catch {
13475
- return true;
13476
- }
13477
- };
13478
-
13479
13546
  export { build };
@@ -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 = {},
@@ -4858,9 +4843,10 @@ const getFirstReferenceInProject = (reference) => {
4858
4843
  return getFirstReferenceInProject(firstReference);
4859
4844
  };
4860
4845
 
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.
4846
+ // Injections write urls in html attributes before references are analyzed, so an url
4847
+ // that still cannot be resolved may be a placeholder no injection replaced. Rather than
4848
+ // guessing what a placeholder looks like (the key is free-form), tell the file it comes
4849
+ // from: injections are configured for it.
4864
4850
  const detailsFromInjectionsOnOwner = (reference) => {
4865
4851
  if (!reference) {
4866
4852
  return {};
@@ -4879,7 +4865,7 @@ const detailsFromInjectionsOnOwner = (reference) => {
4879
4865
  return {};
4880
4866
  }
4881
4867
  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:
4868
+ 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
4869
  <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
4884
4870
  };
4885
4871
  };
@@ -5047,6 +5033,13 @@ const INJECTIONS = {
5047
5033
  },
5048
5034
  };
5049
5035
 
5036
+ const readInjectionValue = (injection) => {
5037
+ if (injection && injection[injectionSymbol]) {
5038
+ return injection.value;
5039
+ }
5040
+ return injection;
5041
+ };
5042
+
5050
5043
  const isPlaceholderInjection = (value) => {
5051
5044
  return (
5052
5045
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -5120,7 +5113,7 @@ const injectPlaceholderReplacements = (
5120
5113
  for (const { key, isOptional, value } of placeholderReplacements) {
5121
5114
  let index = content.indexOf(key);
5122
5115
  if (index === -1) {
5123
- if (!isOptional) {
5116
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
5124
5117
  urlInfo.context.logger.warn(
5125
5118
  `placeholder "${key}" not found in ${urlInfo.url}.
5126
5119
  --- suggestion a ---
@@ -5145,7 +5138,7 @@ return {
5145
5138
  magicSource.replace({
5146
5139
  start,
5147
5140
  end,
5148
- replacement: asReplacement(value, urlInfo),
5141
+ replacement: asReplacement(value, urlInfo.type),
5149
5142
  });
5150
5143
  index = content.indexOf(key, end);
5151
5144
  }
@@ -5156,8 +5149,8 @@ return {
5156
5149
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
5157
5150
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
5158
5151
  // 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") {
5152
+ const asReplacement = (value, type) => {
5153
+ if (type === "js_classic" || type === "js_module") {
5161
5154
  return JSON.stringify(value, null, " ");
5162
5155
  }
5163
5156
  if (typeof value === "string") {
@@ -5235,6 +5228,7 @@ const jsenvPluginInjections = (rawAssociations) => {
5235
5228
  }
5236
5229
  return null;
5237
5230
  };
5231
+ const injectionsForUrlInfoMap = new WeakMap();
5238
5232
  let getInjections = null;
5239
5233
 
5240
5234
  return {
@@ -5253,8 +5247,8 @@ const jsenvPluginInjections = (rawAssociations) => {
5253
5247
  });
5254
5248
  return injectionsGetter;
5255
5249
  };
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"
5250
+ // errors and the build read this to know an unresolved url may come from
5251
+ // an injection, and word what they report accordingly
5258
5252
  context.hasInjections = (url) => {
5259
5253
  return Boolean(findInjectionsGetterForUrl(url));
5260
5254
  };
@@ -5308,16 +5302,61 @@ const jsenvPluginInjections = (rawAssociations) => {
5308
5302
  contentInjections: defaultInjections,
5309
5303
  };
5310
5304
  }
5305
+ injectionsForUrlInfoMap.set(urlInfo, injections);
5311
5306
  return {
5312
- contentInjections: {
5313
- ...defaultInjections,
5314
- ...injections,
5315
- },
5307
+ contentInjections: { ...defaultInjections, ...injections },
5316
5308
  };
5317
5309
  },
5310
+ // The content still holds the placeholders when references are analyzed: they are
5311
+ // replaced at the very end, once the type of every inline content is known.
5312
+ // A specifier must not wait for that, it would resolve "__BACKEND_URL__/users/me"
5313
+ // as a file sitting next to the document. Only the placeholder is resolved here,
5314
+ // then the specifier goes to whoever resolves this kind of url (node esm, web, ...)
5315
+ resolveReference: (reference) => {
5316
+ const { ownerUrlInfo } = reference;
5317
+ const injections = injectionsForUrlInfoMap.get(ownerUrlInfo);
5318
+ if (!injections) {
5319
+ return null;
5320
+ }
5321
+ const { specifier, keySet } = injectIntoSpecifier(
5322
+ reference.specifier,
5323
+ injections,
5324
+ );
5325
+ if (keySet.size === 0) {
5326
+ return null;
5327
+ }
5328
+ reference.specifier = specifier;
5329
+ for (const key of keySet) {
5330
+ // rewriting the specifier takes the placeholder out of the content,
5331
+ // so the injection step must not expect to find it there
5332
+ ownerUrlInfo.contentInjectionUsedKeySet.add(key);
5333
+ }
5334
+ return null;
5335
+ },
5318
5336
  };
5319
5337
  };
5320
5338
 
5339
+ const injectIntoSpecifier = (specifier, injections) => {
5340
+ let specifierInjected = specifier;
5341
+ const keySet = new Set();
5342
+ for (const key of Object.keys(injections)) {
5343
+ if (!specifierInjected.includes(key)) {
5344
+ continue;
5345
+ }
5346
+ const injection = injections[key];
5347
+ if (!isPlaceholderInjection(injection)) {
5348
+ continue;
5349
+ }
5350
+ const value = readInjectionValue(injection);
5351
+ if (typeof value !== "string") {
5352
+ continue;
5353
+ }
5354
+ specifierInjected = specifierInjected.replaceAll(key, value);
5355
+ keySet.add(key);
5356
+ }
5357
+ return { specifier: specifierInjected, keySet };
5358
+ };
5359
+
5321
5360
  // What a file inlines (a <script> or a <style> inside html) is authored in that file
5322
5361
  // and inherits its injections, with two adjustments:
5323
5362
  // - a global belongs to the file itself, injecting it into each inline content would
@@ -7645,8 +7684,10 @@ const getCorePlugins = ({
7645
7684
  ...(packageBundle
7646
7685
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
7647
7686
  : []),
7648
- jsenvPluginReferenceAnalysis(referenceAnalysis),
7687
+ // before reference analysis: an url written by an injection must hold its
7688
+ // final value when references are analyzed
7649
7689
  jsenvPluginInjections(injections),
7690
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
7650
7691
  jsenvPluginTranspilation(transpilation),
7651
7692
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
7652
7693
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -9309,6 +9350,9 @@ const createUrlInfo = (url, context) => {
9309
9350
  contentFinalized: false,
9310
9351
  contentSideEffects: [],
9311
9352
  contentInjections: {},
9353
+ // placeholders already consumed somewhere else than the content (in a specifier),
9354
+ // so that not finding them in the content is not worth a warning
9355
+ contentInjectionUsedKeySet: new Set(),
9312
9356
 
9313
9357
  sourcemap: null,
9314
9358
  sourcemapIsWrong: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.5",
3
+ "version": "41.4.6",
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";
@@ -1488,7 +1489,10 @@ const prepareEntryPointBuild = async (
1488
1489
  const registerHtmlMutation = (callback) => {
1489
1490
  htmlMutationCallbackSet.add(callback);
1490
1491
  };
1491
- htmlRefine(htmlAst, { registerHtmlMutation });
1492
+ htmlRefine(htmlAst, {
1493
+ registerHtmlMutation,
1494
+ htmlUrlInfo: urlInfo,
1495
+ });
1492
1496
  for (const htmlMutationCallback of htmlMutationCallbackSet) {
1493
1497
  htmlMutationCallback();
1494
1498
  }
@@ -1572,20 +1576,3 @@ const prepareEntryPointBuild = async (
1572
1576
  },
1573
1577
  };
1574
1578
  };
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";
@@ -895,7 +896,7 @@ export const createBuildSpecifierManager = ({
895
896
 
896
897
  prepareResyncResourceHints: ({ registerHtmlRefine }) => {
897
898
  const hintToInjectMap = new Map();
898
- registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
899
+ registerHtmlRefine((htmlAst, { registerHtmlMutation, htmlUrlInfo }) => {
899
900
  visitHtmlNodes(htmlAst, {
900
901
  link: (node) => {
901
902
  if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
@@ -919,12 +920,39 @@ export const createBuildSpecifierManager = ({
919
920
  return;
920
921
  }
921
922
  const rawUrl = href;
923
+ if (targetsAFileThatDoesNotExist(rawUrl)) {
924
+ // this hint never designated anything, so nothing can explain its
925
+ // removal; taking away markup written by hand on a guess is worse
926
+ // than leaving it there
927
+ logger.warn(
928
+ createDetailedMessage(
929
+ `${UNICODE.WARNING} resource hint kept as is: "${href}" cannot be resolved`,
930
+ {
931
+ "html file": htmlUrlInfo.url,
932
+ ...(rawKitchen.context.hasInjections
933
+ ? {
934
+ suggestion: `when that url is written by an injection, jsenv resolves it in html attributes before analyzing references; check the placeholder spelling`,
935
+ }
936
+ : {}),
937
+ },
938
+ ),
939
+ );
940
+ // the build url means nothing for something jsenv could not resolve,
941
+ // and it would leak a local path into the build
942
+ registerHtmlMutation(() => {
943
+ setHtmlNodeAttributes(node, {
944
+ href: urlToRelativeUrl(rawUrl, htmlUrlInfo.url),
945
+ });
946
+ });
947
+ return;
948
+ }
922
949
  const finalUrl = internalRedirections.get(rawUrl) || rawUrl;
923
950
  const urlInfo = finalKitchen.graph.getUrlInfo(finalUrl);
924
951
  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.
952
+ if (!href.startsWith("file:")) {
953
+ // the hint designates a resource jsenv does not own: an origin for
954
+ // preconnect/dns-prefetch, a remote file for the others. The author
955
+ // knows what the page will request at runtime, keep it as-is.
928
956
  return;
929
957
  }
930
958
  logger.warn(
@@ -1369,5 +1397,15 @@ const asBuildUrlVersioned = ({
1369
1397
  return `${buildDirectoryUrl}${pathname}${search}${hash}`;
1370
1398
  };
1371
1399
 
1400
+ const targetsAFileThatDoesNotExist = (url) => {
1401
+ if (!url.startsWith("file:")) {
1402
+ return false;
1403
+ }
1404
+ const urlObject = new URL(url);
1405
+ urlObject.search = "";
1406
+ urlObject.hash = "";
1407
+ return !existsSync(urlObject);
1408
+ };
1409
+
1372
1410
  // export for unit tests
1373
1411
  export { generateVersion };
@@ -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") {
@@ -3,6 +3,7 @@ import { asUrlWithoutSearch, urlToRelativeUrl } from "@jsenv/urls";
3
3
  import {
4
4
  INJECTIONS,
5
5
  isPlaceholderInjection,
6
+ readInjectionValue,
6
7
  } from "../../kitchen/url_graph/url_info_injections.js";
7
8
 
8
9
  export const jsenvPluginInjections = (rawAssociations) => {
@@ -18,6 +19,7 @@ export const jsenvPluginInjections = (rawAssociations) => {
18
19
  }
19
20
  return null;
20
21
  };
22
+ const injectionsForUrlInfoMap = new WeakMap();
21
23
  let getInjections = null;
22
24
 
23
25
  return {
@@ -36,8 +38,8 @@ export const jsenvPluginInjections = (rawAssociations) => {
36
38
  });
37
39
  return injectionsGetter;
38
40
  };
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
+ // errors and the build read this to know an unresolved url may come from
42
+ // an injection, and word what they report accordingly
41
43
  context.hasInjections = (url) => {
42
44
  return Boolean(findInjectionsGetterForUrl(url));
43
45
  };
@@ -91,16 +93,61 @@ export const jsenvPluginInjections = (rawAssociations) => {
91
93
  contentInjections: defaultInjections,
92
94
  };
93
95
  }
96
+ injectionsForUrlInfoMap.set(urlInfo, injections);
94
97
  return {
95
- contentInjections: {
96
- ...defaultInjections,
97
- ...injections,
98
- },
98
+ contentInjections: { ...defaultInjections, ...injections },
99
99
  };
100
100
  },
101
+ // The content still holds the placeholders when references are analyzed: they are
102
+ // replaced at the very end, once the type of every inline content is known.
103
+ // A specifier must not wait for that, it would resolve "__BACKEND_URL__/users/me"
104
+ // as a file sitting next to the document. Only the placeholder is resolved here,
105
+ // then the specifier goes to whoever resolves this kind of url (node esm, web, ...)
106
+ resolveReference: (reference) => {
107
+ const { ownerUrlInfo } = reference;
108
+ const injections = injectionsForUrlInfoMap.get(ownerUrlInfo);
109
+ if (!injections) {
110
+ return null;
111
+ }
112
+ const { specifier, keySet } = injectIntoSpecifier(
113
+ reference.specifier,
114
+ injections,
115
+ );
116
+ if (keySet.size === 0) {
117
+ return null;
118
+ }
119
+ reference.specifier = specifier;
120
+ for (const key of keySet) {
121
+ // rewriting the specifier takes the placeholder out of the content,
122
+ // so the injection step must not expect to find it there
123
+ ownerUrlInfo.contentInjectionUsedKeySet.add(key);
124
+ }
125
+ return null;
126
+ },
101
127
  };
102
128
  };
103
129
 
130
+ const injectIntoSpecifier = (specifier, injections) => {
131
+ let specifierInjected = specifier;
132
+ const keySet = new Set();
133
+ for (const key of Object.keys(injections)) {
134
+ if (!specifierInjected.includes(key)) {
135
+ continue;
136
+ }
137
+ const injection = injections[key];
138
+ if (!isPlaceholderInjection(injection)) {
139
+ continue;
140
+ }
141
+ const value = readInjectionValue(injection);
142
+ if (typeof value !== "string") {
143
+ continue;
144
+ }
145
+ specifierInjected = specifierInjected.replaceAll(key, value);
146
+ keySet.add(key);
147
+ }
148
+ return { specifier: specifierInjected, keySet };
149
+ };
150
+
104
151
  // What a file inlines (a <script> or a <style> inside html) is authored in that file
105
152
  // and inherits its injections, with two adjustments:
106
153
  // - a global belongs to the file itself, injecting it into each inline content would
@@ -87,8 +87,10 @@ export const getCorePlugins = ({
87
87
  ...(packageBundle
88
88
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
89
89
  : []),
90
- jsenvPluginReferenceAnalysis(referenceAnalysis),
90
+ // before reference analysis: an url written by an injection must hold its
91
+ // final value when references are analyzed
91
92
  jsenvPluginInjections(injections),
93
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
92
94
  jsenvPluginTranspilation(transpilation),
93
95
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
94
96
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -6,6 +6,7 @@ import {
6
6
  import { createMagicSource } from "@jsenv/sourcemap";
7
7
  import { urlToExtension } from "@jsenv/urls";
8
8
  import { JS_QUOTES } from "@jsenv/utils/src/string/js_quotes.js";
9
+ import { isBareSpecifier } from "../../../helpers/bare_specifier.js";
9
10
 
10
11
  export const jsenvPluginJsReferenceAnalysis = ({ inlineContent }) => {
11
12
  return [
@@ -192,20 +193,3 @@ const parseAndTransformJsReferences = async (
192
193
  const { content, sourcemap } = magicSource.toContentAndSourcemap();
193
194
  return { content, sourcemap };
194
195
  };
195
-
196
- const isBareSpecifier = (specifier) => {
197
- if (
198
- specifier[0] === "/" ||
199
- specifier.startsWith("./") ||
200
- specifier.startsWith("../")
201
- ) {
202
- return false;
203
- }
204
- try {
205
- // eslint-disable-next-line no-new
206
- new URL(specifier);
207
- return false;
208
- } catch {
209
- return true;
210
- }
211
- };
@@ -14,6 +14,7 @@ import {
14
14
  import { URL_META } from "@jsenv/url-meta";
15
15
  import { urlToBasename, urlToExtension } from "@jsenv/urls";
16
16
  import { readFileSync } from "node:fs";
17
+ import { isBareSpecifier } from "../../helpers/bare_specifier.js";
17
18
 
18
19
  export const createNodeEsmResolver = ({
19
20
  packageDirectory,
@@ -487,20 +488,3 @@ const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
487
488
  }
488
489
  };
489
490
  };
490
-
491
- const isBareSpecifier = (specifier) => {
492
- if (
493
- specifier[0] === "/" ||
494
- specifier.startsWith("./") ||
495
- specifier.startsWith("../")
496
- ) {
497
- return false;
498
- }
499
- try {
500
- // eslint-disable-next-line no-new
501
- new URL(specifier);
502
- return false;
503
- } catch {
504
- return true;
505
- }
506
- };