@jsenv/core 41.4.10 → 41.4.12

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.
@@ -196,12 +196,21 @@ const createStatus = (
196
196
  declaredVersion,
197
197
  declaredBy,
198
198
  installedVersion: null,
199
+ // the file telling this dependency apart; it is what an install rewrites and
200
+ // what the dev server looks at to know the dependency became the declared one
201
+ watchedPath: null,
199
202
  state: "missing",
200
203
  };
201
204
  const installedDirectoryUrl = findInstalledDirectoryUrl(
202
205
  declaringDirectoryUrl,
203
206
  packageName,
204
207
  );
208
+ status.watchedPath = watchedPathFromDirectoryUrl(
209
+ packageDirectory,
210
+ // not installed yet: name the place where it is expected to appear
211
+ installedDirectoryUrl ||
212
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
213
+ );
205
214
  if (!installedDirectoryUrl) {
206
215
  return status;
207
216
  }
@@ -220,6 +229,14 @@ const createStatus = (
220
229
  return status;
221
230
  };
222
231
 
232
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
233
+ const packageJsonUrl = `${directoryUrl}package.json`;
234
+ if (!packageDirectory.url) {
235
+ return packageJsonUrl;
236
+ }
237
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
238
+ };
239
+
223
240
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
224
241
  let directoryUrl = declaringDirectoryUrl;
