DEV: Use Ember CLI middleware to decorate the index template (#12292)

* DEV: Use Ember CLI middleware to decorate the index template

Previously we'd do this on the client side which did not support our
full plugin API. Now requests for the index template will contact the
dev server for a bootstrap.json and apply it to the current template.

* FIX: Allows logins in development mode for Ember CLI
This commit is contained in:
Robin Ward 2021-03-09 10:09:35 -05:00 committed by GitHub
parent 687e09c885
commit 7435d55ea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 304 additions and 131 deletions

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
{{bootstrap-content-for "html-tag"}}
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@ -7,29 +7,32 @@
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{bootstrap-content-for "before-script-load"}}
{{content-for "before-script-load"}}
<script src="{{rootURL}}assets/vendor.js"></script>
<script src="{{rootURL}}assets/discourse.js"></script>
<script src="{{rootURL}}assets/admin.js"></script>
{{bootstrap-content-for "head"}}
{{content-for "head"}}
</head>
<body>
{{bootstrap-content-for "body"}}
{{content-for "body"}}
<section id='main'>
</section>
<script src="{{rootURL}}assets/vendor.js"></script>
<script src="{{rootURL}}assets/discourse.js"></script>
<script src="{{rootURL}}assets/admin.js"></script>
<div id='offscreen-content'>
</div>
{{bootstrap-content-for "hidden-login-form"}}
{{bootstrap-content-for "preloaded"}}
<script src="{{rootURL}}assets/scripts/discourse-boot.js"></script>
<script>
document.addEventListener("discourse-booted", (e) => {
const config = e.detail;
const app = require(`${config.modulePrefix}/app`)["default"].create(
config
);
app.start();
});
</script>
{{bootstrap-content-for "body-footer"}}
{{content-for "body-footer"}}
</body>
</html>

View File

