@jsenv/core 41.4.4 → 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.
@@ -333,6 +333,7 @@ ${reason}`,
333
333
  }
334
334
  return createFailedToResolveUrlError({
335
335
  reason: `An error occured during specifier resolution`,
336
+ ...detailsFromInjectionsOnOwner(reference),
336
337
  ...detailsFromValueThrown(error),
337
338
  });
338
339
  };
@@ -394,6 +395,7 @@ ${reason}`,
394
395
  return createFailedToFetchUrlContentError({
395
396
  code: "NOT_FOUND",
396
397
  reason: "no entry on filesystem",
398
+ ...detailsFromInjectionsOnOwner(urlInfo.firstReference),
397
399
  });
398
400
  }
399
401
  }
@@ -626,6 +628,33 @@ const getFirstReferenceInProject = (reference) => {
626
628
  return getFirstReferenceInProject(firstReference);
627
629
  };
628
630
 
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.
635
+ const detailsFromInjectionsOnOwner = (reference) => {
636
+ if (!reference) {
637
+ return {};
638
+ }
639
+ const ownerUrlInfo = reference.ownerUrlInfo;
640
+ if (ownerUrlInfo.type !== "html") {
641
+ // "jsenv-ignore" is an html attribute
642
+ return {};
643
+ }
644
+ const { hasInjections } = ownerUrlInfo.context;
645
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
646
+ return {};
647
+ }
648
+ const { node, attributeName } = reference.astInfo || {};
649
+ if (!node || !attributeName) {
650
+ return {};
651
+ }
652
+ return {
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:
654
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
655
+ };
656
+ };
657
+
629
658
  const detailsFromPluginController = (jsenvPluginsController) => {
630
659
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
631
660
  if (!currentPlugin) {
@@ -2073,6 +2102,9 @@ const createUrlInfo = (url, context) => {
2073
2102
  contentFinalized: false,
2074
2103
  contentSideEffects: [],
2075
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(),
2076
2108
 
2077
2109
  sourcemap: null,
2078
2110
  sourcemapIsWrong: false,
@@ -2385,6 +2417,13 @@ const INJECTIONS = {
2385
2417
  },
2386
2418
  };
2387
2419
 
2420
+ const readInjectionValue = (injection) => {
2421
+ if (injection && injection[injectionSymbol]) {
2422
+ return injection.value;
2423
+ }
2424
+ return injection;
2425
+ };
2426
+
2388
2427
  const isPlaceholderInjection = (value) => {
2389
2428
  return (
2390
2429
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -2458,7 +2497,7 @@ const injectPlaceholderReplacements = (
2458
2497
  for (const { key, isOptional, value } of placeholderReplacements) {
2459
2498
  let index = content.indexOf(key);
2460
2499
  if (index === -1) {
2461
- if (!isOptional) {
2500
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
2462
2501
  urlInfo.context.logger.warn(
2463
2502
  `placeholder "${key}" not found in ${urlInfo.url}.
2464
2503
  --- suggestion a ---
@@ -2483,7 +2522,7 @@ return {
2483
2522
  magicSource.replace({
2484
2523
  start,
2485
2524
  end,
2486
- replacement: asReplacement(value, urlInfo),
2525
+ replacement: asReplacement(value, urlInfo.type),
2487
2526
  });
2488
2527
  index = content.indexOf(key, end);
2489
2528
  }
@@ -2494,8 +2533,8 @@ return {
2494
2533
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
2495
2534
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
2496
2535
  // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
2497
- const asReplacement = (value, urlInfo) => {
2498
- if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2536
+ const asReplacement = (value, type) => {
2537
+ if (type === "js_classic" || type === "js_module") {
2499
2538
  return JSON.stringify(value, null, " ");
2500
2539
  }
2501
2540
  if (typeof value === "string") {
@@ -2511,7 +2550,14 @@ const injectGlobals = (content, globals, urlInfo) => {
2511
2550
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
2512
2551
  return globalsInjectorOnJs(content, globals, urlInfo);
2513
2552
  }
2514
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
2553
+ throw new Error(
2554
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
2555
+ file: urlInfo.url,
2556
+ ...(urlInfo.isInline
2557
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
2558
+ : {}),
2559
+ }),
2560
+ );
2515
2561
  };
2516
2562
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
2517
2563
  // ideally we would inject an importmap but browser support is too low
@@ -4640,6 +4686,25 @@ const testAppliesDuring = (plugin, kitchen) => {
4640
4686
  );
4641
4687
  };
4642
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
+
4643
4708
  /*
4644
4709
  * https://github.com/parcel-bundler/parcel/blob/v2/packages/transformers/css/src/CSSTransformer.js
4645
4710
  */
@@ -5581,7 +5646,7 @@ const parseAndTransformJsReferences = async (
5581
5646
  let filenameHint;
5582
5647
  if (
5583
5648
  externalReferenceInfo.subtype === "import_dynamic" &&
5584
- isBareSpecifier$2(externalReferenceInfo.specifier)
5649
+ isBareSpecifier(externalReferenceInfo.specifier)
5585
5650
  ) {
5586
5651
  filenameHint = `${externalReferenceInfo.specifier}.js`;
5587
5652
  }
@@ -5671,23 +5736,6 @@ const parseAndTransformJsReferences = async (
5671
5736
  return { content, sourcemap };
5672
5737
  };
5673
5738
 
5674
- const isBareSpecifier$2 = (specifier) => {
5675
- if (
5676
- specifier[0] === "/" ||
5677
- specifier.startsWith("./") ||
5678
- specifier.startsWith("../")
5679
- ) {
5680
- return false;
5681
- }
5682
- try {
5683
- // eslint-disable-next-line no-new
5684
- new URL(specifier);
5685
- return false;
5686
- } catch {
5687
- return true;
5688
- }
5689
- };
5690
-
5691
5739
  const jsenvPluginReferenceExpectedTypes = () => {
5692
5740
  const redirectJsReference = (reference) => {
5693
5741
  const urlObject = new URL(reference.url);
@@ -6078,7 +6126,7 @@ const createBuildPackageConditions = (
6078
6126
  for (const key of keys) {
6079
6127
  const associatedValue = packageConditionsConfig[key];
6080
6128
 
6081
- if (!isBareSpecifier$1(key)) {
6129
+ if (!isBareSpecifier(key)) {
6082
6130
  const url = new URL(key, rootDirectoryUrl);
6083
6131
  associationsRaw[url] = associatedValue;
6084
6132
  continue;
@@ -6113,7 +6161,7 @@ const createBuildPackageConditions = (
6113
6161
  );
6114
6162
  resolveConditionsFromSpecifier = (specifier, importer, { resolver }) => {
6115
6163
  let associatedValue;
6116
- if (isBareSpecifier$1(specifier)) {
6164
+ if (isBareSpecifier(specifier)) {
6117
6165
  const { url } = resolver({
6118
6166
  specifier,
6119
6167
  parentUrl: importer,
@@ -6138,7 +6186,7 @@ const createBuildPackageConditions = (
6138
6186
  const nodeRuntimeEnabled = Object.keys(runtimeCompat).includes("node");
6139
6187
  // https://nodejs.org/api/esm.html#resolver-algorithm-specification
6140
6188
  const devResolver = (specifier, importer, { resolver }) => {
6141
- if (isBareSpecifier$1(specifier)) {
6189
+ if (isBareSpecifier(specifier)) {
6142
6190
  const { url } = resolver({
6143
6191
  specifier,
6144
6192
  parentUrl: importer,
@@ -6202,7 +6250,7 @@ const createBuildPackageConditions = (
6202
6250
  const associations = URL_META.resolveAssociations(
6203
6251
  { applies: value },
6204
6252
  (pattern) => {
6205
- if (isBareSpecifier$1(pattern)) {
6253
+ if (isBareSpecifier(pattern)) {
6206
6254
  try {
6207
6255
  if (pattern.endsWith("/")) {
6208
6256
  // avoid package path not exported
@@ -6225,7 +6273,7 @@ const createBuildPackageConditions = (
6225
6273
  },
6226
6274
  );
6227
6275
  customResolver = (specifier, importer, { resolver }) => {
6228
- if (isBareSpecifier$1(specifier)) {
6276
+ if (isBareSpecifier(specifier)) {
6229
6277
  const { url } = resolver({
6230
6278
  specifier,
6231
6279
  parentUrl: importer,
@@ -6357,23 +6405,6 @@ const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
6357
6405
  };
6358
6406
  };
6359
6407
 
6360
- const isBareSpecifier$1 = (specifier) => {
6361
- if (
6362
- specifier[0] === "/" ||
6363
- specifier.startsWith("./") ||
6364
- specifier.startsWith("../")
6365
- ) {
6366
- return false;
6367
- }
6368
- try {
6369
- // eslint-disable-next-line no-new
6370
- new URL(specifier);
6371
- return false;
6372
- } catch {
6373
- return true;
6374
- }
6375
- };
6376
-
6377
6408
  const jsenvPluginNodeEsmResolution = ({
6378
6409
  packageDirectory,
6379
6410
  resolutionConfig = {},
@@ -7641,6 +7672,7 @@ const jsenvPluginInjections = (rawAssociations) => {
7641
7672
  }
7642
7673
  return null;
7643
7674
  };
7675
+ const injectionsForUrlInfoMap = new WeakMap();
7644
7676
  let getInjections = null;
7645
7677
 
7646
7678
  return {
@@ -7652,11 +7684,20 @@ const jsenvPluginInjections = (rawAssociations) => {
7652
7684
  { injectionsGetter: rawAssociations },
7653
7685
  context.rootDirectoryUrl,
7654
7686
  );
7655
- const findInjectionsGetter = (urlInfo) => {
7687
+ const findInjectionsGetterForUrl = (url) => {
7656
7688
  const { injectionsGetter } = URL_META.applyAssociations({
7657
- url: asUrlWithoutSearch(urlInfo.url),
7689
+ url: asUrlWithoutSearch(url),
7658
7690
  associations: resolvedAssociations,
7659
7691
  });
7692
+ return injectionsGetter;
7693
+ };
7694
+ // errors and the build read this to know an unresolved url may come from
7695
+ // an injection, and word what they report accordingly
7696
+ context.hasInjections = (url) => {
7697
+ return Boolean(findInjectionsGetterForUrl(url));
7698
+ };
7699
+ const findInjectionsGetter = (urlInfo) => {
7700
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
7660
7701
  if (injectionsGetter) {
7661
7702
  return { injectionsGetter, isInherited: false };
7662
7703
  }
@@ -7688,9 +7729,7 @@ const jsenvPluginInjections = (rawAssociations) => {
7688
7729
  if (!injections || !isInherited) {
7689
7730
  return injections;
7690
7731
  }
7691
- // the file holds several inline contents; a placeholder configured for the file
7692
- // is expected in one of them, not in each
7693
- return asOptionalInjections(injections);
7732
+ return asInheritedInjections(injections);
7694
7733
  };
7695
7734
  }
7696
7735
  },
@@ -7707,22 +7746,79 @@ const jsenvPluginInjections = (rawAssociations) => {
7707
7746
  contentInjections: defaultInjections,
7708
7747
  };
7709
7748
  }
7749
+ injectionsForUrlInfoMap.set(urlInfo, injections);
7710
7750
  return {
7711
- contentInjections: {
7712
- ...defaultInjections,
7713
- ...injections,
7714
- },
7751
+ contentInjections: { ...defaultInjections, ...injections },
7715
7752
  };
7716
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
+ },
7717
7780
  };
7718
7781
  };
7719
7782
 
7720
- const asOptionalInjections = (injections) => {
7721
- const optionalInjections = {};
7783
+ const injectIntoSpecifier = (specifier, injections) => {
7784
+ let specifierInjected = specifier;
7785
+ const keySet = new Set();
7722
7786
  for (const key of Object.keys(injections)) {
7723
- optionalInjections[key] = INJECTIONS.optional(injections[key]);
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);
7724
7800
  }
7725
- return optionalInjections;
7801
+ return { specifier: specifierInjected, keySet };
7802
+ };
7803
+
7804
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
7805
+ // and inherits its injections, with two adjustments:
7806
+ // - a global belongs to the file itself, injecting it into each inline content would
7807
+ // repeat it and reach types that cannot receive globals (css)
7808
+ // - a placeholder configured for the file is expected in one of its inline contents,
7809
+ // not in each, so a missing one is not worth a warning
7810
+ const asInheritedInjections = (injections) => {
7811
+ const inheritedInjections = {};
7812
+ for (const key of Object.keys(injections)) {
7813
+ const value = injections[key];
7814
+ if (isPlaceholderInjection(value)) {
7815
+ inheritedInjections[key] = INJECTIONS.optional(value);
7816
+ }
7817
+ }
7818
+ if (Object.keys(inheritedInjections).length === 0) {
7819
+ return null;
7820
+ }
7821
+ return inheritedInjections;
7726
7822
  };
7727
7823
 
7728
7824
  /*
@@ -9779,8 +9875,10 @@ const getCorePlugins = ({
9779
9875
  ...(packageBundle
9780
9876
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
9781
9877
  : []),
9782
- jsenvPluginReferenceAnalysis(referenceAnalysis),
9878
+ // before reference analysis: an url written by an injection must hold its
9879
+ // final value when references are analyzed
9783
9880
  jsenvPluginInjections(injections),
9881
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
9784
9882
  jsenvPluginTranspilation(transpilation),
9785
9883
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
9786
9884
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -11123,7 +11221,7 @@ const createBuildSpecifierManager = ({
11123
11221
 
11124
11222
  prepareResyncResourceHints: ({ registerHtmlRefine }) => {
11125
11223
  const hintToInjectMap = new Map();
11126
- registerHtmlRefine((htmlAst, { registerHtmlMutation }) => {
11224
+ registerHtmlRefine((htmlAst, { registerHtmlMutation, htmlUrlInfo }) => {
11127
11225
  visitHtmlNodes(htmlAst, {
11128
11226
  link: (node) => {
11129
11227
  if (getHtmlNodeAttribute(node, "jsenv-ignore") !== undefined) {
@@ -11147,12 +11245,39 @@ const createBuildSpecifierManager = ({
11147
11245
  return;
11148
11246
  }
11149
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
+ }
11150
11274
  const finalUrl = internalRedirections.get(rawUrl) || rawUrl;
11151
11275
  const urlInfo = finalKitchen.graph.getUrlInfo(finalUrl);
11152
11276
  if (!urlInfo) {
11153
- if (rel === "preconnect" || rel === "dns-prefetch") {
11154
- // preconnect/dns-prefetch hints refer to origins, not specific resources in the graph.
11155
- // 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.
11156
11281
  return;
11157
11282
  }
11158
11283
  logger.warn(
@@ -11597,6 +11722,16 @@ const asBuildUrlVersioned = ({
11597
11722
  return `${buildDirectoryUrl}${pathname}${search}${hash}`;
11598
11723
  };
11599
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
+
11600
11735
  // import { ANSI } from "@jsenv/humanize";
11601
11736
 
11602
11737
  const createBuildUrlsGenerator = ({
@@ -13320,7 +13455,10 @@ const prepareEntryPointBuild = async (
13320
13455
  const registerHtmlMutation = (callback) => {
13321
13456
  htmlMutationCallbackSet.add(callback);
13322
13457
  };
13323
- htmlRefine(htmlAst, { registerHtmlMutation });
13458
+ htmlRefine(htmlAst, {
13459
+ registerHtmlMutation,
13460
+ htmlUrlInfo: urlInfo,
13461
+ });
13324
13462
  for (const htmlMutationCallback of htmlMutationCallbackSet) {
13325
13463
  htmlMutationCallback();
13326
13464
  }
@@ -13405,21 +13543,4 @@ const prepareEntryPointBuild = async (
13405
13543
  };
13406
13544
  };
13407
13545
 
13408
- const isBareSpecifier = (specifier) => {
13409
- if (
13410
- specifier[0] === "/" ||
13411
- specifier.startsWith("./") ||
13412
- specifier.startsWith("../")
13413
- ) {
13414
- return false;
13415
- }
13416
- try {
13417
- // eslint-disable-next-line no-new
13418
- new URL(specifier);
13419
- return false;
13420
- } catch {
13421
- return true;
13422
- }
13423
- };
13424
-
13425
13546
  export { build };
@@ -8,7 +8,7 @@
8
8
 
9
9
  <body>
10
10
  <p>Syntax error: <strong>${reasonCode}</strong></p>
11
- <a jsenv-ignore="" href="${errorLinkHref}">${errorLinkText}</a>
11
+ <a href="${errorLinkHref}">${errorLinkText}</a>
12
12
  ${syntaxErrorHTML}
13
13
  </body>
14
14
  </html>
@@ -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 = {},
@@ -4563,6 +4548,7 @@ ${reason}`,
4563
4548
  }
4564
4549
  return createFailedToResolveUrlError({
4565
4550
  reason: `An error occured during specifier resolution`,
4551
+ ...detailsFromInjectionsOnOwner(reference),
4566
4552
  ...detailsFromValueThrown(error),
4567
4553
  });
4568
4554
  };
@@ -4624,6 +4610,7 @@ ${reason}`,
4624
4610
  return createFailedToFetchUrlContentError({
4625
4611
  code: "NOT_FOUND",
4626
4612
  reason: "no entry on filesystem",
4613
+ ...detailsFromInjectionsOnOwner(urlInfo.firstReference),
4627
4614
  });
4628
4615
  }
4629
4616
  }
@@ -4856,6 +4843,33 @@ const getFirstReferenceInProject = (reference) => {
4856
4843
  return getFirstReferenceInProject(firstReference);
4857
4844
  };
4858
4845
 
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.
4850
+ const detailsFromInjectionsOnOwner = (reference) => {
4851
+ if (!reference) {
4852
+ return {};
4853
+ }
4854
+ const ownerUrlInfo = reference.ownerUrlInfo;
4855
+ if (ownerUrlInfo.type !== "html") {
4856
+ // "jsenv-ignore" is an html attribute
4857
+ return {};
4858
+ }
4859
+ const { hasInjections } = ownerUrlInfo.context;
4860
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
4861
+ return {};
4862
+ }
4863
+ const { node, attributeName } = reference.astInfo || {};
4864
+ if (!node || !attributeName) {
4865
+ return {};
4866
+ }
4867
+ return {
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:
4869
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
4870
+ };
4871
+ };
4872
+
4859
4873
  const detailsFromPluginController = (jsenvPluginsController) => {
4860
4874
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
4861
4875
  if (!currentPlugin) {
@@ -5019,6 +5033,13 @@ const INJECTIONS = {
5019
5033
  },
5020
5034
  };
5021
5035
 
5036
+ const readInjectionValue = (injection) => {
5037
+ if (injection && injection[injectionSymbol]) {
5038
+ return injection.value;
5039
+ }
5040
+ return injection;
5041
+ };
5042
+
5022
5043
  const isPlaceholderInjection = (value) => {
5023
5044
  return (
5024
5045
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -5092,7 +5113,7 @@ const injectPlaceholderReplacements = (
5092
5113
  for (const { key, isOptional, value } of placeholderReplacements) {
5093
5114
  let index = content.indexOf(key);
5094
5115
  if (index === -1) {
5095
- if (!isOptional) {
5116
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
5096
5117
  urlInfo.context.logger.warn(
5097
5118
  `placeholder "${key}" not found in ${urlInfo.url}.
5098
5119
  --- suggestion a ---
@@ -5117,7 +5138,7 @@ return {
5117
5138
  magicSource.replace({
5118
5139
  start,
5119
5140
  end,
5120
- replacement: asReplacement(value, urlInfo),
5141
+ replacement: asReplacement(value, urlInfo.type),
5121
5142
  });
5122
5143
  index = content.indexOf(key, end);
5123
5144
  }
@@ -5128,8 +5149,8 @@ return {
5128
5149
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
5129
5150
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
5130
5151
  // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
5131
- const asReplacement = (value, urlInfo) => {
5132
- if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
5152
+ const asReplacement = (value, type) => {
5153
+ if (type === "js_classic" || type === "js_module") {
5133
5154
  return JSON.stringify(value, null, " ");
5134
5155
  }
5135
5156
  if (typeof value === "string") {
@@ -5145,7 +5166,14 @@ const injectGlobals = (content, globals, urlInfo) => {
5145
5166
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
5146
5167
  return globalsInjectorOnJs(content, globals, urlInfo);
5147
5168
  }
5148
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
5169
+ throw new Error(
5170
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
5171
+ file: urlInfo.url,
5172
+ ...(urlInfo.isInline
5173
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
5174
+ : {}),
5175
+ }),
5176
+ );
5149
5177
  };
5150
5178
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
5151
5179
  // ideally we would inject an importmap but browser support is too low
@@ -5200,6 +5228,7 @@ const jsenvPluginInjections = (rawAssociations) => {
5200
5228
  }
5201
5229
  return null;
5202
5230
  };
5231
+ const injectionsForUrlInfoMap = new WeakMap();
5203
5232
  let getInjections = null;
5204
5233
 
5205
5234
  return {
@@ -5211,11 +5240,20 @@ const jsenvPluginInjections = (rawAssociations) => {
5211
5240
  { injectionsGetter: rawAssociations },
5212
5241
  context.rootDirectoryUrl,
5213
5242
  );
5214
- const findInjectionsGetter = (urlInfo) => {
5243
+ const findInjectionsGetterForUrl = (url) => {
5215
5244
  const { injectionsGetter } = URL_META.applyAssociations({
5216
- url: asUrlWithoutSearch(urlInfo.url),
5245
+ url: asUrlWithoutSearch(url),
5217
5246
  associations: resolvedAssociations,
5218
5247
  });
5248
+ return injectionsGetter;
5249
+ };
5250
+ // errors and the build read this to know an unresolved url may come from
5251
+ // an injection, and word what they report accordingly
5252
+ context.hasInjections = (url) => {
5253
+ return Boolean(findInjectionsGetterForUrl(url));
5254
+ };
5255
+ const findInjectionsGetter = (urlInfo) => {
5256
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
5219
5257
  if (injectionsGetter) {
5220
5258
  return { injectionsGetter, isInherited: false };
5221
5259
  }
@@ -5247,9 +5285,7 @@ const jsenvPluginInjections = (rawAssociations) => {
5247
5285
  if (!injections || !isInherited) {
5248
5286
  return injections;
5249
5287
  }
5250
- // the file holds several inline contents; a placeholder configured for the file
5251
- // is expected in one of them, not in each
5252
- return asOptionalInjections(injections);
5288
+ return asInheritedInjections(injections);
5253
5289
  };
5254
5290
  }
5255
5291
  },
@@ -5266,22 +5302,79 @@ const jsenvPluginInjections = (rawAssociations) => {
5266
5302
  contentInjections: defaultInjections,
5267
5303
  };
5268
5304
  }
5305
+ injectionsForUrlInfoMap.set(urlInfo, injections);
5269
5306
  return {
5270
- contentInjections: {
5271
- ...defaultInjections,
5272
- ...injections,
5273
- },
5307
+ contentInjections: { ...defaultInjections, ...injections },
5274
5308
  };
5275
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
+ },
5276
5336
  };
5277
5337
  };
5278
5338
 
5279
- const asOptionalInjections = (injections) => {
5280
- const optionalInjections = {};
5339
+ const injectIntoSpecifier = (specifier, injections) => {
5340
+ let specifierInjected = specifier;
5341
+ const keySet = new Set();
5281
5342
  for (const key of Object.keys(injections)) {
5282
- optionalInjections[key] = INJECTIONS.optional(injections[key]);
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
+
5360
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
5361
+ // and inherits its injections, with two adjustments:
5362
+ // - a global belongs to the file itself, injecting it into each inline content would
5363
+ // repeat it and reach types that cannot receive globals (css)
5364
+ // - a placeholder configured for the file is expected in one of its inline contents,
5365
+ // not in each, so a missing one is not worth a warning
5366
+ const asInheritedInjections = (injections) => {
5367
+ const inheritedInjections = {};
5368
+ for (const key of Object.keys(injections)) {
5369
+ const value = injections[key];
5370
+ if (isPlaceholderInjection(value)) {
5371
+ inheritedInjections[key] = INJECTIONS.optional(value);
5372
+ }
5373
+ }
5374
+ if (Object.keys(inheritedInjections).length === 0) {
5375
+ return null;
5283
5376
  }
5284
- return optionalInjections;
5377
+ return inheritedInjections;
5285
5378
  };
5286
5379
 
5287
5380
  const jsenvPluginInliningAsDataUrl = () => {
@@ -7591,8 +7684,10 @@ const getCorePlugins = ({
7591
7684
  ...(packageBundle
7592
7685
  ? [jsenvPluginWorkspaceBundle({ packageDirectory })]
7593
7686
  : []),
7594
- jsenvPluginReferenceAnalysis(referenceAnalysis),
7687
+ // before reference analysis: an url written by an injection must hold its
7688
+ // final value when references are analyzed
7595
7689
  jsenvPluginInjections(injections),
7690
+ jsenvPluginReferenceAnalysis(referenceAnalysis),
7596
7691
  jsenvPluginTranspilation(transpilation),
7597
7692
  // "jsenvPluginInlining" must be very soon because all other plugins will react differently once they see the file is inlined
7598
7693
  ...(inlining ? [jsenvPluginInlining()] : []),
@@ -9255,6 +9350,9 @@ const createUrlInfo = (url, context) => {
9255
9350
  contentFinalized: false,
9256
9351
  contentSideEffects: [],
9257
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(),
9258
9356
 
9259
9357
  sourcemap: null,
9260
9358
  sourcemapIsWrong: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.4",
3
+ "version": "41.4.6",
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"
@@ -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
+ };
@@ -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,33 @@ const getFirstReferenceInProject = (reference) => {
371
373
  return getFirstReferenceInProject(firstReference);
372
374
  };
373
375
 
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.
380
+ const detailsFromInjectionsOnOwner = (reference) => {
381
+ if (!reference) {
382
+ return {};
383
+ }
384
+ const ownerUrlInfo = reference.ownerUrlInfo;
385
+ if (ownerUrlInfo.type !== "html") {
386
+ // "jsenv-ignore" is an html attribute
387
+ return {};
388
+ }
389
+ const { hasInjections } = ownerUrlInfo.context;
390
+ if (!hasInjections || !hasInjections(ownerUrlInfo.url)) {
391
+ return {};
392
+ }
393
+ const { node, attributeName } = reference.astInfo || {};
394
+ if (!node || !attributeName) {
395
+ return {};
396
+ }
397
+ return {
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:
399
+ <${node.nodeName} jsenv-ignore ${attributeName}="${reference.specifier}" />`,
400
+ };
401
+ };
402
+
374
403
  const detailsFromPluginController = (jsenvPluginsController) => {
375
404
  const currentPlugin = jsenvPluginsController.getCurrentPlugin();
376
405
  if (!currentPlugin) {
@@ -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,
@@ -1,4 +1,5 @@
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");
@@ -24,6 +25,13 @@ export const INJECTIONS = {
24
25
  },
25
26
  };
26
27
 
28
+ export const readInjectionValue = (injection) => {
29
+ if (injection && injection[injectionSymbol]) {
30
+ return injection.value;
31
+ }
32
+ return injection;
33
+ };
34
+
27
35
  export const isPlaceholderInjection = (value) => {
28
36
  return (
29
37
  !value || !value[injectionSymbol] || value[injectionSymbol] !== "global"
@@ -97,7 +105,7 @@ export const injectPlaceholderReplacements = (
97
105
  for (const { key, isOptional, value } of placeholderReplacements) {
98
106
  let index = content.indexOf(key);
99
107
  if (index === -1) {
100
- if (!isOptional) {
108
+ if (!isOptional && !urlInfo.contentInjectionUsedKeySet.has(key)) {
101
109
  urlInfo.context.logger.warn(
102
110
  `placeholder "${key}" not found in ${urlInfo.url}.
103
111
  --- suggestion a ---
@@ -122,7 +130,7 @@ return {
122
130
  magicSource.replace({
123
131
  start,
124
132
  end,
125
- replacement: asReplacement(value, urlInfo),
133
+ replacement: asReplacement(value, urlInfo.type),
126
134
  });
127
135
  index = content.indexOf(key, end);
128
136
  }
@@ -133,8 +141,8 @@ return {
133
141
  // In JS the placeholder stands for a value, so it must be substituted by a literal.
134
142
  // Everywhere else (html attributes and text, css, ...) it stands for a piece of text
135
143
  // and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me"
136
- const asReplacement = (value, urlInfo) => {
137
- if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
144
+ export const asReplacement = (value, type) => {
145
+ if (type === "js_classic" || type === "js_module") {
138
146
  return JSON.stringify(value, null, " ");
139
147
  }
140
148
  if (typeof value === "string") {
@@ -150,7 +158,14 @@ export const injectGlobals = (content, globals, urlInfo) => {
150
158
  if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") {
151
159
  return globalsInjectorOnJs(content, globals, urlInfo);
152
160
  }
153
- throw new Error(`cannot inject globals into "${urlInfo.type}"`);
161
+ throw new Error(
162
+ createDetailedMessage(`cannot inject globals into "${urlInfo.type}"`, {
163
+ file: urlInfo.url,
164
+ ...(urlInfo.isInline
165
+ ? { "inline content of": urlInfo.inlineUrlSite.url }
166
+ : {}),
167
+ }),
168
+ );
154
169
  };
155
170
  const globalInjectorOnHtml = (content, globals, urlInfo) => {
156
171
  // ideally we would inject an importmap but browser support is too low
@@ -1,6 +1,10 @@
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
+ readInjectionValue,
7
+ } from "../../kitchen/url_graph/url_info_injections.js";
4
8
 
5
9
  export const jsenvPluginInjections = (rawAssociations) => {
6
10
  const getDefaultInjections = (urlInfo) => {
@@ -15,6 +19,7 @@ export const jsenvPluginInjections = (rawAssociations) => {
15
19
  }
16
20
  return null;
17
21
  };
22
+ const injectionsForUrlInfoMap = new WeakMap();
18
23
  let getInjections = null;
19
24
 
20
25
  return {
@@ -26,11 +31,20 @@ export const jsenvPluginInjections = (rawAssociations) => {
26
31
  { injectionsGetter: rawAssociations },
27
32
  context.rootDirectoryUrl,
28
33
  );
29
- const findInjectionsGetter = (urlInfo) => {
34
+ const findInjectionsGetterForUrl = (url) => {
30
35
  const { injectionsGetter } = URL_META.applyAssociations({
31
- url: asUrlWithoutSearch(urlInfo.url),
36
+ url: asUrlWithoutSearch(url),
32
37
  associations: resolvedAssociations,
33
38
  });
39
+ return injectionsGetter;
40
+ };
41
+ // errors and the build read this to know an unresolved url may come from
42
+ // an injection, and word what they report accordingly
43
+ context.hasInjections = (url) => {
44
+ return Boolean(findInjectionsGetterForUrl(url));
45
+ };
46
+ const findInjectionsGetter = (urlInfo) => {
47
+ const injectionsGetter = findInjectionsGetterForUrl(urlInfo.url);
34
48
  if (injectionsGetter) {
35
49
  return { injectionsGetter, isInherited: false };
36
50
  }
@@ -62,9 +76,7 @@ export const jsenvPluginInjections = (rawAssociations) => {
62
76
  if (!injections || !isInherited) {
63
77
  return injections;
64
78
  }
65
- // the file holds several inline contents; a placeholder configured for the file
66
- // is expected in one of them, not in each
67
- return asOptionalInjections(injections);
79
+ return asInheritedInjections(injections);
68
80
  };
69
81
  }
70
82
  },
@@ -81,20 +93,77 @@ export const jsenvPluginInjections = (rawAssociations) => {
81
93
  contentInjections: defaultInjections,
82
94
  };
83
95
  }
96
+ injectionsForUrlInfoMap.set(urlInfo, injections);
84
97
  return {
85
- contentInjections: {
86
- ...defaultInjections,
87
- ...injections,
88
- },
98
+ contentInjections: { ...defaultInjections, ...injections },
89
99
  };
90
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
+ },
91
127
  };
92
128
  };
93
129
 
94
- const asOptionalInjections = (injections) => {
95
- const optionalInjections = {};
130
+ const injectIntoSpecifier = (specifier, injections) => {
131
+ let specifierInjected = specifier;
132
+ const keySet = new Set();
96
133
  for (const key of Object.keys(injections)) {
97
- optionalInjections[key] = INJECTIONS.optional(injections[key]);
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
+
151
+ // What a file inlines (a <script> or a <style> inside html) is authored in that file
152
+ // and inherits its injections, with two adjustments:
153
+ // - a global belongs to the file itself, injecting it into each inline content would
154
+ // repeat it and reach types that cannot receive globals (css)
155
+ // - a placeholder configured for the file is expected in one of its inline contents,
156
+ // not in each, so a missing one is not worth a warning
157
+ const asInheritedInjections = (injections) => {
158
+ const inheritedInjections = {};
159
+ for (const key of Object.keys(injections)) {
160
+ const value = injections[key];
161
+ if (isPlaceholderInjection(value)) {
162
+ inheritedInjections[key] = INJECTIONS.optional(value);
163
+ }
164
+ }
165
+ if (Object.keys(inheritedInjections).length === 0) {
166
+ return null;
98
167
  }
99
- return optionalInjections;
168
+ return inheritedInjections;
100
169
  };
@@ -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
- };