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";
const express = require("express");
const { encode } = require("html-entities");
const cleanBaseURL = require("clean-base-url");
const path = require("path");
const fs = require("fs");
const fsPromises = fs.promises;
const { JSDOM } = require("jsdom");
const { shouldLoadPlugins } = require("discourse-plugins");
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
function generateUID() {
let firstPart = (Math.random() * 46656) | 0; // eslint-disable-line no-bitwise
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;
async function listDistAssets() {
const files = await glob("**/*.js", { nodir: true, cwd: "dist/assets" });
return new Set(files);
}
function htmlTag(buffer, bootstrap) {
let classList = "";
if (bootstrap.html_classes) {
classList = ` class="${bootstrap.html_classes}"`;
}
buffer.push(`<html lang="${bootstrap.html_lang}"${classList}>`);
}
function updateScriptReferences({
chunkInfos,
dom,
selector,
attribute,
baseURL,
distAssets,
}) {
const elements = dom.window.document.querySelectorAll(selector);
const handledEntrypoints = new Set();
function head(buffer, bootstrap, headers, baseURL) {
if (bootstrap.csrf_token) {
buffer.push(`<meta name="csrf-param" content="authenticity_token">`);
buffer.push(`<meta name="csrf-token" content="${bootstrap.csrf_token}">`);
for (const el of elements) {
const entrypointName = el.dataset.discourseEntrypoint;
if (handledEntrypoints.has(entrypointName)) {
el.remove();
continue;
}
if (bootstrap.theme_id) {
buffer.push(
`<meta name="discourse_theme_id" content="${bootstrap.theme_id}">`
);
}
let chunks = chunkInfos[`assets/${entrypointName}.js`]?.assets;
if (bootstrap.theme_color) {
buffer.push(`<meta name="theme-color" content="${bootstrap.theme_color}">`);
}
if (bootstrap.authentication_data) {
buffer.push(
`<meta id="data-authentication" data-authentication-data="${encode(
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);
if (!chunks) {
if (distAssets.has(`${entrypointName}.js`)) {
chunks = [`assets/${entrypointName}.js`];
} else {
val = val.toString();
// Not an ember-cli asset, do not rewrite
continue;
}
setupData += ` data-${sd.replace(/\_/g, "-")}="${encode(val)}"`;
}
const newElements = chunks.map((chunk) => {
const newElement = el.cloneNode(true);
newElement[attribute] = `${baseURL}${chunk}`;
newElement.dataset.emberCliRewritten = "true";
return newElement;
});
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 (
entrypointName === "discourse" &&
el.tagName.toLowerCase() === "script"
) {
const liveReload = dom.window.document.createElement("script");
liveReload.setAttribute("async", "");
liveReload.src = `${baseURL}ember-cli-live-reload.js`;
newElements.unshift(liveReload);
}
if (admin) {
buffer.push(`<script defer src="${baseURL}assets/wizard.js"></script>`);
}
}
el.replaceWith(...newElements);
bootstrap.plugin_js.forEach((src) =>
buffer.push(`<script defer src="${src}"></script>`)
);
buffer.push(bootstrap.theme_html.translations);
buffer.push(bootstrap.theme_html.js);
buffer.push(bootstrap.theme_html.head_tag);
buffer.push(bootstrap.html.before_head_close);
}
function localeScript(buffer, bootstrap) {
buffer.push(`<script defer src="${bootstrap.locale_script}"></script>`);
(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");
if (reqUrlSafeMode) {
url.searchParams.append("safe_mode", reqUrlSafeMode);
}
const navigationMenu = forUrlSearchParams.get("navigation_menu");
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}`
);
handledEntrypoints.add(entrypointName);
}
}
@ -364,22 +156,35 @@ async function handleRequest(proxy, baseURL, req, res) {
res.status(response.status);
if (isHTML) {
const responseText = await response.text();
const preloadJson = isHTML ? extractPreloadJson(responseText) : null;
const [responseText, chunkInfoText, distAssets] = await Promise.all([
response.text(),
fsPromises.readFile("dist/assets.json", "utf-8"),
listDistAssets(),
]);
if (preloadJson) {
const html = await buildFromBootstrap(
proxy,
const chunkInfos = JSON.parse(chunkInfoText);
const dom = new JSDOM(responseText);
updateScriptReferences({
chunkInfos,
dom,
selector: "script[data-discourse-entrypoint]",
attribute: "src",
baseURL,
req,
response,
extractPreloadJson(responseText)
);
res.set("content-type", "text/html");
res.send(html);
} else {
res.send(responseText);
}
distAssets,
});
updateScriptReferences({
chunkInfos,
dom,
selector: "link[rel=preload][data-discourse-entrypoint]",
attribute: "href",
baseURL,
distAssets,
});
res.send(dom.serialize());
} else {
res.send(Buffer.from(await response.arrayBuffer()));
}
@ -392,63 +197,6 @@ module.exports = {
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) {
const app = config.app;
let { proxy, rootURL, baseURL } = config.options;

View File

@ -1,7 +1,7 @@
{
"name": "bootstrap-json",
"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",
"license": "GPL-2.0-only",
"keywords": [
@ -16,8 +16,8 @@
},
"devDependencies": {
"clean-base-url": "^1.0.0",
"discourse-plugins": "1.0.0",
"express": "^4.18.2",
"glob": "^10.3.10",
"html-entities": "^2.4.0",
"jsdom": "^22.1.0",
"node-fetch": "^3.3.2"

View File

@ -267,4 +267,72 @@ module.exports = {
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>
<head>
<!--
👋 Greetings Discourse Developer. This HTML was generated by the ember-cli proxy. If you're looking for
<head> content generated by Rails, you'll need to start the server with `ALLOW_EMBER_CLI_PROXY_BYPASS=1`
and then visit the Rails port (e.g. `localhost:3000`) directly. Be sure to keep ember-cli running so
that JS assets continue to be re-compiled when changes are made.
👋 Greetings Discourse Developer. This html file is used by ember-cli/embroider to define our JS entrypoints,
but it is never actually used in development or production. Instead, we generate an `assets.json` file which
is ingested by Rails and used to generate the correct <script> tags.
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">
<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/discourse.css" />
<!-- bootstrap-content head -->
{{content-for "head"}}
<script defer src="{{rootURL}}assets/vendor.js"></script>
<script defer src="{{rootURL}}assets/discourse.js"></script>
<!-- bootstrap-content locale-script -->
</head>
<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>
</html>

View File

@ -1470,6 +1470,18 @@
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5"
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":
version "0.3.3"
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"
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":
version "2.11.8"
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"
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:
version "2.2.1"
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:
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:
version "0.6.15"
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:
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:
version "1.3.4"
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"
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:
version "3.0.0"
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"
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:
version "3.0.1"
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"
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:
version "5.0.15"
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
@ -7344,6 +7400,15 @@ istextorbinary@^2.5.1:
editions "^2.2.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:
version "27.5.1"
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"
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:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -8157,6 +8227,13 @@ minimatch@^7.4.3:
dependencies:
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:
version "0.2.14"
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"
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:
version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@ -8789,6 +8871,14 @@ path-root@^0.1.1:
dependencies:
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:
version "0.1.7"
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"
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:
version "1.1.1"
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"
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"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -9956,6 +10051,15 @@ string-width@^2.1.0:
is-fullwidth-code-point "^2.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:
version "4.0.8"
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:
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:
version "3.0.1"
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:
ansi-regex "^4.1.0"
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==
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
dependencies:
ansi-regex "^5.0.1"
ansi-regex "^6.0.1"
strip-bom@^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"
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:
version "6.2.0"
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"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
ansi-styles "^6.1.0"
string-width "^5.0.1"
strip-ansi "^7.0.1"
wrappy@1:
version "1.0.2"

View File

@ -1,103 +1,8 @@
# frozen_string_literal: true
class BootstrapController < ApplicationController
include ApplicationHelper
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
urls =
Discourse
@ -114,74 +19,4 @@ class BootstrapController < ApplicationController
render plain: stylesheet, content_type: "text/css"
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

View File

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

View File

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

View File

@ -2,19 +2,18 @@
class EmberCli < ActiveSupport::CurrentAttributes
# Cache which persists for the duration of a request
attribute :request_cached_script_chunks
attribute :request_cache
def self.dist_dir
"#{Rails.root}/app/assets/javascripts/discourse/dist"
end
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
def self.script_chunks
return @production_chunk_infos if @production_chunk_infos
return self.request_cached_script_chunks if self.request_cached_script_chunks
return cache[:script_chunks] if cache[:script_chunks]
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")]
end
@production_chunk_infos = chunk_infos if Rails.env.production?
self.request_cached_script_chunks = chunk_infos
cache[:script_chunks] = chunk_infos
rescue Errno::ENOENT
{}
end
@ -62,9 +60,16 @@ class EmberCli < ActiveSupport::CurrentAttributes
File.exist?("#{dist_dir}/tests/index.html")
end
def self.cache
if Rails.env.development?
self.request_cache ||= {}
else
@production_cache ||= {}
end
end
def self.clear_cache!
@production_chunk_infos = nil
@assets = nil
self.request_cached_script_chunks = nil
self.request.cache = nil
@production_cache = nil
end
end

View File

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

View File

@ -900,6 +900,13 @@ RSpec.describe ApplicationController do
{ HTTP_ACCEPT_LANGUAGE: locale }
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 "when accept-language header differs from default locale" do
before do
@ -909,9 +916,9 @@ RSpec.describe ApplicationController do
context "with an anonymous user" 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.parsed_body["bootstrap"]["locale_script"]).to end_with("en.js")
expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/en.js")
end
end
@ -920,9 +927,9 @@ RSpec.describe ApplicationController do
user = Fabricate(:user, locale: :fr)
sign_in(user)
get "/bootstrap.json", headers: headers("fr")
get "/latest", headers: headers("fr")
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
@ -938,15 +945,15 @@ RSpec.describe ApplicationController do
context "with an anonymous user" 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.parsed_body["bootstrap"]["locale_script"]).to end_with("fr.js")
expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/fr.js")
end
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.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)
end
end
@ -957,9 +964,9 @@ RSpec.describe ApplicationController do
before { sign_in(user) }
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.parsed_body["bootstrap"]["locale_script"]).to end_with("fr.js")
expect(locale_scripts(response.body)).to contain_exactly("/assets/locales/fr.js")
end
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.default_locale = "en"
get "/bootstrap.json", headers: headers("zh-CN")
get "/latest", headers: headers("zh-CN")
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
@ -994,9 +1001,9 @@ RSpec.describe ApplicationController do
SiteSetting.allow_user_locale = true
SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: headers("")
get "/latest", headers: headers("")
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
@ -1011,18 +1018,18 @@ RSpec.describe ApplicationController do
context "with an anonymous user" 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.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
end
end
context "when the preferred locale includes a region" 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.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
@ -1032,9 +1039,9 @@ RSpec.describe ApplicationController do
SiteSetting.allow_user_locale = true
SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: { Cookie: "" }
get "/latest", headers: { Cookie: "" }
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

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