@ -9,7 +9,6 @@ import I18n from "I18n";
import PreloadStore from "discourse/lib/preload-store";
import RSVP from "rsvp";
import Session from "discourse/models/session";
import { camelize } from "@ember/string";
import deprecated from "discourse-common/lib/deprecated";
import { setDefaultOwner } from "discourse-common/lib/get-owner";
import { setIconList } from "discourse-common/lib/icon-library";
@ -29,21 +28,12 @@ export default {
}
let setupData;
let preloaded;
if (app.bootstrap) {
// This is annoying but our old way of using `data-*` attributes used camelCase by default
setupData = {};
Object.keys(app.bootstrap.setup_data).forEach((k) => {
setupData[camelize(k)] = app.bootstrap.setup_data[k];
});
preloaded = app.bootstrap.preloaded;
}
const setupDataElement = document.getElementById("data-discourse-setup");
if (setupDataElement) {
setupData = setupDataElement.dataset;
}
let preloaded;
const preloadedDataElement = document.getElementById("data-preloaded");
if (preloadedDataElement) {
preloaded = JSON.parse(preloadedDataElement.dataset.preloaded);

View File

@ -6,6 +6,7 @@ module.exports = function (environment) {
environment,
rootURL: "/",
locationType: "auto",
historySupportMiddleware: false,
EmberENV: {
FEATURES: {
// Here you can enable experimental features on an ember canary build

View File

@ -28,7 +28,9 @@ module.exports = function (defaults) {
let discourseRoot = resolve("../../../..");
let vendorJs = discourseRoot + "/vendor/assets/javascripts/";
let app = new EmberApp(defaults, { autoRun: false });
let app = new EmberApp(defaults, {
autoRun: false,
});
// WARNING: We should only import scripts here if they are not in NPM.
// For example: our very specific version of bootstrap-modal.

View File

@ -0,0 +1,234 @@
"use strict";
const bent = require("bent");
const getJSON = bent("json");
const { encode } = require("html-entities");
const cleanBaseURL = require("clean-base-url");
const path = require("path");
const fs = require("fs");
const IGNORE_PATHS = [
/\/ember-cli-live-reload\.js$/,
/\/session\/[^\/]+\/become$/,
];
function htmlTag(buffer, bootstrap) {
let classList = "";
if (bootstrap.html_classes) {
classList = ` class="${bootstrap.html_classes}"`;
}
buffer.push(`<html lang="${bootstrap.html_lang}"${classList}>`);
}
function head(buffer, bootstrap) {
if (bootstrap.csrf_token) {
buffer.push(`<meta name="csrf-param" buffer="authenticity_token">`);
buffer.push(`<meta name="csrf-token" buffer="${bootstrap.csrf_token}">`);
}
if (bootstrap.theme_ids) {
buffer.push(
`<meta name="discourse_theme_ids" buffer="${bootstrap.theme_ids}">`
);
}
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 {
val = val.toString();
}
setupData += ` data-${sd.replace(/\_/g, "-")}="${encode(val)}"`;
}
});
buffer.push(`<meta id="data-discourse-setup"${setupData} />`);
(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}"`);
}
let link = `<link rel="stylesheet" type="text/css" href="${
s.href
}" ${attrs.join(" ")}></script>\n`;
buffer.push(link);
});
bootstrap.plugin_js.forEach((src) =>
buffer.push(`<script 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 beforeScriptLoad(buffer, bootstrap) {
buffer.push(bootstrap.html.before_script_load);
buffer.push(`<script src="${bootstrap.locale_script}"></script>`);
(bootstrap.extra_locales || []).forEach((l) =>
buffer.push(`<script src="${l}"></script>`)
);
}
function body(buffer, bootstrap) {
buffer.push(bootstrap.theme_html.header);
buffer.push(bootstrap.html.header);
}
function bodyFooter(buffer, bootstrap) {
buffer.push(bootstrap.theme_html.body_tag);
buffer.push(bootstrap.html.before_body_close);
}
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,
head: head,
body: body,
"hidden-login-form": hiddenLoginForm,
preloaded: preloaded,
"body-footer": bodyFooter,
};
function replaceIn(bootstrap, template, id) {
let buffer = [];
BUILDERS[id](buffer, bootstrap);
let contents = buffer.filter((b) => b && b.length > 0).join("\n");
return template.replace(`{{bootstrap-content-for "${id}"}}`, contents);
}
function applyBootstrap(bootstrap, template) {
Object.keys(BUILDERS).forEach((id) => {
template = replaceIn(bootstrap, template, id);
});
return template;
}
function decorateIndex(baseUrl, headers) {
// eslint-disable-next-line
return new Promise((resolve, reject) => {
fs.readFile(
path.join(process.cwd(), "dist", "index.html"),
"utf8",
(err, template) => {
getJSON(`${baseUrl}/bootstrap.json`, null, headers)
.then((json) => {
resolve(applyBootstrap(json.bootstrap, template));
})
.catch(() => {
reject(`Could not get ${baseUrl}/bootstrap.json`);
});
}
);
});
}
module.exports = {
name: require("./package").name,
isDevelopingAddon() {
return true;
},
serverMiddleware(config) {
let proxy = config.options.proxy;
let app = config.app;
let options = config.options;
let watcher = options.watcher;
let baseURL =
options.rootURL === ""
? "/"
: cleanBaseURL(options.rootURL || options.baseURL);
app.use(async (req, res, next) => {
try {
const results = await watcher;
if (this.shouldHandleRequest(req, options)) {
let assetPath = req.path.slice(baseURL.length);
let isFile = false;
try {
isFile = fs
.statSync(path.join(results.directory, assetPath))
.isFile();
} catch (err) {
/* ignore */
}
if (!isFile) {
let template;
try {
template = await decorateIndex(proxy, req.headers);
} catch (e) {
template = `
<html>
<h1>Discourse Build Error</h1>
<p>${e.toString()}</p>
</html>
`;
}
res.send(template);
}
}
} finally {
next();
}
});
},
shouldHandleRequest(req, options) {
let acceptHeaders = req.headers.accept || [];
let hasHTMLHeader = acceptHeaders.indexOf("text/html") !== -1;
if (req.method !== "GET") {
return false;
}
if (!hasHTMLHeader) {
return false;
}
if (IGNORE_PATHS.some((ip) => ip.test(req.path))) {
return false;
}
let baseURL =
options.rootURL === ""
? "/"
: cleanBaseURL(options.rootURL || options.baseURL);
let baseURLRegexp = new RegExp(`^${baseURL}`);
return baseURLRegexp.test(req.path);
},
};

View File

@ -0,0 +1,13 @@
{
"name": "bootstrap-json",
"keywords": [
"ember-addon"
],
"ember-addon": {
"before": [
"serve-files-middleware",
"history-support-middleware",
"proxy-server-middleware"
]
}
}

View File

@ -20,6 +20,7 @@
"@glimmer/component": "^1.0.0",
"@popperjs/core": "^2.4.4",
"admin": "^1.0.0",
"bent": "^7.3.12",
"broccoli-asset-rev": "^3.0.0",
"discourse-common": "^1.0.0",
"discourse-hbr": "^1.0.0",
@ -39,6 +40,7 @@
"ember-maybe-import-regenerator": "^0.1.6",
"ember-qunit": "^4.6.0",
"ember-source": "~3.15.0",
"html-entities": "^2.1.0",
"loader.js": "^4.7.0",
"message-bus-client": "^3.3.0",
"mousetrap": "^1.6.5",
@ -55,5 +57,10 @@
},
"ember": {
"edition": "default"
},
"ember-addon": {
"paths": [
"lib/bootstrap-json"
]
}
}

View File

@ -163,117 +163,20 @@
}
});
define("I18n", ["exports"], function (exports) {
return I18n;
});
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
// TODO: Eliminate this global
window.virtualDom = require("virtual-dom");
let head = document.getElementsByTagName("head")[0];
function loadScript(src) {
return new Promise((resolve, reject) => {
let script = document.createElement("script");
script.onload = () => resolve();
script.src = src;
head.appendChild(script);
});
}
let isTesting = require("discourse-common/config/environment").isTesting;
let element = document.querySelector(
`meta[name="discourse/config/environment"]`
);
const config = JSON.parse(
decodeURIComponent(element.getAttribute("content"))
);
fetch("/bootstrap.json")
.then((res) => res.json())
.then((data) => {
let bootstrap = data.bootstrap;
config.bootstrap = bootstrap;
// We know better, we packaged this.
config.bootstrap.setup_data.markdown_it_url =
"/assets/discourse-markdown.js";
let locale = bootstrap.locale_script;
if (bootstrap.csrf_token) {
const csrfParam = document.createElement("meta");
csrfParam.setAttribute("name", "csrf-param");
csrfParam.setAttribute("content", "authenticity_token");
head.append(csrfParam);
const csrfToken = document.createElement("meta");
csrfToken.setAttribute("name", "csrf-token");
csrfToken.setAttribute("content", bootstrap.csrf_token);
head.append(csrfToken);
}
(bootstrap.stylesheets || []).forEach((s) => {
let link = document.createElement("link");
link.setAttribute("rel", "stylesheet");
link.setAttribute("type", "text/css");
link.setAttribute("href", s.href);
if (s.media) {
link.setAttribute("media", s.media);
}
if (s.target) {
link.setAttribute("data-target", s.target);
}
if (s.theme_id) {
link.setAttribute("data-theme-id", s.theme_id);
}
head.append(link);
});
let pluginJs = bootstrap.plugin_js;
if (isTesting()) {
// pluginJs = pluginJs.concat(bootstrap.plugin_test_js);
}
pluginJs.forEach((src) => {
let script = document.createElement("script");
script.setAttribute("src", src);
head.append(script);
});
if (bootstrap.theme_ids) {
let theme_ids = document.createElement("meta");
theme_ids.setAttribute("name", "discourse_theme_ids");
theme_ids.setAttribute("content", bootstrap.theme_ids);
head.append(theme_ids);
}
let htmlElement = document.getElementsByTagName("html")[0];
htmlElement.classList = bootstrap.html_classes;
htmlElement.setAttribute("lang", bootstrap.html_lang);
let themeHtml = bootstrap.theme_html;
let html = bootstrap.html;
head.insertAdjacentHTML("beforeend", themeHtml.translations || "");
head.insertAdjacentHTML("beforeend", themeHtml.js || "");
head.insertAdjacentHTML("beforeend", themeHtml.head_tag || "");
head.insertAdjacentHTML("afterbegin", html.before_script_load || "");
head.insertAdjacentHTML("beforeend", html.before_head_close || "");
let main = document.getElementById("main");
main.insertAdjacentHTML("beforebegin", themeHtml.header || "");
main.insertAdjacentHTML("beforebegin", html.header || "");
let body = document.getElementsByTagName("body")[0];
body.insertAdjacentHTML("beforeend", themeHtml.body_tag || "");
body.insertAdjacentHTML("beforeend", html.before_body_close || "");
loadScript(locale).then(() => {
define("I18n", ["exports"], function (exports) {
return I18n;
});
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
let extras = (bootstrap.extra_locales || []).map(loadScript);
return Promise.all(extras).then(() => {
const event = new CustomEvent("discourse-booted", { detail: config });
document.dispatchEvent(event);
});
});
});
const app = require(`${config.modulePrefix}/app`)["default"].create(config);
app.start();
})();

View File

@ -3222,6 +3222,15 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
bent@^7.3.12:
version "7.3.12"
resolved "https://registry.yarnpkg.com/bent/-/bent-7.3.12.tgz#e0a2775d4425e7674c64b78b242af4f49da6b035"
integrity sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==
dependencies:
bytesish "^0.4.1"
caseless "~0.12.0"
is-stream "^2.0.0"
better-assert@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
@ -4052,6 +4061,11 @@ bytes@3.1.0:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
bytesish@^0.4.1:
version "0.4.4"
resolved "https://registry.yarnpkg.com/bytesish/-/bytesish-0.4.4.tgz#f3b535a0f1153747427aee27256748cff92347e6"
integrity sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ==
cacache@^12.0.2:
version "12.0.4"
resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
@ -7140,6 +7154,11 @@ html-encoding-sniffer@^1.0.2:
dependencies:
whatwg-encoding "^1.0.1"
html-entities@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.1.0.tgz#f5de1f8d5e1f16859a74aa73a90f0db502ca723a"
integrity sha512-u+OHVGMH5P1HlaTFp3M4HolRnWepgx5rAnYBo+7/TrBZahuJjgQ4TMv2GjQ4IouGDzkgXYeOI/NQuF95VOUOsQ==
http-cache-semantics@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"

View File

@ -61,7 +61,8 @@ class BootstrapController < ApplicationController
html: create_html,
theme_html: create_theme_html,
html_classes: html_classes,
html_lang: html_lang
html_lang: html_lang,
login_path: main_app.login_path
}
bootstrap[:extra_locales] = extra_locales if extra_locales.present?
bootstrap[:csrf_token] = form_authenticity_token if current_user