From 7435d55ea668ad601c85587b19eeefb2c14dca43 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 9 Mar 2021 10:09:35 -0500 Subject: [PATCH] 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 --- .../javascripts/discourse/app/index.html | 31 +-- .../pre-initializers/discourse-bootstrap.js | 12 +- .../discourse/config/environment.js | 1 + .../javascripts/discourse/ember-cli-build.js | 4 +- .../discourse/lib/bootstrap-json/index.js | 234 ++++++++++++++++++ .../discourse/lib/bootstrap-json/package.json | 13 + app/assets/javascripts/discourse/package.json | 7 + .../public/assets/scripts/discourse-boot.js | 111 +-------- app/assets/javascripts/yarn.lock | 19 ++ app/controllers/bootstrap_controller.rb | 3 +- 10 files changed, 304 insertions(+), 131 deletions(-) create mode 100644 app/assets/javascripts/discourse/lib/bootstrap-json/index.js create mode 100644 app/assets/javascripts/discourse/lib/bootstrap-json/package.json diff --git a/app/assets/javascripts/discourse/app/index.html b/app/assets/javascripts/discourse/app/index.html index 35176355a3..28558df7de 100644 --- a/app/assets/javascripts/discourse/app/index.html +++ b/app/assets/javascripts/discourse/app/index.html @@ -1,5 +1,5 @@ - +{{bootstrap-content-for "html-tag"}} @@ -7,29 +7,32 @@ + {{bootstrap-content-for "before-script-load"}} + {{content-for "before-script-load"}} + + + + + + {{bootstrap-content-for "head"}} {{content-for "head"}} + {{bootstrap-content-for "body"}} {{content-for "body"}}
- - - +
+
+ + {{bootstrap-content-for "hidden-login-form"}} + {{bootstrap-content-for "preloaded"}} + - - + {{bootstrap-content-for "body-footer"}} {{content-for "body-footer"}} diff --git a/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js b/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js index be37c27df4..ceb7796a18 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js @@ -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); diff --git a/app/assets/javascripts/discourse/config/environment.js b/app/assets/javascripts/discourse/config/environment.js index c704e44c56..e004e1eaba 100644 --- a/app/assets/javascripts/discourse/config/environment.js +++ b/app/assets/javascripts/discourse/config/environment.js @@ -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 diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 22cc046d42..bd6fbbccb6 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -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. diff --git a/app/assets/javascripts/discourse/lib/bootstrap-json/index.js b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js new file mode 100644 index 0000000000..d75ea398fa --- /dev/null +++ b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js @@ -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(``); +} + +function head(buffer, bootstrap) { + if (bootstrap.csrf_token) { + buffer.push(``); + buffer.push(``); + } + if (bootstrap.theme_ids) { + buffer.push( + `` + ); + } + + 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(``); + + (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 = `\n`; + buffer.push(link); + }); + + bootstrap.plugin_js.forEach((src) => + buffer.push(``) + ); + + 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(``); + (bootstrap.extra_locales || []).forEach((l) => + buffer.push(``) + ); +} + +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(` + + `); + } +} + +function preloaded(buffer, bootstrap) { + buffer.push( + `` + ); +} + +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 = ` + +

Discourse Build Error

+

${e.toString()}

+ + `; + } + 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); + }, +}; diff --git a/app/assets/javascripts/discourse/lib/bootstrap-json/package.json b/app/assets/javascripts/discourse/lib/bootstrap-json/package.json new file mode 100644 index 0000000000..c79e0bc382 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/bootstrap-json/package.json @@ -0,0 +1,13 @@ +{ + "name": "bootstrap-json", + "keywords": [ + "ember-addon" + ], + "ember-addon": { + "before": [ + "serve-files-middleware", + "history-support-middleware", + "proxy-server-middleware" + ] + } +} diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 97a11a3d6d..27f98930dc 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -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" + ] } } diff --git a/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js index 6d53b78cf8..903de3dba6 100644 --- a/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js +++ b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js @@ -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(); })(); diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index c95e456820..f524a81984 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -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" diff --git a/app/controllers/bootstrap_controller.rb b/app/controllers/bootstrap_controller.rb index 5cd0edacdd..9a52f741a2 100644 --- a/app/controllers/bootstrap_controller.rb +++ b/app/controllers/bootstrap_controller.rb @@ -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