@jsenv/core 41.4.11 → 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.
@@ -9429,6 +9429,12 @@ const jsenvPluginCustomElementsRedefine = () => {
9429
9429
  const jsenvPluginRibbon = ({
9430
9430
  rootDirectoryUrl,
9431
9431
  htmlInclude = "/**/*.html",
9432
+ text,
9433
+ color,
9434
+ textColor,
9435
+ href,
9436
+ target,
9437
+ position,
9432
9438
  }) => {
9433
9439
  const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
9434
9440
  const associations = URL_META.resolveAssociations(
@@ -9441,7 +9447,7 @@ const jsenvPluginRibbon = ({
9441
9447
  );
9442
9448
  return {
9443
9449
  name: "jsenv:ribbon",
9444
- appliesDuring: "dev",
9450
+ appliesDuring: "*",
9445
9451
  transformUrlContent: {
9446
9452
  html: (urlInfo) => {
9447
9453
  const jsenvToolbarHtmlClientFileUrl = urlInfo.context.getPluginMeta(
@@ -9476,9 +9482,19 @@ const jsenvPluginRibbon = ({
9476
9482
  src: ribbonClientFileReference.generatedSpecifier,
9477
9483
  initCall: {
9478
9484
  callee: "injectRibbon",
9479
- params: {
9480
- text: urlInfo.context.dev ? "DEV" : "BUILD",
9481
- },
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
+ }),
9482
9498
  },
9483
9499
  pluginName: "jsenv:ribbon",
9484
9500
  });
@@ -9488,6 +9504,16 @@ const jsenvPluginRibbon = ({
9488
9504
  };
9489
9505
  };
9490
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
+
9491
9517
  /**
9492
9518
  * HTML page server by jsenv dev server will listen for drop events
9493
9519
  * and redirect the browser to the dropped file location.
@@ -12174,6 +12200,18 @@ const jsenvPluginMappings = (mappings) => {
12174
12200
  * How URLs are versioned for this entry point (defaults to "search_param")
12175
12201
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
12176
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.
12177
12215
  * @param {object} [entryPoint.injections]
12178
12216
  * Values to inject into files, as { urlPattern: getInjections }.
12179
12217
  * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
@@ -12991,6 +13029,7 @@ const entryPointDefaultParams = {
12991
13029
  magicDirectoryIndex: undefined,
12992
13030
  directoryReferenceEffect: undefined,
12993
13031
  scenarioPlaceholders: undefined,
13032
+ ribbon: false,
12994
13033
  injections: undefined,
12995
13034
  transpilation: {},
12996
13035
  preserveComments: undefined,
@@ -13045,6 +13084,7 @@ const prepareEntryPointBuild = async (
13045
13084
  magicDirectoryIndex,
13046
13085
  directoryReferenceEffect,
13047
13086
  scenarioPlaceholders,
13087
+ ribbon,
13048
13088
  injections,
13049
13089
  transpilation,
13050
13090
  preserveComments,
@@ -13221,6 +13261,7 @@ const prepareEntryPointBuild = async (
13221
13261
  inlining: false,
13222
13262
  http,
13223
13263
  scenarioPlaceholders,
13264
+ ribbon,
13224
13265
  packageSideEffects,
13225
13266
  }),
13226
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,
@@ -1,4 +1,6 @@
1
- import { startServer, createFileSystemFetch, serverPluginCORS, jsenvAccessControlAllowedHeaders, serverPluginErrorHandler } from "@jsenv/server";
1
+ import { createFileSystemFetch, startServer, serverPluginCORS, jsenvAccessControlAllowedHeaders, serverPluginErrorHandler } from "@jsenv/server";
2
+ import { parseHtml, injectJsenvScript, stringifyHtmlAst } from "@jsenv/ast";
3
+ import { readFileSync } from "node:fs";
2
4
  import { createLogger, Abort, raceProcessTeardownEvents, createTaskLog } from "./jsenv_core_packages.js";
3
5
  import "./jsenv_core_node_modules.js";
4
6
  import "node:process";
@@ -6,6 +8,43 @@ import "node:os";
6
8
  import "node:tty";
7
9
  import "node:util";
8
10
 
11
+ /*
12
+ * Inject the ribbon into an html string, inlining the client code.
13
+ *
14
+ * Used when serving files that must not be modified on the filesystem (the build
15
+ * directory served by startBuildServer): the ribbon exists only in what the
16
+ * server sends, never in the artifact that gets deployed.
17
+ */
18
+
19
+
20
+ const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
21
+ let ribbonClientFileContent;
22
+
23
+ const injectRibbonIntoHtml = (
24
+ html,
25
+ { text = "BUILD", color, textColor, href, target, position } = {},
26
+ ) => {
27
+ if (ribbonClientFileContent === undefined) {
28
+ ribbonClientFileContent = String(
29
+ readFileSync(new URL(ribbonClientFileUrl)),
30
+ );
31
+ }
32
+ const htmlAst = parseHtml({ html, url: "file:///ribbon.html" });
33
+ const params = { text, color, textColor, href, target, position };
34
+ for (const key of Object.keys(params)) {
35
+ if (params[key] === undefined) {
36
+ delete params[key];
37
+ }
38
+ }
39
+ injectJsenvScript(htmlAst, {
40
+ type: "module",
41
+ content: `${ribbonClientFileContent}
42
+ injectRibbon(${JSON.stringify(params)});`,
43
+ pluginName: "jsenv:ribbon",
44
+ });
45
+ return stringifyHtmlAst(htmlAst);
46
+ };
47
+
9
48
  /*
10
49
  * startBuildServer is mean to interact with the build files;
11
50
  * files that will be deployed to production server(s).
@@ -26,11 +65,16 @@ import "node:util";
26
65
  * Start a server for build files.
27
66
  * @param {Object} buildServerParameters
28
67
  * @param {string|url} buildServerParameters.buildDirectoryUrl Directory where build files are written
68
+ * @param {boolean|object} [buildServerParameters.ribbon=false] Inject a ribbon in the html responses,
69
+ * marking the page as non-production. The build directory itself is left untouched.
70
+ * As an object: `text`, `color`, `textColor`, `href`, `target`, `position`
71
+ * (see startDevServer "ribbon" param).
29
72
  * @return {Object} A build server object
30
73
  */
31
74
  const startBuildServer = async ({
32
75
  buildDirectoryUrl,
33
76
  buildDirectoryMainFileRelativeUrl = "index.html",
77
+ ribbon = false,
34
78
  port = 9779,
35
79
  routes = [],
36
80
  serverPlugins = [],
@@ -46,6 +90,9 @@ const startBuildServer = async ({
46
90
  keepProcessAlive = true,
47
91
  }) => {
48
92
  const logger = createLogger({ logLevel });
93
+ if (ribbon === true) {
94
+ ribbon = {};
95
+ }
49
96
  const operation = Abort.startOperation();
50
97
  operation.addAbortSignal(signal);
51
98
  if (handleSIGINT) {
@@ -58,6 +105,9 @@ const startBuildServer = async ({
58
105
  });
59
106
  }
60
107
 
108
+ const fileSystemFetch = createFileSystemFetch(buildDirectoryUrl, {
109
+ mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
110
+ });
61
111
  const startBuildServerTask = createTaskLog("start build server", {
62
112
  disabled: !logger.levels.info,
63
113
  });
@@ -96,9 +146,9 @@ const startBuildServer = async ({
96
146
  {
97
147
  endpoint: "GET /",
98
148
  description: "Serve build files",
99
- fetch: createFileSystemFetch(buildDirectoryUrl, {
100
- mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
101
- }),
149
+ fetch: ribbon
150
+ ? withRibbonInjectedInHtml(fileSystemFetch, ribbon)
151
+ : fileSystemFetch,
102
152
  },
103
153
  ],
104
154
  });
@@ -120,4 +170,44 @@ const startBuildServer = async ({
120
170
  };
121
171
  };
122
172
 
173
+ const withRibbonInjectedInHtml = (fetch, ribbon) => {
174
+ return async (request, helpers) => {
175
+ const response = await fetch(request, helpers);
176
+ if (!response || response.status !== 200) {
177
+ return response;
178
+ }
179
+ const contentType = response.headers?.["content-type"];
180
+ if (!contentType || !contentType.startsWith("text/html")) {
181
+ return response;
182
+ }
183
+ const html = await readResponseBodyAsString(response.body);
184
+ const htmlWithRibbon = injectRibbonIntoHtml(html, ribbon);
185
+ const bodyBuffer = Buffer.from(htmlWithRibbon);
186
+ const headers = {
187
+ ...response.headers,
188
+ "content-length": bodyBuffer.length,
189
+ };
190
+ // the body is generated per request; what the file on disk hashes to or when
191
+ // it was modified does not describe it
192
+ delete headers.etag;
193
+ delete headers["last-modified"];
194
+ return {
195
+ ...response,
196
+ headers,
197
+ body: bodyBuffer,
198
+ };
199
+ };
200
+ };
201
+
202
+ const readResponseBodyAsString = async (body) => {
203
+ if (typeof body === "string" || Buffer.isBuffer(body)) {
204
+ return String(body);
205
+ }
206
+ const chunks = [];
207
+ for await (const chunk of body) {
208
+ chunks.push(Buffer.from(chunk));
209
+ }
210
+ return String(Buffer.concat(chunks));
211
+ };
212
+
123
213
  export { startBuildServer };
@@ -7258,6 +7258,12 @@ const jsenvPluginCustomElementsRedefine = () => {
7258
7258
  const jsenvPluginRibbon = ({
7259
7259
  rootDirectoryUrl,
7260
7260
  htmlInclude = "/**/*.html",
7261
+ text,
7262
+ color,
7263
+ textColor,
7264
+ href,
7265
+ target,
7266
+ position,
7261
7267
  }) => {
7262
7268
  const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
7263
7269
  const associations = URL_META.resolveAssociations(
@@ -7270,7 +7276,7 @@ const jsenvPluginRibbon = ({
7270
7276
  );
7271
7277
  return {
7272
7278
  name: "jsenv:ribbon",
7273
- appliesDuring: "dev",
7279
+ appliesDuring: "*",
7274
7280
  transformUrlContent: {
7275
7281
  html: (urlInfo) => {
7276
7282
  const jsenvToolbarHtmlClientFileUrl = urlInfo.context.getPluginMeta(
@@ -7305,9 +7311,19 @@ const jsenvPluginRibbon = ({
7305
7311
  src: ribbonClientFileReference.generatedSpecifier,
7306
7312
  initCall: {
7307
7313
  callee: "injectRibbon",
7308
- params: {
7309
- text: urlInfo.context.dev ? "DEV" : "BUILD",
7310
- },
7314
+ params: withoutUndefinedValues({
7315
+ text:
7316
+ text === undefined
7317
+ ? urlInfo.context.dev
7318
+ ? "DEV"
7319
+ : "BUILD"
7320
+ : text,
7321
+ color,
7322
+ textColor,
7323
+ href,
7324
+ target,
7325
+ position,
7326
+ }),
7311
7327
  },
7312
7328
  pluginName: "jsenv:ribbon",
7313
7329
  });
@@ -7317,6 +7333,16 @@ const jsenvPluginRibbon = ({
7317
7333
  };
7318
7334
  };
7319
7335
 
7336
+ const withoutUndefinedValues = (object) => {
7337
+ const objectWithoutUndefinedValues = {};
7338
+ for (const key of Object.keys(object)) {
7339
+ if (object[key] !== undefined) {
7340
+ objectWithoutUndefinedValues[key] = object[key];
7341
+ }
7342
+ }
7343
+ return objectWithoutUndefinedValues;
7344
+ };
7345
+
7320
7346
  /**
7321
7347
  * HTML page server by jsenv dev server will listen for drop events
7322
7348
  * and redirect the browser to the dropped file location.
@@ -11692,7 +11718,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11692
11718
  * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
11693
11719
  * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
11694
11720
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
11695
- * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
11721
+ * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
11696
11722
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11697
11723
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11698
11724
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.11",
3
+ "version": "41.4.12",
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.5",
76
- "@jsenv/js-module-fallback": "1.4.38",
75
+ "@jsenv/ast": "6.9.0",
76
+ "@jsenv/js-module-fallback": "1.4.39",
77
77
  "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.6",
79
- "@jsenv/plugin-supervisor": "1.8.9",
80
- "@jsenv/plugin-transpilation": "1.5.79",
78
+ "@jsenv/plugin-minification": "1.7.7",
79
+ "@jsenv/plugin-supervisor": "1.8.10",
80
+ "@jsenv/plugin-transpilation": "1.5.80",
81
81
  "@jsenv/server": "17.6.0",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
@@ -134,6 +134,18 @@ import { jsenvPluginMappings } from "./jsenv_plugin_mappings.js";
134
134
  * How URLs are versioned for this entry point (defaults to "search_param")
135
135
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
136
136
  * Sourcemap generation strategy for this entry point (defaults to "none")
137
+ * @param {boolean|object} [entryPoint.ribbon=false]
138
+ * Inject a ribbon marking the page as non-production. Disabled by default
139
+ * because the ribbon ends up inside the build directory: enable it on the
140
+ * builds meant for a preview/review app, not on the production build.
141
+ *
142
+ * ribbon: {
143
+ * text: "preview",
144
+ * color: "#7c3aed",
145
+ * href: "https://github.com/org/repo/pull/42",
146
+ * }
147
+ *
148
+ * See startDevServer "ribbon" param for the full list of options.
137
149
  * @param {object} [entryPoint.injections]
138
150
  * Values to inject into files, as { urlPattern: getInjections }.
139
151
  * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
@@ -953,6 +965,7 @@ const entryPointDefaultParams = {
953
965
  magicDirectoryIndex: undefined,
954
966
  directoryReferenceEffect: undefined,
955
967
  scenarioPlaceholders: undefined,
968
+ ribbon: false,
956
969
  injections: undefined,
957
970
  transpilation: {},
958
971
  preserveComments: undefined,
@@ -1007,6 +1020,7 @@ const prepareEntryPointBuild = async (
1007
1020
  magicDirectoryIndex,
1008
1021
  directoryReferenceEffect,
1009
1022
  scenarioPlaceholders,
1023
+ ribbon,
1010
1024
  injections,
1011
1025
  transpilation,
1012
1026
  preserveComments,
@@ -1183,6 +1197,7 @@ const prepareEntryPointBuild = async (
1183
1197
  inlining: false,
1184
1198
  http,
1185
1199
  scenarioPlaceholders,
1200
+ ribbon,
1186
1201
  packageSideEffects,
1187
1202
  }),
1188
1203
  ]);
@@ -23,15 +23,22 @@ import {
23
23
  startServer,
24
24
  } from "@jsenv/server";
25
25
 
26
+ import { injectRibbonIntoHtml } from "../plugins/ribbon/ribbon_html_injection.js";
27
+
26
28
  /**
27
29
  * Start a server for build files.
28
30
  * @param {Object} buildServerParameters
29
31
  * @param {string|url} buildServerParameters.buildDirectoryUrl Directory where build files are written
32
+ * @param {boolean|object} [buildServerParameters.ribbon=false] Inject a ribbon in the html responses,
33
+ * marking the page as non-production. The build directory itself is left untouched.
34
+ * As an object: `text`, `color`, `textColor`, `href`, `target`, `position`
35
+ * (see startDevServer "ribbon" param).
30
36
  * @return {Object} A build server object
31
37
  */
32
38
  export const startBuildServer = async ({
33
39
  buildDirectoryUrl,
34
40
  buildDirectoryMainFileRelativeUrl = "index.html",
41
+ ribbon = false,
35
42
  port = 9779,
36
43
  routes = [],
37
44
  serverPlugins = [],
@@ -47,6 +54,9 @@ export const startBuildServer = async ({
47
54
  keepProcessAlive = true,
48
55
  }) => {
49
56
  const logger = createLogger({ logLevel });
57
+ if (ribbon === true) {
58
+ ribbon = {};
59
+ }
50
60
  const operation = Abort.startOperation();
51
61
  operation.addAbortSignal(signal);
52
62
  if (handleSIGINT) {
@@ -60,6 +70,9 @@ export const startBuildServer = async ({
60
70
  });
61
71
  }
62
72
 
73
+ const fileSystemFetch = createFileSystemFetch(buildDirectoryUrl, {
74
+ mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
75
+ });
63
76
  const startBuildServerTask = createTaskLog("start build server", {
64
77
  disabled: !logger.levels.info,
65
78
  });
@@ -98,9 +111,9 @@ export const startBuildServer = async ({
98
111
  {
99
112
  endpoint: "GET /",
100
113
  description: "Serve build files",
101
- fetch: createFileSystemFetch(buildDirectoryUrl, {
102
- mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
103
- }),
114
+ fetch: ribbon
115
+ ? withRibbonInjectedInHtml(fileSystemFetch, ribbon)
116
+ : fileSystemFetch,
104
117
  },
105
118
  ],
106
119
  });
@@ -121,3 +134,43 @@ export const startBuildServer = async ({
121
134
  },
122
135
  };
123
136
  };
137
+
138
+ const withRibbonInjectedInHtml = (fetch, ribbon) => {
139
+ return async (request, helpers) => {
140
+ const response = await fetch(request, helpers);
141
+ if (!response || response.status !== 200) {
142
+ return response;
143
+ }
144
+ const contentType = response.headers?.["content-type"];
145
+ if (!contentType || !contentType.startsWith("text/html")) {
146
+ return response;
147
+ }
148
+ const html = await readResponseBodyAsString(response.body);
149
+ const htmlWithRibbon = injectRibbonIntoHtml(html, ribbon);
150
+ const bodyBuffer = Buffer.from(htmlWithRibbon);
151
+ const headers = {
152
+ ...response.headers,
153
+ "content-length": bodyBuffer.length,
154
+ };
155
+ // the body is generated per request; what the file on disk hashes to or when
156
+ // it was modified does not describe it
157
+ delete headers.etag;
158
+ delete headers["last-modified"];
159
+ return {
160
+ ...response,
161
+ headers,
162
+ body: bodyBuffer,
163
+ };
164
+ };
165
+ };
166
+
167
+ const readResponseBodyAsString = async (body) => {
168
+ if (typeof body === "string" || Buffer.isBuffer(body)) {
169
+ return String(body);
170
+ }
171
+ const chunks = [];
172
+ for await (const chunk of body) {
173
+ chunks.push(Buffer.from(chunk));
174
+ }
175
+ return String(Buffer.concat(chunks));
176
+ };
@@ -48,7 +48,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
48
48
  * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
49
49
  * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
50
50
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
51
- * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
51
+ * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
52
52
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
53
53
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
54
54
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
@@ -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
+ };