DEV: Simplify ember-cli proxy strategy (#24242)

Previously, the app HTML served by the Ember-CLI proxy was generated based on a 'bootstrap json' payload generated by Rails. This inevitably leads to differences between the Rails HTML and the Ember-CLI HTML.

This commit overhauls our proxying strategy. Now, we totally ignore the ember-cli `index.html` file. Instead, we take the full HTML from Rails and surgically replace script URLs based on a `data-discourse-entrypoint` attribute. This should be faster (only one request to Rails), more robust, and less confusing for developers.
This commit is contained in:
David Taylor 2023-11-10 11:16:06 +00:00 committed by GitHub
parent 80208d0ab6
commit ac896755bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 348 additions and 710 deletions

View File

@ -1,279 +1,71 @@
"use strict"; "use strict";
const express = require("express"); const express = require("express");
const { encode } = require("html-entities");
const cleanBaseURL = require("clean-base-url"); const cleanBaseURL = require("clean-base-url");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const fsPromises = fs.promises; const fsPromises = fs.promises;
const { JSDOM } = require("jsdom"); const { JSDOM } = require("jsdom");
const { shouldLoadPlugins } = require("discourse-plugins");
const { Buffer } = require("node:buffer"); const { Buffer } = require("node:buffer");
const { cwd, env } = require("node:process"); const { env } = require("node:process");
const { glob } = require("glob");
// via https://stackoverflow.com/a/6248722/165668 async function listDistAssets() {
function generateUID() { const files = await glob("**/*.js", { nodir: true, cwd: "dist/assets" });
let firstPart = (Math.random() * 46656) | 0; // eslint-disable-line no-bitwise return new Set(files);
let secondPart = (Math.random() * 46656) | 0; // eslint-disable-line no-bitwise
firstPart = ("000" + firstPart.toString(36)).slice(-3);
secondPart = ("000" + secondPart.toString(36)).slice(-3);
return firstPart + secondPart;
} }
function htmlTag(buffer, bootstrap) { function updateScriptReferences({
let classList = ""; chunkInfos,
if (bootstrap.html_classes) { dom,
classList = ` class="${bootstrap.html_classes}"`; selector,
} attribute,
buffer.push(`<html lang="${bootstrap.html_lang}"${classList}>`); baseURL,
} distAssets,
}) {
const elements = dom.window.document.querySelectorAll(selector);
const handledEntrypoints = new Set();
function head(buffer, bootstrap, headers, baseURL) { for (const el of elements) {
if (bootstrap.csrf_token) { const entrypointName = el.dataset.discourseEntrypoint;
buffer.push(`<meta name="csrf-param" content="authenticity_token">`);
buffer.push(`<meta name="csrf-token" content="${bootstrap.csrf_token}">`);
}
if (bootstrap.theme_id) { if (handledEntrypoints.has(entrypointName)) {
buffer.push( el.remove();
`<meta name="discourse_theme_id" content="${bootstrap.theme_id}">` continue;
); }
}
if (bootstrap.theme_color) { let chunks = chunkInfos[`assets/${entrypointName}.js`]?.assets;
buffer.push(`<meta name="theme-color" content="${bootstrap.theme_color}">`);
}
if (bootstrap.authentication_data) { if (!chunks) {
buffer.push( if (distAssets.has(`${entrypointName}.js`)) {
`<meta id="data-authentication" data-authentication-data="${encode( chunks = [`assets/${entrypointName}.js`];
bootstrap.authentication_data
)}">`
);
}
let setupData = "";
Object.keys(bootstrap.setup_data).forEach((sd) => {
let val = bootstrap.setup_data[sd];
if (val) {
if (Array.isArray(val)) {
val = JSON.stringify(val);
} else { } else {
val = val.toString(); // Not an ember-cli asset, do not rewrite
continue;
} }
setupData += ` data-${sd.replace(/\_/g, "-")}="${encode(val)}"`;
}
});
buffer.push(`<meta id="data-discourse-setup"${setupData} />`);
if (bootstrap.preloaded.currentUser) {
const user = JSON.parse(bootstrap.preloaded.currentUser);
let { admin, staff } = user;
if (staff) {
buffer.push(`<script defer src="${baseURL}assets/admin.js"></script>`);
} }
if (admin) { const newElements = chunks.map((chunk) => {
buffer.push(`<script defer src="${baseURL}assets/wizard.js"></script>`); const newElement = el.cloneNode(true);
} newElement[attribute] = `${baseURL}${chunk}`;
} newElement.dataset.emberCliRewritten = "true";
bootstrap.plugin_js.forEach((src) => return newElement;
buffer.push(`<script defer src="${src}"></script>`) });
);
buffer.push(bootstrap.theme_html.translations); if (
buffer.push(bootstrap.theme_html.js); entrypointName === "discourse" &&
buffer.push(bootstrap.theme_html.head_tag); el.tagName.toLowerCase() === "script"
buffer.push(bootstrap.html.before_head_close); ) {
} const liveReload = dom.window.document.createElement("script");
liveReload.setAttribute("async", "");
function localeScript(buffer, bootstrap) { liveReload.src = `${baseURL}ember-cli-live-reload.js`;
buffer.push(`<script defer src="${bootstrap.locale_script}"></script>`); newElements.unshift(liveReload);
(bootstrap.extra_locales || []).forEach((l) =>
buffer.push(`<script defer src="${l}"></script>`)
);
}
function beforeScriptLoad(buffer, bootstrap) {
buffer.push(bootstrap.html.before_script_load);
}
function discoursePreloadStylesheets(buffer, bootstrap) {
(bootstrap.stylesheets || []).forEach((s) => {
let link = `<link rel="preload" as="style" href="${s.href}">`;
buffer.push(link);
});
}
function discourseStylesheets(buffer, bootstrap) {
(bootstrap.stylesheets || []).forEach((s) => {
let attrs = [];
if (s.media) {
attrs.push(`media="${s.media}"`);
}
if (s.target) {
attrs.push(`data-target="${s.target}"`);
}
if (s.theme_id) {
attrs.push(`data-theme-id="${s.theme_id}"`);
}
if (s.class) {
attrs.push(`class="${s.class}"`);
}
let link = `<link rel="stylesheet" type="text/css" href="${
s.href
}" ${attrs.join(" ")}>`;
buffer.push(link);
});
}
function body(buffer, bootstrap) {
buffer.push(bootstrap.theme_html.header);
buffer.push(bootstrap.html.header);
}
function bodyFooter(buffer, bootstrap, headers) {
buffer.push(bootstrap.theme_html.body_tag);
buffer.push(bootstrap.html.before_body_close);
let v = generateUID();
buffer.push(`
<script
async
type="text/javascript"
id="mini-profiler"
src="/mini-profiler-resources/includes.js?v=${v}"
data-css-url="/mini-profiler-resources/includes.css?v=${v}"
data-version="${v}"
data-path="/mini-profiler-resources/"
data-horizontal-position="right"
data-vertical-position="top"
data-trivial="false"
data-children="false"
data-max-traces="20"
data-controls="false"
data-total-sql-count="false"
data-authorized="true"
data-toggle-shortcut="alt+p"
data-start-hidden="false"
data-collapse-results="true"
data-html-container="body"
data-hidden-custom-fields="x"
data-ids="${headers.get("x-miniprofiler-ids")}"
></script>
`);
}
function hiddenLoginForm(buffer, bootstrap) {
if (!bootstrap.preloaded.currentUser) {
buffer.push(`
<form id='hidden-login-form' method="post" action="${bootstrap.login_path}" style="display: none;">
<input name="username" type="text" id="signin_username">
<input name="password" type="password" id="signin_password">
<input name="redirect" type="hidden">
<input type="submit" id="signin-button">
</form>
`);
}
}
function preloaded(buffer, bootstrap) {
buffer.push(
`<div class="hidden" id="data-preloaded" data-preloaded="${encode(
JSON.stringify(bootstrap.preloaded)
)}"></div>`
);
}
const BUILDERS = {
"html-tag": htmlTag,
"before-script-load": beforeScriptLoad,
"discourse-preload-stylesheets": discoursePreloadStylesheets,
head,
body,
"discourse-stylesheets": discourseStylesheets,
"hidden-login-form": hiddenLoginForm,
preloaded,
"body-footer": bodyFooter,
"locale-script": localeScript,
};
function replaceIn(bootstrap, template, id, headers, baseURL) {
let buffer = [];
BUILDERS[id](buffer, bootstrap, headers, baseURL);
let contents = buffer.filter((b) => b && b.length > 0).join("\n");
if (id === "html-tag") {
return template.replace(`<html>`, contents);
} else {
return template.replace(`<!-- bootstrap-content ${id} -->`, contents);
}
}
function extractPreloadJson(html) {
const dom = new JSDOM(html);
const dataElement = dom.window.document.querySelector("#data-preloaded");
if (!dataElement || !dataElement.dataset) {
return;
}
return dataElement.dataset.preloaded;
}
async function applyBootstrap(bootstrap, template, response, baseURL, preload) {
bootstrap.preloaded = Object.assign(JSON.parse(preload), bootstrap.preloaded);
Object.keys(BUILDERS).forEach((id) => {
template = replaceIn(bootstrap, template, id, response.headers, baseURL);
});
return template;
}
async function buildFromBootstrap(proxy, baseURL, req, response, preload) {
try {
const template = await fsPromises.readFile(
path.join(cwd(), "dist", "index.html"),
"utf8"
);
let url = new URL(`${proxy}${baseURL}bootstrap.json`);
url.searchParams.append("for_url", req.url);
const forUrlSearchParams = new URL(req.url, "https://dummy-origin.invalid")
.searchParams;
const mobileView = forUrlSearchParams.get("mobile_view");
if (mobileView) {
url.searchParams.append("mobile_view", mobileView);
} }
const reqUrlSafeMode = forUrlSearchParams.get("safe_mode"); el.replaceWith(...newElements);
if (reqUrlSafeMode) {
url.searchParams.append("safe_mode", reqUrlSafeMode);
}
const navigationMenu = forUrlSearchParams.get("navigation_menu"); handledEntrypoints.add(entrypointName);
if (navigationMenu) {
url.searchParams.append("navigation_menu", navigationMenu);
}
const reqUrlPreviewThemeId = forUrlSearchParams.get("preview_theme_id");
if (reqUrlPreviewThemeId) {
url.searchParams.append("preview_theme_id", reqUrlPreviewThemeId);
}
const { default: fetch } = await import("node-fetch");
const res = await fetch(url, { headers: req.headers });
const json = await res.json();
return applyBootstrap(json.bootstrap, template, response, baseURL, preload);
} catch (error) {
throw new Error(
`Could not get ${proxy}${baseURL}bootstrap.json\n\n${error}`
);
} }
} }
@ -364,22 +156,35 @@ async function handleRequest(proxy, baseURL, req, res) {
res.status(response.status); res.status(response.status);
if (isHTML) { if (isHTML) {
const responseText = await response.text(); const [responseText, chunkInfoText, distAssets] = await Promise.all([
const preloadJson = isHTML ? extractPreloadJson(responseText) : null; response.text(),
fsPromises.readFile("dist/assets.json", "utf-8"),
listDistAssets(),
]);
if (preloadJson) { const chunkInfos = JSON.parse(chunkInfoText);
const html = await buildFromBootstrap(
proxy, const dom = new JSDOM(responseText);
baseURL,
req, updateScriptReferences({
response, chunkInfos,
extractPreloadJson(responseText) dom,
); selector: "script[data-discourse-entrypoint]",
res.set("content-type", "text/html"); attribute: "src",
res.send(html); baseURL,
} else { distAssets,
res.send(responseText); });
}
updateScriptReferences({
chunkInfos,
dom,
selector: "link[rel=preload][data-discourse-entrypoint]",
attribute: "href",
baseURL,
distAssets,
});
res.send(dom.serialize());
} else { } else {
res.send(Buffer.from(await response.arrayBuffer())); res.send(Buffer.from(await response.arrayBuffer()));
} }
@ -392,63 +197,6 @@ module.exports = {
return true; return true;
}, },
contentFor(type, config) {
if (shouldLoadPlugins() && type === "test-plugin-js") {
const scripts = [];
const pluginInfos = this.app.project
.findAddonByName("discourse-plugins")
.pluginInfos();
for (const {
pluginName,
directoryName,
hasJs,
hasAdminJs,
} of pluginInfos) {
if (hasJs) {
scripts.push({
src: `plugins/${directoryName}.js`,
name: pluginName,
});
}
if (fs.existsSync(`../plugins/${directoryName}_extras.js.erb`)) {
scripts.push({
src: `plugins/${directoryName}_extras.js`,
name: pluginName,
});
}
if (hasAdminJs) {
scripts.push({
src: `plugins/${directoryName}_admin.js`,
name: pluginName,
});
}
}
return scripts
.map(
({ src, name }) =>
`<script src="${config.rootURL}assets/${src}" data-discourse-plugin="${name}"></script>`
)
.join("\n");
} else if (shouldLoadPlugins() && type === "test-plugin-tests-js") {
return this.app.project
.findAddonByName("discourse-plugins")
.pluginInfos()
.filter(({ hasTests }) => hasTests)
.map(
({ directoryName, pluginName }) =>
`<script src="${config.rootURL}assets/plugins/test/${directoryName}_tests.js" data-discourse-plugin="${pluginName}"></script>`
)
.join("\n");
} else if (shouldLoadPlugins() && type === "test-plugin-css") {
return `<link rel="stylesheet" href="${config.rootURL}bootstrap/plugin-css-for-tests.css" data-discourse-plugin="_all" />`;
}
},
serverMiddleware(config) { serverMiddleware(config) {
const app = config.app; const app = config.app;
let { proxy, rootURL, baseURL } = config.options; let { proxy, rootURL, baseURL } = config.options;

View File

@ -1,7 +1,7 @@
{ {
"name": "bootstrap-json", "name": "bootstrap-json",
"version": "1.0.0", "version": "1.0.0",
"description": "Express.js middleware that proxies ember cli requests and fetches bootstrap json", "description": "Express.js middleware which injects ember-cli asset URLs into Discourse's HTML",
"author": "Discourse", "author": "Discourse",
"license": "GPL-2.0-only", "license": "GPL-2.0-only",
"keywords": [ "keywords": [
@ -16,8 +16,8 @@
}, },
"devDependencies": { "devDependencies": {
"clean-base-url": "^1.0.0", "clean-base-url": "^1.0.0",
"discourse-plugins": "1.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"glob": "^10.3.10",
"html-entities": "^2.4.0", "html-entities": "^2.4.0",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"

View File

@ -267,4 +267,72 @@ module.exports = {
return true; return true;
} }
}, },
pluginScriptTags(config) {
const scripts = [];
const pluginInfos = this.pluginInfos();
for (const {
pluginName,
directoryName,
hasJs,
hasAdminJs,
} of pluginInfos) {
if (hasJs) {
scripts.push({
src: `plugins/${directoryName}.js`,
name: pluginName,
});
}
if (fs.existsSync(`../plugins/${directoryName}_extras.js.erb`)) {
scripts.push({
src: `plugins/${directoryName}_extras.js`,
name: pluginName,
});
}
if (hasAdminJs) {
scripts.push({
src: `plugins/${directoryName}_admin.js`,
name: pluginName,
});
}
}
return scripts
.map(
({ src, name }) =>
`<script src="${config.rootURL}assets/${src}" data-discourse-plugin="${name}"></script>`
)
.join("\n");
},
pluginTestScriptTags(config) {
return this.pluginInfos()
.filter(({ hasTests }) => hasTests)
.map(
({ directoryName, pluginName }) =>
`<script src="${config.rootURL}assets/plugins/test/${directoryName}_tests.js" data-discourse-plugin="${pluginName}"></script>`
)
.join("\n");
},
contentFor(type, config) {
if (!this.shouldLoadPlugins()) {
return;
}
switch (type) {
case "test-plugin-js":
return this.pluginScriptTags(config);
case "test-plugin-tests-js":
return this.pluginTestScriptTags(config);
case "test-plugin-css":
return `<link rel="stylesheet" href="${config.rootURL}bootstrap/plugin-css-for-tests.css" data-discourse-plugin="_all" />`;
}
},
}; };

View File

@ -2,57 +2,23 @@
<html> <html>
<head> <head>
<!-- <!--
👋 Greetings Discourse Developer. This HTML was generated by the ember-cli proxy. If you're looking for 👋 Greetings Discourse Developer. This html file is used by ember-cli/embroider to define our JS entrypoints,
<head> content generated by Rails, you'll need to start the server with `ALLOW_EMBER_CLI_PROXY_BYPASS=1` but it is never actually used in development or production. Instead, we generate an `assets.json` file which
and then visit the Rails port (e.g. `localhost:3000`) directly. Be sure to keep ember-cli running so is ingested by Rails and used to generate the correct <script> tags.
that JS assets continue to be re-compiled when changes are made.
When ember-cli is used as a proxy, we use the rails-generated HTML and replace urls in script/link tags
with the local ember-cli versions.
--> -->
<meta charset="utf-8"> <meta charset="utf-8">
<title>Discourse - Ember CLI</title> <title>Discourse - Ember CLI</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<!-- bootstrap-content before-script-load -->
{{content-for "before-script-load"}}
<!-- bootstrap-content discourse-preload-stylesheets -->
{{content-for "discourse-preload-stylesheets"}}
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css" /> <link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css" />
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/discourse.css" /> <link integrity="" rel="stylesheet" href="{{rootURL}}assets/discourse.css" />
<!-- bootstrap-content head -->
{{content-for "head"}}
<script defer src="{{rootURL}}assets/vendor.js"></script> <script defer src="{{rootURL}}assets/vendor.js"></script>
<script defer src="{{rootURL}}assets/discourse.js"></script> <script defer src="{{rootURL}}assets/discourse.js"></script>
<!-- bootstrap-content locale-script -->
</head> </head>
<body> <body>
<discourse-assets>
<discourse-assets-stylesheets>
<!-- bootstrap-content discourse-stylesheets -->
{{content-for "discourse-stylesheets"}}
</discourse-assets-stylesheets>
<discourse-assets-json>
<!-- bootstrap-content preloaded -->
</discourse-assets-json>
<discourse-assets-icons></discourse-assets-icons>
</discourse-assets>
<!-- bootstrap-content body -->
{{content-for "body"}}
<section id='main'>
</section>
<!-- bootstrap-content hidden-login-form -->
<script defer src="{{rootURL}}assets/start-discourse.js" data-embroider-ignore></script>
<!-- bootstrap-content body-footer -->
{{content-for "body-footer"}}
</body> </body>
</html> </html>

View File

@ -1470,6 +1470,18 @@
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5"
integrity sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA== integrity sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
dependencies:
string-width "^5.1.2"
string-width-cjs "npm:string-width@^4.2.0"
strip-ansi "^7.0.1"
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
wrap-ansi "^8.1.0"
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
version "0.3.3" version "0.3.3"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
@ -1536,6 +1548,11 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@pkgjs/parseargs@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@popperjs/core@^2.11.8": "@popperjs/core@^2.11.8":
version "2.11.8" version "2.11.8"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
@ -2372,6 +2389,11 @@ ansi-regex@^5.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-regex@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
ansi-styles@^2.2.1: ansi-styles@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
@ -2391,6 +2413,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies: dependencies:
color-convert "^2.0.1" color-convert "^2.0.1"
ansi-styles@^6.1.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
ansi-to-html@^0.6.15, ansi-to-html@^0.6.6: ansi-to-html@^0.6.15, ansi-to-html@^0.6.6:
version "0.6.15" version "0.6.15"
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.15.tgz#ac6ad4798a00f6aa045535d7f6a9cb9294eebea7" resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.15.tgz#ac6ad4798a00f6aa045535d7f6a9cb9294eebea7"
@ -4607,6 +4634,11 @@ dot-prop@^5.2.0:
dependencies: dependencies:
is-obj "^2.0.0" is-obj "^2.0.0"
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
editions@^1.1.1: editions@^1.1.1:
version "1.3.4" version "1.3.4"
resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b"
@ -5344,6 +5376,11 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
emoji-regex@^9.2.2:
version "9.2.2"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
emojis-list@^3.0.0: emojis-list@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
@ -6111,6 +6148,14 @@ for-in@^1.0.2:
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==
foreground-child@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d"
integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==
dependencies:
cross-spawn "^7.0.0"
signal-exit "^4.0.1"
form-data@^3.0.0: form-data@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
@ -6395,6 +6440,17 @@ glob-to-regexp@^0.4.1:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@^10.3.10:
version "10.3.10"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b"
integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==
dependencies:
foreground-child "^3.1.0"
jackspeak "^2.3.5"
minimatch "^9.0.1"
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
path-scurry "^1.10.1"
glob@^5.0.10: glob@^5.0.10:
version "5.0.15" version "5.0.15"
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
@ -7344,6 +7400,15 @@ istextorbinary@^2.5.1:
editions "^2.2.0" editions "^2.2.0"
textextensions "^2.5.0" textextensions "^2.5.0"
jackspeak@^2.3.5:
version "2.3.6"
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8"
integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==
dependencies:
"@isaacs/cliui" "^8.0.2"
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
jest-worker@^27.4.5: jest-worker@^27.4.5:
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0"
@ -7904,6 +7969,11 @@ lru-cache@^7.5.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
"lru-cache@^9.1.1 || ^10.0.0":
version "10.0.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a"
integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==
magic-string@^0.25.7: magic-string@^0.25.7:
version "0.25.9" version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -8157,6 +8227,13 @@ minimatch@^7.4.3:
dependencies: dependencies:
brace-expansion "^2.0.1" brace-expansion "^2.0.1"
minimatch@^9.0.1:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@~0.2.11: minimatch@~0.2.11:
version "0.2.14" version "0.2.14"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a"
@ -8178,6 +8255,11 @@ minipass@^2.2.0:
safe-buffer "^5.1.2" safe-buffer "^5.1.2"
yallist "^3.0.0" yallist "^3.0.0"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0":
version "7.0.4"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
mixin-deep@^1.2.0: mixin-deep@^1.2.0:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@ -8789,6 +8871,14 @@ path-root@^0.1.1:
dependencies: dependencies:
path-root-regex "^0.1.0" path-root-regex "^0.1.0"
path-scurry@^1.10.1:
version "1.10.1"
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698"
integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==
dependencies:
lru-cache "^9.1.1 || ^10.0.0"
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
path-to-regexp@0.1.7: path-to-regexp@0.1.7:
version "0.1.7" version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
@ -9683,6 +9773,11 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
signal-exit@^4.0.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
silent-error@^1.0.0, silent-error@^1.0.1, silent-error@^1.1.1: silent-error@^1.0.0, silent-error@^1.0.1, silent-error@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/silent-error/-/silent-error-1.1.1.tgz#f72af5b0d73682a2ba1778b7e32cd8aa7c2d8662" resolved "https://registry.yarnpkg.com/silent-error/-/silent-error-1.1.1.tgz#f72af5b0d73682a2ba1778b7e32cd8aa7c2d8662"
@ -9939,7 +10034,7 @@ string-template@~0.2.0, string-template@~0.2.1:
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -9956,6 +10051,15 @@ string-width@^2.1.0:
is-fullwidth-code-point "^2.0.0" is-fullwidth-code-point "^2.0.0"
strip-ansi "^4.0.0" strip-ansi "^4.0.0"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
dependencies:
eastasianwidth "^0.2.0"
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
string.prototype.matchall@^4.0.5: string.prototype.matchall@^4.0.5:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz#3bf85722021816dcd1bf38bb714915887ca79fd3" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz#3bf85722021816dcd1bf38bb714915887ca79fd3"
@ -10009,6 +10113,13 @@ string_decoder@^1.1.1:
dependencies: dependencies:
safe-buffer "~5.2.0" safe-buffer "~5.2.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^3.0.0: strip-ansi@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
@ -10030,12 +10141,12 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies: dependencies:
ansi-regex "^4.1.0" ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1: strip-ansi@^7.0.1:
version "6.0.1" version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
dependencies: dependencies:
ansi-regex "^5.0.1" ansi-regex "^6.0.1"
strip-bom@^4.0.0: strip-bom@^4.0.0:
version "4.0.0" version "4.0.0"
@ -11020,6 +11131,16 @@ workerpool@^6.0.0, workerpool@^6.0.2, workerpool@^6.4.0:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.1.tgz#1398eb5f8f44fb2d21ed9225cf34bb0131504c1d" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.1.tgz#1398eb5f8f44fb2d21ed9225cf34bb0131504c1d"
integrity sha512-zIK7qRgM1Mk+ySxOJl7ZpjX6SlKt5gugxzl8eXHPdbpXX8iDAaVIxYJz4Apn6JdDxP2buY/Ekqg0bOLNSf0u0g== integrity sha512-zIK7qRgM1Mk+ySxOJl7ZpjX6SlKt5gugxzl8eXHPdbpXX8iDAaVIxYJz4Apn6JdDxP2buY/Ekqg0bOLNSf0u0g==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^6.0.1: wrap-ansi@^6.0.1:
version "6.2.0" version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@ -11029,14 +11150,14 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0: wrap-ansi@^8.1.0:
version "7.0.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
dependencies: dependencies:
ansi-styles "^4.0.0" ansi-styles "^6.1.0"
string-width "^4.1.0" string-width "^5.0.1"
strip-ansi "^6.0.0" strip-ansi "^7.0.1"
wrappy@1: wrappy@1:
version "1.0.2" version "1.0.2"

View File

@ -1,103 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class BootstrapController < ApplicationController class BootstrapController < ApplicationController
include ApplicationHelper
skip_before_action :redirect_to_login_if_required, :check_xhr skip_before_action :redirect_to_login_if_required, :check_xhr
# This endpoint allows us to produce the data required to start up Discourse via JSON API,
# so that you don't have to scrape the HTML for `data-*` payloads
def index
locale = script_asset_path("locales/#{I18n.locale}")
preload_anonymous_data
if current_user
current_user.sync_notification_channel_position
preload_current_user_data
end
@stylesheets = []
add_scheme(scheme_id, "all", "light-scheme")
add_scheme(dark_scheme_id, "(prefers-color-scheme: dark)", "dark-scheme")
if rtl?
add_style(mobile_view? ? :mobile_rtl : :desktop_rtl)
else
add_style(mobile_view? ? :mobile : :desktop)
end
add_style(rtl? ? :admin_rtl : :admin) if staff?
add_style(rtl? ? :wizard_rtl : :wizard) if admin?
assets_fake_request = ActionDispatch::Request.new(request.env.dup)
assets_for_url = params[:for_url]
if assets_for_url
path, query = assets_for_url.split("?", 2)
assets_fake_request.env["PATH_INFO"] = path
assets_fake_request.env["QUERY_STRING"] = query
end
Discourse
.find_plugin_css_assets(
include_official: allow_plugins?,
include_unofficial: allow_third_party_plugins?,
mobile_view: mobile_view?,
desktop_view: !mobile_view?,
request: assets_fake_request,
rtl: rtl?,
)
.each { |file| add_style(file, plugin: true) }
add_style(mobile_view? ? :mobile_theme : :desktop_theme) if theme_id.present?
extra_locales = []
if ExtraLocalesController.client_overrides_exist?
extra_locales << ExtraLocalesController.url("overrides")
end
extra_locales << ExtraLocalesController.url("admin") if staff?
extra_locales << ExtraLocalesController.url("wizard") if admin?
plugin_js =
Discourse
.find_plugin_js_assets(
include_official: allow_plugins?,
include_unofficial: allow_third_party_plugins?,
request: assets_fake_request,
)
.map { |f| script_asset_path(f) }
plugin_test_js =
if Rails.env != "production"
script_asset_path("plugin-tests")
else
[]
end
bootstrap = {
theme_id: theme_id,
theme_color: "##{ColorScheme.hex_for_name("header_background", scheme_id)}",
title: SiteSetting.title,
current_homepage: current_homepage,
locale_script: locale,
stylesheets: @stylesheets,
plugin_js: plugin_js,
plugin_test_js: plugin_test_js,
setup_data: client_side_setup_data,
preloaded: @preloaded,
html: create_html,
theme_html: create_theme_html,
html_classes: html_classes,
html_lang: html_lang,
login_path: main_app.login_path,
authentication_data: authentication_data,
}
bootstrap[:extra_locales] = extra_locales if extra_locales.present?
bootstrap[:csrf_token] = form_authenticity_token if current_user
render_json_dump(bootstrap: bootstrap)
end
def plugin_css_for_tests def plugin_css_for_tests
urls = urls =
Discourse Discourse
@ -114,74 +19,4 @@ class BootstrapController < ApplicationController
render plain: stylesheet, content_type: "text/css" render plain: stylesheet, content_type: "text/css"
end end
private
def add_scheme(scheme_id, media, css_class)
return if scheme_id.to_i == -1
if style =
Stylesheet::Manager.new(theme_id: theme_id).color_scheme_stylesheet_details(
scheme_id,
media,
)
@stylesheets << { href: style[:new_href], media: media, class: css_class }
end
end
def add_style(target, opts = nil)
if styles = Stylesheet::Manager.new(theme_id: theme_id).stylesheet_details(target, "all")
styles.each do |style|
@stylesheets << {
href: style[:new_href],
media: "all",
theme_id: style[:theme_id],
target: style[:target],
}.merge(opts || {})
end
end
end
def create_html
html = {}
return html unless allow_plugins?
add_plugin_html(html, :before_body_close)
add_plugin_html(html, :before_head_close)
add_plugin_html(html, :before_script_load)
add_plugin_html(html, :header)
html
end
def add_plugin_html(html, key)
add_if_present(
html,
key,
DiscoursePluginRegistry.build_html("server:#{key.to_s.dasherize}", self),
)
end
def create_theme_html
theme_html = {}
return theme_html if customization_disabled?
theme_view = mobile_view? ? :mobile : :desktop
add_if_present(theme_html, :body_tag, Theme.lookup_field(theme_id, theme_view, "body_tag"))
add_if_present(theme_html, :head_tag, Theme.lookup_field(theme_id, theme_view, "head_tag"))
add_if_present(theme_html, :header, Theme.lookup_field(theme_id, theme_view, "header"))
add_if_present(
theme_html,
:translations,
Theme.lookup_field(theme_id, :translations, I18n.locale),
)
add_if_present(theme_html, :js, Theme.lookup_field(theme_id, :extra_js, nil))
theme_html
end
def add_if_present(hash, key, val)
hash[key] = val if val.present?
end
end end

View File

@ -142,22 +142,24 @@ module ApplicationHelper
scripts scripts
.map do |name| .map do |name|
path = script_asset_path(name) path = script_asset_path(name)
preload_script_url(path) preload_script_url(path, entrypoint: script)
end end
.join("\n") .join("\n")
.html_safe .html_safe
end end
def preload_script_url(url) def preload_script_url(url, entrypoint: nil)
entrypoint_attribute = entrypoint ? "data-discourse-entrypoint=\"#{entrypoint}\"" : ""
add_resource_preload_list(url, "script") add_resource_preload_list(url, "script")
if GlobalSetting.preload_link_header if GlobalSetting.preload_link_header
<<~HTML.html_safe <<~HTML.html_safe
<script defer src="#{url}"></script> <script defer src="#{url}" #{entrypoint_attribute}></script>
HTML HTML
else else
<<~HTML.html_safe <<~HTML.html_safe
<link rel="preload" href="#{url}" as="script"> <link rel="preload" href="#{url}" as="script" #{entrypoint_attribute}>
<script defer src="#{url}"></script> <script defer src="#{url}" #{entrypoint_attribute}></script>
HTML HTML
end end
end end

View File

@ -27,8 +27,6 @@ Discourse::Application.routes.draw do
match "/404", to: "exceptions#not_found", via: %i[get post] match "/404", to: "exceptions#not_found", via: %i[get post]
get "/404-body" => "exceptions#not_found_body" get "/404-body" => "exceptions#not_found_body"
get "/bootstrap" => "bootstrap#index"
if Rails.env.test? || Rails.env.development? if Rails.env.test? || Rails.env.development?
get "/bootstrap/plugin-css-for-tests.css" => "bootstrap#plugin_css_for_tests" get "/bootstrap/plugin-css-for-tests.css" => "bootstrap#plugin_css_for_tests"
end end

View File

@ -2,19 +2,18 @@
class EmberCli < ActiveSupport::CurrentAttributes class EmberCli < ActiveSupport::CurrentAttributes
# Cache which persists for the duration of a request # Cache which persists for the duration of a request
attribute :request_cached_script_chunks attribute :request_cache
def self.dist_dir def self.dist_dir
"#{Rails.root}/app/assets/javascripts/discourse/dist" "#{Rails.root}/app/assets/javascripts/discourse/dist"
end end
def self.assets def self.assets
@assets ||= Dir.glob("**/*.{js,map,txt}", base: "#{dist_dir}/assets") cache[:assets] ||= Dir.glob("**/*.{js,map,txt}", base: "#{dist_dir}/assets")
end end
def self.script_chunks def self.script_chunks
return @production_chunk_infos if @production_chunk_infos return cache[:script_chunks] if cache[:script_chunks]
return self.request_cached_script_chunks if self.request_cached_script_chunks
chunk_infos = JSON.parse(File.read("#{dist_dir}/assets.json")) chunk_infos = JSON.parse(File.read("#{dist_dir}/assets.json"))
@ -30,8 +29,7 @@ class EmberCli < ActiveSupport::CurrentAttributes
chunk_infos["vendor"] = [fingerprinted.delete_suffix(".js")] chunk_infos["vendor"] = [fingerprinted.delete_suffix(".js")]
end end
@production_chunk_infos = chunk_infos if Rails.env.production? cache[:script_chunks] = chunk_infos
self.request_cached_script_chunks = chunk_infos
rescue Errno::ENOENT rescue Errno::ENOENT
{} {}
end end
@ -62,9 +60,16 @@ class EmberCli < ActiveSupport::CurrentAttributes
File.exist?("#{dist_dir}/tests/index.html") File.exist?("#{dist_dir}/tests/index.html")
end end
def self.cache
if Rails.env.development?
self.request_cache ||= {}
else
@production_cache ||= {}
end
end
def self.clear_cache! def self.clear_cache!
@production_chunk_infos = nil self.request.cache = nil
@assets = nil @production_cache = nil
self.request_cached_script_chunks = nil
end end
end end

View File

@ -3,10 +3,10 @@
RSpec.describe ApplicationHelper do RSpec.describe ApplicationHelper do
describe "preload_script" do describe "preload_script" do
def script_tag(url) def script_tag(url, entrypoint)
<<~HTML <<~HTML
<link rel="preload" href="#{url}" as="script"> <link rel="preload" href="#{url}" as="script" data-discourse-entrypoint="#{entrypoint}">
<script defer src="#{url}"></script> <script defer src="#{url}" data-discourse-entrypoint="#{entrypoint}"></script>
HTML HTML
end end
@ -57,33 +57,44 @@ RSpec.describe ApplicationHelper do
helper.request.env["HTTP_ACCEPT_ENCODING"] = "br" helper.request.env["HTTP_ACCEPT_ENCODING"] = "br"
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq(script_tag("https://s3cdn.com/assets/start-discourse.br.js")) expect(link).to eq(
script_tag("https://s3cdn.com/assets/start-discourse.br.js", "start-discourse"),
)
end end
it "gives s3 cdn if asset host is not set" do it "gives s3 cdn if asset host is not set" do
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq(script_tag("https://s3cdn.com/assets/start-discourse.js")) expect(link).to eq(
script_tag("https://s3cdn.com/assets/start-discourse.js", "start-discourse"),
)
end end
it "can fall back to gzip compression" do it "can fall back to gzip compression" do
helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip" helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip"
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq(script_tag("https://s3cdn.com/assets/start-discourse.gz.js")) expect(link).to eq(
script_tag("https://s3cdn.com/assets/start-discourse.gz.js", "start-discourse"),
)
end end
it "gives s3 cdn even if asset host is set" do it "gives s3 cdn even if asset host is set" do
set_cdn_url "https://awesome.com" set_cdn_url "https://awesome.com"
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq(script_tag("https://s3cdn.com/assets/start-discourse.js")) expect(link).to eq(
script_tag("https://s3cdn.com/assets/start-discourse.js", "start-discourse"),
)
end end
it "gives s3 cdn but without brotli/gzip extensions for theme tests assets" do it "gives s3 cdn but without brotli/gzip extensions for theme tests assets" do
helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip, br" helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip, br"
link = helper.preload_script("discourse/tests/theme_qunit_ember_jquery") link = helper.preload_script("discourse/tests/theme_qunit_ember_jquery")
expect(link).to eq( expect(link).to eq(
script_tag("https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js"), script_tag(
"https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js",
"discourse/tests/theme_qunit_ember_jquery",
),
) )
end end

View File

@ -900,6 +900,13 @@ RSpec.describe ApplicationController do
{ HTTP_ACCEPT_LANGUAGE: locale } { HTTP_ACCEPT_LANGUAGE: locale }
end end
def locale_scripts(body)
Nokogiri::HTML5
.parse(body)
.css('script[src*="assets/locales/"]')
.map { |script| script.attributes["src"].value }
end
context "with allow_user_locale disabled" do context "with allow_user_locale disabled" do
context "when accept-language header differs from default locale" do context "when accept-language header differs from default locale" do
before do before do
@ -909,9 +916,9 @@ RSpec.describe ApplicationController do
context "with an anonymous user" do context "with an anonymous user" do
it "uses the default locale" do it "uses the default locale" do
get "/bootstrap.json", headers: headers("fr") get "/latest", headers: headers("fr")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("en.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/en.js")
end end
end end
@ -920,9 +927,9 @@ RSpec.describe ApplicationController do
user = Fabricate(:user, locale: :fr) user = Fabricate(:user, locale: :fr)
sign_in(user) sign_in(user)
get "/bootstrap.json", headers: headers("fr") get "/latest", headers: headers("fr")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("en.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/en.js")
end end
end end
end end
@ -938,15 +945,15 @@ RSpec.describe ApplicationController do
context "with an anonymous user" do context "with an anonymous user" do
it "uses the locale from the headers" do it "uses the locale from the headers" do
get "/bootstrap.json", headers: headers("fr") get "/latest", headers: headers("fr")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("fr.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/fr.js")
end end
it "doesn't leak after requests" do it "doesn't leak after requests" do
get "/bootstrap.json", headers: headers("fr") get "/latest", headers: headers("fr")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("fr.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/fr.js")
expect(I18n.locale.to_s).to eq(SiteSettings::DefaultsProvider::DEFAULT_LOCALE) expect(I18n.locale.to_s).to eq(SiteSettings::DefaultsProvider::DEFAULT_LOCALE)
end end
end end
@ -957,9 +964,9 @@ RSpec.describe ApplicationController do
before { sign_in(user) } before { sign_in(user) }
it "uses the user's preferred locale" do it "uses the user's preferred locale" do
get "/bootstrap.json", headers: headers("fr") get "/latest", headers: headers("fr")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("fr.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/fr.js")
end end
it "serves a 404 page in the preferred locale" do it "serves a 404 page in the preferred locale" do
@ -983,9 +990,9 @@ RSpec.describe ApplicationController do
SiteSetting.set_locale_from_accept_language_header = true SiteSetting.set_locale_from_accept_language_header = true
SiteSetting.default_locale = "en" SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: headers("zh-CN") get "/latest", headers: headers("zh-CN")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("zh_CN.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/zh_CN.js")
end end
end end
@ -994,9 +1001,9 @@ RSpec.describe ApplicationController do
SiteSetting.allow_user_locale = true SiteSetting.allow_user_locale = true
SiteSetting.default_locale = "en" SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: headers("") get "/latest", headers: headers("")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("en.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/en.js")
end end
end end
end end
@ -1011,18 +1018,18 @@ RSpec.describe ApplicationController do
context "with an anonymous user" do context "with an anonymous user" do
it "uses the locale from the cookie" do it "uses the locale from the cookie" do
get "/bootstrap.json", headers: { Cookie: "locale=es" } get "/latest", headers: { Cookie: "locale=es" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("es.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/es.js")
expect(I18n.locale.to_s).to eq(SiteSettings::DefaultsProvider::DEFAULT_LOCALE) # doesn't leak after requests expect(I18n.locale.to_s).to eq(SiteSettings::DefaultsProvider::DEFAULT_LOCALE) # doesn't leak after requests
end end
end end
context "when the preferred locale includes a region" do context "when the preferred locale includes a region" do
it "returns the locale and region separated by an underscore" do it "returns the locale and region separated by an underscore" do
get "/bootstrap.json", headers: { Cookie: "locale=zh-CN" } get "/latest", headers: { Cookie: "locale=zh-CN" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("zh_CN.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/zh_CN.js")
end end
end end
end end
@ -1032,9 +1039,9 @@ RSpec.describe ApplicationController do
SiteSetting.allow_user_locale = true SiteSetting.allow_user_locale = true
SiteSetting.default_locale = "en" SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: { Cookie: "" } get "/latest", headers: { Cookie: "" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["bootstrap"]["locale_script"]).to end_with("en.js") expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/en.js")
end end
end end
end end

View File

@ -1,123 +0,0 @@
# frozen_string_literal: true
RSpec.describe BootstrapController do
let(:theme) { Fabricate(:theme, enabled: true) }
before do
DiscoursePluginRegistry.register_html_builder("server:before-head-close") { "<b>wat</b>" }
theme.set_field(target: :desktop, name: :header, value: "<h1>custom header</h1>").save
SiteSetting.default_theme_id = theme.id
end
after do
DiscoursePluginRegistry.reset!
ExtraLocalesController.clear_cache!
end
it "returns data as anonymous" do
get "/bootstrap.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).to be_present
bootstrap = json["bootstrap"]
expect(bootstrap).to be_present
expect(bootstrap["title"]).to be_present
expect(bootstrap["theme_id"]).to eq(theme.id)
expect(bootstrap["setup_data"]["base_url"]).to eq(Discourse.base_url)
expect(bootstrap["stylesheets"]).to be_present
expect(bootstrap["html"]).to be_present
expect(bootstrap["html"]["before_head_close"]).to eq("<b>wat</b>")
expect(bootstrap["theme_html"]).to be_present
expect(bootstrap["theme_html"]["header"]).to eq("<h1>custom header</h1>")
preloaded = bootstrap["preloaded"]
expect(preloaded["site"]).to be_present
expect(preloaded["siteSettings"]).to be_present
expect(preloaded["currentUser"]).to be_blank
expect(preloaded["topicTrackingStates"]).to be_blank
expect(bootstrap["html_classes"]).to eq("desktop-view not-mobile-device text-size-normal anon")
expect(bootstrap["html_lang"]).to eq("en")
end
it "returns user data when authenticated" do
user = Fabricate(:user)
sign_in(user)
get "/bootstrap.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).to be_present
bootstrap = json["bootstrap"]
preloaded = bootstrap["preloaded"]
expect(preloaded["currentUser"]).to be_present
expect(preloaded["topicTrackingStates"]).to be_present
end
it "returns extra locales (admin) when staff" do
user = Fabricate(:admin)
sign_in(user)
get "/bootstrap.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).to be_present
bootstrap = json["bootstrap"]
expect(bootstrap["extra_locales"]).to be_present
end
it "returns data when login_required is enabled" do
SiteSetting.login_required = true
get "/bootstrap.json"
expect(response.status).to eq(200)
expect(response.parsed_body).to be_present
end
context "when authentication data is present" do
it "returns authentication data" do
cookie_data = "someauthenticationdata"
cookies["authentication_data"] = cookie_data
get "/bootstrap.json"
bootstrap = response.parsed_body["bootstrap"]
expect(bootstrap["authentication_data"]).to eq(cookie_data)
end
end
context "with a plugin asset filter" do
let :plugin do
plugin = plugin_from_fixtures("my_plugin")
plugin.register_asset_filter do |type, request|
next true if request.path == "/mypluginroute"
false
end
plugin
end
before do
Discourse.plugins << plugin
plugin.activate!
end
after { Discourse.plugins.delete plugin }
it "filters assets using the given path" do
get "/bootstrap.json"
expect(response.status).to eq(200)
plugin_assets = response.parsed_body.dig("bootstrap", "plugin_js")
expect(plugin_assets).not_to include(a_string_matching "my_plugin")
get "/bootstrap.json?for_url=/mypluginroute"
expect(response.status).to eq(200)
plugin_assets = response.parsed_body.dig("bootstrap", "plugin_js")
expect(plugin_assets).to include(a_string_matching "my_plugin")
end
end
end