@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.
@@ -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
- export const initDependencyStatus = ({ problems }) => {
20
+ export 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
 
@@ -57,3 +78,213 @@ const summarize = (outdatedList) => {
57
78
  .map(({ packageName }) => packageName)
58
79
  .join(", ")}`;
59
80
  };
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
+ `;
@@ -15,6 +15,7 @@ const clientFileUrl = import.meta.resolve("./client/dependency_status.js");
15
15
  export const jsenvPluginDependencyStatus = ({
16
16
  dependencyProblemEventEmitter,
17
17
  getDependencyProblems,
18
+ getDependencyWatchInfo = () => ({}),
18
19
  }) => {
19
20
  return {
20
21
  name: "jsenv:dependency_status",
@@ -51,7 +52,10 @@ export const jsenvPluginDependencyStatus = ({
51
52
  src: clientReference.generatedSpecifier,
52
53
  initCall: {
53
54
  callee: "initDependencyStatus",
54
- params: { problems: getDependencyProblems() },
55
+ params: {
56
+ problems: getDependencyProblems(),
57
+ watchInfo: getDependencyWatchInfo(),
58
+ },
55
59
  },
56
60
  pluginName: "jsenv:dependency_status",
57
61
  });
@@ -1,49 +1,117 @@
1
- export 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
+ export 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 @@ export 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,
@@ -5,6 +5,12 @@ import { asUrlWithoutSearch } from "@jsenv/urls";
5
5
  export const jsenvPluginRibbon = ({
6
6
  rootDirectoryUrl,
7
7
  htmlInclude = "/**/*.html",
8
+ text,
9
+ color,
10
+ textColor,
11
+ href,
12
+ target,
13
+ position,
8
14
  }) => {
9
15
  const ribbonClientFileUrl = import.meta.resolve("./client/ribbon.js");
10
16
  const associations = URL_META.resolveAssociations(
@@ -17,7 +23,7 @@ export const jsenvPluginRibbon = ({
17
23
  );
18
24
  return {
19
25
  name: "jsenv:ribbon",
20
- appliesDuring: "dev",
26
+ appliesDuring: "*",
21
27
  transformUrlContent: {
22
28
  html: (urlInfo) => {
23
29
  const jsenvToolbarHtmlClientFileUrl = urlInfo.context.getPluginMeta(
@@ -52,9 +58,19 @@ export const jsenvPluginRibbon = ({
52
58
  src: ribbonClientFileReference.generatedSpecifier,
53
59
  initCall: {
54
60
  callee: "injectRibbon",
55
- params: {
56
- text: urlInfo.context.dev ? "DEV" : "BUILD",
57
- },
61
+ params: withoutUndefinedValues({
62
+ text:
63
+ text === undefined
64
+ ? urlInfo.context.dev
65
+ ? "DEV"
66
+ : "BUILD"
67
+ : text,
68
+ color,
69
+ textColor,
70
+ href,
71
+ target,
72
+ position,
73
+ }),
58
74
  },
59
75
  pluginName: "jsenv:ribbon",
60
76
  });
@@ -63,3 +79,13 @@ export const jsenvPluginRibbon = ({
63
79
  },
64
80
  };
65
81
  };
82
+
83
+ const withoutUndefinedValues = (object) => {
84
+ const objectWithoutUndefinedValues = {};
85
+ for (const key of Object.keys(object)) {
86
+ if (object[key] !== undefined) {
87
+ objectWithoutUndefinedValues[key] = object[key];
88
+ }
89
+ }
90
+ return objectWithoutUndefinedValues;
91
+ };
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Inject the ribbon into an html string, inlining the client code.
3
+ *
4
+ * Used when serving files that must not be modified on the filesystem (the build
5
+ * directory served by startBuildServer): the ribbon exists only in what the
6
+ * server sends, never in the artifact that gets deployed.
7
+ */
8
+
9
+ import { injectJsenvScript, parseHtml, stringifyHtmlAst } from "@jsenv/ast";
10
+ import { readFileSync } from "node:fs";
11
+
12
+ const ribbonClientFileUrl = import.meta.resolve("./client/ribbon.js");
13
+ let ribbonClientFileContent;
14
+
15
+ export const injectRibbonIntoHtml = (
16
+ html,
17
+ { text = "BUILD", color, textColor, href, target, position } = {},
18
+ ) => {
19
+ if (ribbonClientFileContent === undefined) {
20
+ ribbonClientFileContent = String(
21
+ readFileSync(new URL(ribbonClientFileUrl)),
22
+ );
23
+ }
24
+ const htmlAst = parseHtml({ html, url: "file:///ribbon.html" });
25
+ const params = { text, color, textColor, href, target, position };
26
+ for (const key of Object.keys(params)) {
27
+ if (params[key] === undefined) {
28
+ delete params[key];
29
+ }
30
+ }
31
+ injectJsenvScript(htmlAst, {
32
+ type: "module",
33
+ content: `${ribbonClientFileContent}
34
+ injectRibbon(${JSON.stringify(params)});`,
35
+ pluginName: "jsenv:ribbon",
36
+ });
37
+ return stringifyHtmlAst(htmlAst);
38
+ };