225
242
  while (directoryUrl) {
@@ -9298,6 +9315,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
9298
9315
  const jsenvPluginDependencyStatus = ({
9299
9316
  dependencyProblemEventEmitter,
9300
9317
  getDependencyProblems,
9318
+ getDependencyWatchInfo = () => ({}),
9301
9319
  }) => {
9302
9320
  return {
9303
9321
  name: "jsenv:dependency_status",
@@ -9334,7 +9352,10 @@ const jsenvPluginDependencyStatus = ({
9334
9352
  src: clientReference.generatedSpecifier,
9335
9353
  initCall: {
9336
9354
  callee: "initDependencyStatus",
9337
- params: { problems: getDependencyProblems() },
9355
+ params: {
9356
+ problems: getDependencyProblems(),
9357
+ watchInfo: getDependencyWatchInfo(),
9358
+ },
9338
9359
  },
9339
9360
  pluginName: "jsenv:dependency_status",
9340
9361
  });
@@ -9408,6 +9429,12 @@ const jsenvPluginCustomElementsRedefine = () => {
9408
9429
  const jsenvPluginRibbon = ({
9409
9430
  rootDirectoryUrl,
9410
9431
  htmlInclude = "/**/*.html",
9432
+ text,
9433
+ color,
9434
+ textColor,
9435
+ href,
9436
+ target,
9437
+ position,
9411
9438
  }) => {
9412
9439
  const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
9413
9440
  const associations = URL_META.resolveAssociations(
@@ -9420,7 +9447,7 @@ const jsenvPluginRibbon = ({
9420
9447
  );
9421
9448
  return {
9422
9449
  name: "jsenv:ribbon",
9423
- appliesDuring: "dev",
9450
+ appliesDuring: "*",
9424
9451
  transformUrlContent: {
9425
9452
  html: (urlInfo) => {
9426
9453
  const jsenvToolbarHtmlClientFileUrl = urlInfo.context.getPluginMeta(
@@ -9455,9 +9482,19 @@ const jsenvPluginRibbon = ({
9455
9482
  src: ribbonClientFileReference.generatedSpecifier,
9456
9483
  initCall: {
9457
9484
  callee: "injectRibbon",
9458
- params: {
9459
- text: urlInfo.context.dev ? "DEV" : "BUILD",
9460
- },
9485
+ params: withoutUndefinedValues({
9486
+ text:
9487
+ text === undefined
9488
+ ? urlInfo.context.dev
9489
+ ? "DEV"
9490
+ : "BUILD"
9491
+ : text,
9492
+ color,
9493
+ textColor,
9494
+ href,
9495
+ target,
9496
+ position,
9497
+ }),
9461
9498
  },
9462
9499
  pluginName: "jsenv:ribbon",
9463
9500
  });
@@ -9467,6 +9504,16 @@ const jsenvPluginRibbon = ({
9467
9504
  };
9468
9505
  };
9469
9506
 
9507
+ const withoutUndefinedValues = (object) => {
9508
+ const objectWithoutUndefinedValues = {};
9509
+ for (const key of Object.keys(object)) {
9510
+ if (object[key] !== undefined) {
9511
+ objectWithoutUndefinedValues[key] = object[key];
9512
+ }
9513
+ }
9514
+ return objectWithoutUndefinedValues;
9515
+ };
9516
+
9470
9517
  /**
9471
9518
  * HTML page server by jsenv dev server will listen for drop events
9472
9519
  * and redirect the browser to the dropped file location.
@@ -12153,6 +12200,18 @@ const jsenvPluginMappings = (mappings) => {
12153
12200
  * How URLs are versioned for this entry point (defaults to "search_param")
12154
12201
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
12155
12202
  * Sourcemap generation strategy for this entry point (defaults to "none")
12203
+ * @param {boolean|object} [entryPoint.ribbon=false]
12204
+ * Inject a ribbon marking the page as non-production. Disabled by default
12205
+ * because the ribbon ends up inside the build directory: enable it on the
12206
+ * builds meant for a preview/review app, not on the production build.
12207
+ *
12208
+ * ribbon: {
12209
+ * text: "preview",
12210
+ * color: "#7c3aed",
12211
+ * href: "https://github.com/org/repo/pull/42",
12212
+ * }
12213
+ *
12214
+ * See startDevServer "ribbon" param for the full list of options.
12156
12215
  * @param {object} [entryPoint.injections]
12157
12216
  * Values to inject into files, as { urlPattern: getInjections }.
12158
12217
  * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
@@ -12970,6 +13029,7 @@ const entryPointDefaultParams = {
12970
13029
  magicDirectoryIndex: undefined,
12971
13030
  directoryReferenceEffect: undefined,
12972
13031
  scenarioPlaceholders: undefined,
13032
+ ribbon: false,
12973
13033
  injections: undefined,
12974
13034
  transpilation: {},
12975
13035
  preserveComments: undefined,
@@ -13024,6 +13084,7 @@ const prepareEntryPointBuild = async (
13024
13084
  magicDirectoryIndex,
13025
13085
  directoryReferenceEffect,
13026
13086
  scenarioPlaceholders,
13087
+ ribbon,
13027
13088
  injections,
13028
13089
  transpilation,
13029
13090
  preserveComments,
@@ -13200,6 +13261,7 @@ const prepareEntryPointBuild = async (
13200
13261
  inlining: false,
13201
13262
  http,
13202
13263
  scenarioPlaceholders,
13264
+ ribbon,
13203
13265
  packageSideEffects,
13204
13266
  }),
13205
13267
  ]);
@@ -1,49 +1,117 @@
1
- const injectRibbon = ({ text }) => {
1
+ /*
2
+ * A ribbon marking a page as non-production: "DEV" while developing, whatever
3
+ * the app wants otherwise ("preview", a version number, a branch name).
4
+ *
5
+ * Two shapes, picked by "position":
6
+ * - a corner ("top-right", "top-left", "bottom-right", "bottom-left") draws the
7
+ * diagonal band clipped by a 100x100 square in that corner
8
+ * - an edge ("top", "bottom") draws a full width horizontal band
9
+ *
10
+ * The container never captures pointer events; only the visible band does, and
11
+ * only when "href" turns it into a link. A corner ribbon therefore costs the app
12
+ * nothing: the rest of the 100x100 square keeps letting clicks through to the
13
+ * interface underneath, which usually lives exactly there.
14
+ */
15
+
16
+ const CORNER_POSITIONS = {
17
+ "top-right": {
18
+ placement: `top: 0; right: 0;`,
19
+ transform: `translate(26px, -26px) rotate(45deg)`,
20
+ },
21
+ "top-left": {
22
+ placement: `top: 0; left: 0;`,
23
+ transform: `translate(-26px, -26px) rotate(-45deg)`,
24
+ },
25
+ "bottom-right": {
26
+ placement: `bottom: 0; right: 0;`,
27
+ transform: `translate(26px, 26px) rotate(-45deg)`,
28
+ },
29
+ "bottom-left": {
30
+ placement: `bottom: 0; left: 0;`,
31
+ transform: `translate(-26px, 26px) rotate(45deg)`,
32
+ },
33
+ };
34
+ const EDGE_POSITIONS = {
35
+ top: `top: 0; left: 0; right: 0;`,
36
+ bottom: `bottom: 0; left: 0; right: 0;`,
37
+ };
38
+ const DARK_TEXT_COLOR = "rgb(55, 7, 7)";
39
+ const LIGHT_TEXT_COLOR = "rgb(255, 255, 255)";
40
+
41
+ const injectRibbon = ({
42
+ text = "DEV",
43
+ color = "orange",
44
+ textColor,
45
+ href,
46
+ target = "_blank",
47
+ position = "top-right",
48
+ }) => {
49
+ if (!CORNER_POSITIONS[position] && !EDGE_POSITIONS[position]) {
50
+ console.warn(
51
+ `unknown ribbon position "${position}", falling back to "top-right"`,
52
+ );
53
+ position = "top-right";
54
+ }
55
+ if (textColor === undefined) {
56
+ textColor = pickTextColorFor(color);
57
+ }
58
+ const corner = CORNER_POSITIONS[position];
2
59
  const css = /* css */ `
3
60
  #jsenv_ribbon_container {
4
61
  position: fixed;
5
- top: 0;
6
- right: 0;
7
62
  z-index: 1001;
8
- width: 100px;
9
- height: 100px;
10
- opacity: 0.5;
11
63
  pointer-events: none;
12
64
  overflow: hidden;
13
- }
14
- #jsenv_ribbon {
15
- position: absolute;
16
- top: -10px;
17
- right: -10px;
18
- width: 100%;
19
- height: 100%;
65
+ ${corner
66
+ ? `width: 100px; height: 100px; ${corner.placement}`
67
+ : EDGE_POSITIONS[position]}
20
68
  }
21
69
  #jsenv_ribbon_text {
22
- position: absolute;
23
- top: 20px;
24
- left: 0px;
25
70
  display: block;
26
- width: 125px;
27
- color: rgb(55, 7, 7);
71
+ color: ${textColor};
28
72
  font-weight: 700;
29
73
  font-size: 16px;
30
74
  font-family: "Lato", sans-serif;
31
75
  text-align: center;
76
+ text-decoration: none;
32
77
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
33
- line-height: 36px;
34
- background-color: orange;
78
+ background-color: ${color};
35
79
  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
36
- transform: rotate(45deg);
80
+ opacity: 0.8;
81
+ transition: opacity 150ms ease;
37
82
  user-select: none;
83
+ ${corner
84
+ ? `
85
+ position: absolute;
86
+ top: 50%;
87
+ left: 50%;
88
+ width: 150px;
89
+ margin-top: -18px;
90
+ margin-left: -75px;
91
+ line-height: 36px;
92
+ transform: ${corner.transform};`
93
+ : `
94
+ position: relative;
95
+ width: 100%;
96
+ line-height: 28px;`}
97
+ }
98
+ /* A ribbon without href never receives pointer events, so it stays at 0.8:
99
+ covering enough to be read in one go, transparent enough to guess what is
100
+ underneath */
101
+ #jsenv_ribbon_text:hover,
102
+ #jsenv_ribbon_text:focus-visible {
103
+ opacity: 1;
38
104
  }
39
105
  `;
106
+ const tagName = href ? "a" : "div";
107
+ const linkAttributes = href
108
+ ? ` href="${escapeHtmlAttributeValue(href)}" target="${escapeHtmlAttributeValue(target)}" rel="noopener noreferrer" style="pointer-events: auto;"`
109
+ : "";
40
110
  const html = /* html */ `<div id="jsenv_ribbon_container">
41
111
  <style>
42
112
  ${css}
43
113
  </style>
44
- <div id="jsenv_ribbon">
45
- <div id="jsenv_ribbon_text">${text}</div>
46
- </div>
114
+ <${tagName} id="jsenv_ribbon_text"${linkAttributes}>${text}</${tagName}>
47
115
  </div>`;
48
116
  class JsenvRibbonHtmlElement extends HTMLElement {
49
117
  constructor({ hidden }) {
@@ -72,6 +140,34 @@ const injectRibbon = ({ text }) => {
72
140
  );
73
141
  };
74
142
 
143
+ // The browser is the only one knowing what "orange" or "oklch(...)" resolves to,
144
+ // so we let it resolve the color before deciding between dark and light text
145
+ const pickTextColorFor = (color) => {
146
+ const probeElement = document.createElement("div");
147
+ probeElement.style.color = color;
148
+ document.body.appendChild(probeElement);
149
+ const colorComputed = getComputedStyle(probeElement).color;
150
+ probeElement.remove();
151
+ const rgbMatch = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(
152
+ colorComputed,
153
+ );
154
+ if (!rgbMatch) {
155
+ return DARK_TEXT_COLOR;
156
+ }
157
+ const r = parseFloat(rgbMatch[1]);
158
+ const g = parseFloat(rgbMatch[2]);
159
+ const b = parseFloat(rgbMatch[3]);
160
+ const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
161
+ if (brightness > 0.55) {
162
+ return DARK_TEXT_COLOR;
163
+ }
164
+ return LIGHT_TEXT_COLOR;
165
+ };
166
+
167
+ const escapeHtmlAttributeValue = (value) => {
168
+ return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
169
+ };
170
+
75
171
  const appendIntoRespectingLineBreaksAndIndentation = (
76
172
  node,
77
173
  parentNode,
@@ -3,11 +3,22 @@
3
3
  *
4
4
  * A missing dependency is not handled here: it makes the import fail, so the
5
5
  * error overlay already says it, with more precision (which import, where).
6
+ *
7
+ * The overlay does not only describe the problem, it shows the dev server
8
+ * watching for the fix: the paths being looked at are named and kept alive on
9
+ * screen, so waiting for "npm install" feels like waiting for something that is
10
+ * actually going to happen.
6
11
  */
7
12
 
8
13
  let removeOverlay = () => {};
14
+ let watchInfoFromServer = {};
15
+ // packages listed as outdated at some point, and the path watched for each;
16
+ // what leaves this map has been installed while the overlay was open, which is
17
+ // worth showing as such
18
+ const outdatedPathMap = new Map();
9
19
 
10
- const initDependencyStatus = ({ problems }) => {
20
+ const initDependencyStatus = ({ problems, watchInfo }) => {
21
+ watchInfoFromServer = watchInfo || {};
11
22
  render(problems);
12
23
  // without the server events channel the page only knows the state it was
13
24
  // served with, which is still better than nothing
@@ -24,9 +35,19 @@ const render = (problems) => {
24
35
  const outdatedList = problems.filter(({ state }) => state === "outdated");
25
36
  removeOverlay();
26
37
  removeOverlay = () => {};
38
+ const installedList = [];
39
+ for (const [packageName, watchedPath] of outdatedPathMap) {
40
+ if (!outdatedList.some((problem) => problem.packageName === packageName)) {
41
+ installedList.push({ packageName, watchedPath });
42
+ }
43
+ }
27
44
  if (outdatedList.length === 0) {
45
+ outdatedPathMap.clear();
28
46
  return;
29
47
  }
48
+ for (const { packageName, watchedPath } of outdatedList) {
49
+ outdatedPathMap.set(packageName, watchedPath);
50
+ }
30
51
  const supervisor = window.__supervisor__;
31
52
  if (!supervisor || !supervisor.reportWarning) {
32
53
  console.warn(summarize(outdatedList));
@@ -41,8 +62,8 @@ const render = (problems) => {
41
62
  details: [
42
63
  "The page is running with what is installed in node_modules, which is not what package.json asks for. It may work, but it is not the code the project expects.",
43
64
  "Run npm install to fix it. If an install is already running, there is nothing to do but wait.",
44
- "This page reloads on its own as soon as node_modules matches package.json.",
45
65
  ],
66
+ node: createWatchNode(outdatedList, installedList),
46
67
  });
47
68
  };
48
69
 
@@ -58,4 +79,214 @@ const summarize = (outdatedList) => {
58
79
  .join(", ")}`;
59
80
  };
60
81
 
82
+ const createWatchNode = (outdatedList, installedList) => {
83
+ const { packageJsonPath = "package.json", pollInterval } =
84
+ watchInfoFromServer;
85
+ const node = document.createElement("div");
86
+ node.className = "dependency_watch";
87
+ const rows = [];
88
+ rows.push(
89
+ createRow({
90
+ state: "watching",
91
+ path: packageJsonPath,
92
+ // a file watcher, not a poll: an edit here is what puts a dependency out
93
+ // of date in the first place
94
+ note: "on every change",
95
+ pollInterval,
96
+ }),
97
+ );
98
+ for (const { packageName, declaredVersion, watchedPath } of outdatedList) {
99
+ rows.push(
100
+ createRow({
101
+ state: "watching",
102
+ path: watchedPath || `node_modules/${packageName}/package.json`,
103
+ note: pollInterval
104
+ ? `waiting for ${declaredVersion}, read every ${pollInterval}ms`
105
+ : `waiting for ${declaredVersion}`,
106
+ pollInterval,
107
+ }),
108
+ );
109
+ }
110
+ for (const { packageName, watchedPath } of installedList) {
111
+ rows.push(
112
+ createRow({
113
+ state: "done",
114
+ path: watchedPath || `node_modules/${packageName}/package.json`,
115
+ note: "installed",
116
+ }),
117
+ );
118
+ }
119
+ node.innerHTML = `
120
+ <style>
121
+ ${watchCSS}
122
+ </style>
123
+ <div class="dependency_watch_head">
124
+ <span class="dependency_watch_live">
125
+ <span class="dependency_watch_live_dot"></span>watching
126
+ </span>
127
+ <span class="dependency_watch_head_text">
128
+ the files that will tell the install is over
129
+ </span>
130
+ </div>
131
+ <div class="dependency_watch_rows">
132
+ ${rows.join("\n ")}
133
+ </div>
134
+ <div class="dependency_watch_effect" data-copy-line>
135
+ As soon as node_modules matches package.json, this page reloads by itself.
136
+ </div>`;
137
+ return node;
138
+ };
139
+
140
+ const createRow = ({ state, path, note, pollInterval }) => {
141
+ const sweepStyle = pollInterval
142
+ ? ` style="animation-duration: ${pollInterval}ms"`
143
+ : "";
144
+ return `<div class="dependency_watch_row" data-state="${state}">
145
+ <span class="dependency_watch_row_icon"></span>
146
+ <code class="dependency_watch_row_path">${escapeHtml(path)}</code>
147
+ <span class="dependency_watch_row_note">${escapeHtml(note)}</span>
148
+ ${
149
+ state === "watching"
150
+ ? `<span class="dependency_watch_row_sweep"${sweepStyle}></span>`
151
+ : ""
152
+ }
153
+ </div>`;
154
+ };
155
+
156
+ const escapeHtml = (string) => {
157
+ return String(string)
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;")
162
+ .replace(/'/g, "&#039;");
163
+ };
164
+
165
+ const watchCSS = /* css */ `
166
+ .dependency_watch_head {
167
+ display: flex;
168
+ margin-bottom: 8px;
169
+ align-items: center;
170
+ gap: 8px;
171
+ }
172
+ .dependency_watch_live {
173
+ display: flex;
174
+ padding: 2px 8px;
175
+ align-items: center;
176
+ gap: 6px;
177
+ color: #ffab40;
178
+ font-size: 12px;
179
+ text-transform: uppercase;
180
+ letter-spacing: 0.08em;
181
+ border: 1px solid currentColor;
182
+ border-radius: 999px;
183
+ }
184
+ .dependency_watch_live_dot {
185
+ width: 7px;
186
+ height: 7px;
187
+ background: currentColor;
188
+ border-radius: 50%;
189
+ animation: dependency_watch_pulse 1.4s ease-in-out infinite;
190
+ }
191
+ @keyframes dependency_watch_pulse {
192
+ 0%,
193
+ 100% {
194
+ opacity: 0.25;
195
+ transform: scale(0.8);
196
+ }
197
+ 50% {
198
+ opacity: 1;
199
+ transform: scale(1.15);
200
+ }
201
+ }
202
+ .dependency_watch_head_text {
203
+ font-size: 13px;
204
+ }
205
+
206
+ .dependency_watch_rows {
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 4px;
210
+ }
211
+ .dependency_watch_row {
212
+ position: relative;
213
+ display: flex;
214
+ padding: 6px 10px;
215
+ align-items: center;
216
+ gap: 8px;
217
+ font-size: 13px;
218
+ background: rgba(255, 255, 255, 0.04);
219
+ border: 1px solid #333;
220
+ border-radius: 4px;
221
+ overflow: hidden;
222
+ }
223
+ .dependency_watch_row_icon {
224
+ width: 8px;
225
+ height: 8px;
226
+ flex: none;
227
+ border: 1px solid #ffab40;
228
+ border-radius: 50%;
229
+ }
230
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_icon {
231
+ background: #7fd67f;
232
+ border-color: #7fd67f;
233
+ }
234
+ .dependency_watch_row_path {
235
+ color: #eee;
236
+ text-overflow: ellipsis;
237
+ white-space: nowrap;
238
+ overflow: hidden;
239
+ }
240
+ .dependency_watch_row_note {
241
+ margin-left: auto;
242
+ padding-left: 8px;
243
+ font-size: 12px;
244
+ white-space: nowrap;
245
+ opacity: 0.8;
246
+ }
247
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_note {
248
+ color: #7fd67f;
249
+ opacity: 1;
250
+ }
251
+ /* the sweep is the watching made visible: one pass per read */
252
+ .dependency_watch_row_sweep {
253
+ position: absolute;
254
+ top: 0;
255
+ left: 0;
256
+ width: 35%;
257
+ height: 100%;
258
+ background: linear-gradient(
259
+ 90deg,
260
+ transparent,
261
+ rgba(255, 171, 64, 0.16),
262
+ transparent
263
+ );
264
+ animation: dependency_watch_sweep 2s linear infinite;
265
+ pointer-events: none;
266
+ }
267
+ @keyframes dependency_watch_sweep {
268
+ from {
269
+ transform: translateX(-100%);
270
+ }
271
+ to {
272
+ transform: translateX(340%);
273
+ }
274
+ }
275
+
276
+ .dependency_watch_effect {
277
+ margin-top: 8px;
278
+ color: #ffab40;
279
+ font-size: 13px;
280
+ }
281
+
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .dependency_watch_live_dot {
284
+ animation: none;
285
+ }
286
+ .dependency_watch_row_sweep {
287
+ display: none;
288
+ }
289
+ }
290
+ `;
291
+
61
292
  export { initDependencyStatus };