@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.
- package/dist/build/build.js +45 -4
- package/dist/client/ribbon/ribbon.js +120 -24
- package/dist/start_build_server/start_build_server.js +94 -4
- package/dist/start_dev_server/start_dev_server.js +31 -5
- package/package.json +6 -6
- package/src/build/build.js +15 -0
- package/src/build/start_build_server.js +56 -3
- package/src/dev/start_dev_server.js +1 -1
- package/src/plugins/ribbon/client/ribbon.js +120 -24
- package/src/plugins/ribbon/jsenv_plugin_ribbon.js +30 -4
- package/src/plugins/ribbon/ribbon_html_injection.js +38 -0
package/dist/build/build.js
CHANGED
|
@@ -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: "
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
background-color: orange;
|
|
78
|
+
background-color: ${color};
|
|
35
79
|
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
|
|
36
|
-
|
|
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
|
-
|
|
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, "&").replace(/"/g, """);
|
|
169
|
+
};
|
|
170
|
+
|
|
75
171
|
const appendIntoRespectingLineBreaksAndIndentation = (
|
|
76
172
|
node,
|
|
77
173
|
parentNode,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
100
|
-
|
|
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: "
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
76
|
-
"@jsenv/js-module-fallback": "1.4.
|
|
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.
|
|
79
|
-
"@jsenv/plugin-supervisor": "1.8.
|
|
80
|
-
"@jsenv/plugin-transpilation": "1.5.
|
|
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"
|
package/src/build/build.js
CHANGED
|
@@ -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:
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
background-color: orange;
|
|
78
|
+
background-color: ${color};
|
|
35
79
|
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
|
|
36
|
-
|
|
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
|
-
|
|
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, "&").replace(/"/g, """);
|
|
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: "
|
|
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:
|
|
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
|
+
};
|