diff --git a/app/assets/javascripts/discourse/app/templates/modal/feature-topic.hbs b/app/assets/javascripts/discourse/app/templates/modal/feature-topic.hbs
index fddb84f15a..efc5bdc5f8 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/feature-topic.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/feature-topic.hbs
@@ -147,7 +147,7 @@
{{#if model.isBanner}}
{{d-button action=(action "removeBanner") icon="thumbtack" label="topic.feature.remove_banner" class="btn-primary"}}
{{else}}
- {{d-button action=(action "makeBanner") icon="thumbtack" label="topic.feature.make_banner" class="btn-primary"}}
+ {{d-button action=(action "makeBanner") icon="thumbtack" label="topic.feature.make_banner" class="btn-primary make-banner"}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/templates/tags/index.hbs b/app/assets/javascripts/discourse/app/templates/tags/index.hbs
index be37746670..fc04e0209f 100644
--- a/app/assets/javascripts/discourse/app/templates/tags/index.hbs
+++ b/app/assets/javascripts/discourse/app/templates/tags/index.hbs
@@ -19,14 +19,16 @@
-{{#each model.extras.categories as |category|}}
- {{tag-list tags=category.tags sortProperties=sortProperties categoryId=category.id}}
-{{/each}}
+
+ {{#each model.extras.categories as |category|}}
+ {{tag-list tags=category.tags sortProperties=sortProperties categoryId=category.id}}
+ {{/each}}
-{{#each model.extras.tag_groups as |tagGroup|}}
- {{tag-list tags=tagGroup.tags sortProperties=sortProperties tagGroupName=tagGroup.name}}
-{{/each}}
+ {{#each model.extras.tag_groups as |tagGroup|}}
+ {{tag-list tags=tagGroup.tags sortProperties=sortProperties tagGroupName=tagGroup.name}}
+ {{/each}}
-{{#if model}}
- {{tag-list tags=model sortProperties=sortProperties titleKey=otherTagsTitleKey}}
-{{/if}}
+ {{#if model}}
+ {{tag-list tags=model sortProperties=sortProperties titleKey=otherTagsTitleKey}}
+ {{/if}}
+
diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js
new file mode 100644
index 0000000000..22cc046d42
--- /dev/null
+++ b/app/assets/javascripts/discourse/ember-cli-build.js
@@ -0,0 +1,48 @@
+"use strict";
+
+const EmberApp = require("ember-cli/lib/broccoli/ember-app");
+const resolve = require("path").resolve;
+const mergeTrees = require("broccoli-merge-trees");
+const concat = require("broccoli-concat");
+const babel = require("broccoli-babel-transpiler");
+const path = require("path");
+const funnel = require("broccoli-funnel");
+
+function prettyTextEngine(vendorJs, engine) {
+ let engineTree = babel(`../pretty-text/engines/${engine}`, {
+ plugins: ["@babel/plugin-transform-modules-amd"],
+ moduleIds: true,
+
+ getModuleId(name) {
+ return `pretty-text/engines/${engine}/${path.basename(name)}`;
+ },
+ });
+
+ let markdownIt = funnel(vendorJs, { files: ["markdown-it.js"] });
+ return concat(mergeTrees([engineTree, markdownIt]), {
+ outputFile: `assets/${engine}.js`,
+ });
+}
+
+module.exports = function (defaults) {
+ let discourseRoot = resolve("../../../..");
+ let vendorJs = discourseRoot + "/vendor/assets/javascripts/";
+
+ 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.
+ app.import(vendorJs + "bootbox.js");
+ app.import(vendorJs + "bootstrap-modal.js");
+ app.import(vendorJs + "jquery.ui.widget.js");
+ app.import(vendorJs + "jquery.fileupload.js");
+ app.import(vendorJs + "jquery.autoellipsis-1.0.10.js");
+
+ return mergeTrees([
+ app.toTree(),
+ concat(app.options.adminTree, {
+ outputFile: `assets/admin.js`,
+ }),
+ prettyTextEngine(vendorJs, "discourse-markdown"),
+ ]);
+};
diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json
new file mode 100644
index 0000000000..97a11a3d6d
--- /dev/null
+++ b/app/assets/javascripts/discourse/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "discourse",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Small description for discourse-frontend goes here",
+ "repository": "",
+ "license": "MIT",
+ "author": "",
+ "directories": {
+ "doc": "doc",
+ "test": "tests"
+ },
+ "scripts": {
+ "build": "ember build",
+ "start": "ember serve",
+ "test": "ember test"
+ },
+ "devDependencies": {
+ "@ember/optional-features": "^1.1.0",
+ "@glimmer/component": "^1.0.0",
+ "@popperjs/core": "^2.4.4",
+ "admin": "^1.0.0",
+ "broccoli-asset-rev": "^3.0.0",
+ "discourse-common": "^1.0.0",
+ "discourse-hbr": "^1.0.0",
+ "discourse-widget-hbs": "^1.0.0",
+ "ember-auto-import": "^1.5.3",
+ "ember-buffered-proxy": "^2.0.0-beta.0",
+ "ember-cli": "~3.15.2",
+ "ember-cli-app-version": "^3.2.0",
+ "ember-cli-babel": "^7.13.0",
+ "ember-cli-dependency-checker": "^3.2.0",
+ "ember-cli-htmlbars": "^4.2.0",
+ "ember-cli-inject-live-reload": "^2.0.1",
+ "ember-cli-sri": "^2.1.1",
+ "ember-cli-uglify": "^3.0.0",
+ "ember-export-application-global": "^2.0.1",
+ "ember-load-initializers": "^2.1.1",
+ "ember-maybe-import-regenerator": "^0.1.6",
+ "ember-qunit": "^4.6.0",
+ "ember-source": "~3.15.0",
+ "loader.js": "^4.7.0",
+ "message-bus-client": "^3.3.0",
+ "mousetrap": "^1.6.5",
+ "mousetrap-global-bind": "^1.1.0",
+ "pretender": "^3.4.3",
+ "pretty-text": "^1.0.0",
+ "qunit-dom": "^0.9.2",
+ "select-kit": "^1.0.0",
+ "sinon": "^9.2.0",
+ "virtual-dom": "^2.1.1"
+ },
+ "engines": {
+ "node": "8.* || >= 10.*"
+ },
+ "ember": {
+ "edition": "default"
+ }
+}
diff --git a/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js
new file mode 100644
index 0000000000..fdf76361dc
--- /dev/null
+++ b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js
@@ -0,0 +1,190 @@
+(function () {
+ // TODO: These are needed to load plugins because @ember has its own loader.
+ // We should find a nicer way to do this.
+ const EMBER_MODULES = {
+ "@ember/array": {
+ default: Ember.Array,
+ A: Ember.A,
+ isArray: Ember.isArray,
+ },
+ "@ember/array/proxy": {
+ default: Ember.ArrayProxy,
+ },
+ "@ember/component": {
+ default: Ember.Component,
+ },
+ "@ember/controller": {
+ default: Ember.Controller,
+ inject: Ember.inject.controller,
+ },
+ "@ember/debug": {
+ warn: Ember.warn,
+ },
+ "@ember/object": {
+ action: Ember._action,
+ default: Ember.Object,
+ get: Ember.get,
+ getProperties: Ember.getProperties,
+ set: Ember.set,
+ setProperties: Ember.setProperties,
+ computed: Ember.computed,
+ defineProperty: Ember.defineProperty,
+ },
+ "@ember/object/computed": {
+ alias: Ember.computed.alias,
+ and: Ember.computed.and,
+ bool: Ember.computed.bool,
+ collect: Ember.computed.collect,
+ deprecatingAlias: Ember.computed.deprecatingAlias,
+ empty: Ember.computed.empty,
+ equal: Ember.computed.equal,
+ filter: Ember.computed.filter,
+ filterBy: Ember.computed.filterBy,
+ gt: Ember.computed.gt,
+ gte: Ember.computed.gte,
+ intersect: Ember.computed.intersect,
+ lt: Ember.computed.lt,
+ lte: Ember.computed.lte,
+ map: Ember.computed.map,
+ mapBy: Ember.computed.mapBy,
+ match: Ember.computed.match,
+ max: Ember.computed.max,
+ min: Ember.computed.min,
+ none: Ember.computed.none,
+ not: Ember.computed.not,
+ notEmpty: Ember.computed.notEmpty,
+ oneWay: Ember.computed.oneWay,
+ or: Ember.computed.or,
+ readOnly: Ember.computed.readOnly,
+ reads: Ember.computed.reads,
+ setDiff: Ember.computed.setDiff,
+ sort: Ember.computed.sort,
+ sum: Ember.computed.sum,
+ union: Ember.computed.union,
+ uniq: Ember.computed.uniq,
+ uniqBy: Ember.computed.uniqBy,
+ },
+ "@ember/object/mixin": { default: Ember.Mixin },
+ "@ember/object/proxy": { default: Ember.ObjectProxy },
+ "@ember/object/promise-proxy-mixin": { default: Ember.PromiseProxyMixin },
+ "@ember/object/evented": {
+ default: Ember.Evented,
+ on: Ember.on,
+ },
+ "@ember/routing/route": { default: Ember.Route },
+ "@ember/routing/router": { default: Ember.Router },
+ "@ember/runloop": {
+ bind: Ember.run.bind,
+ cancel: Ember.run.cancel,
+ debounce: Ember.testing ? Ember.run : Ember.run.debounce,
+ later: Ember.run.later,
+ next: Ember.run.next,
+ once: Ember.run.once,
+ run: Ember.run,
+ schedule: Ember.run.schedule,
+ scheduleOnce: Ember.run.scheduleOnce,
+ throttle: Ember.run.throttle,
+ },
+ "@ember/service": {
+ default: Ember.Service,
+ inject: Ember.inject.service,
+ },
+ "@ember/template": {
+ htmlSafe: Ember.String.htmlSafe,
+ },
+ "@ember/utils": {
+ isBlank: Ember.isBlank,
+ isEmpty: Ember.isEmpty,
+ isNone: Ember.isNone,
+ isPresent: Ember.isPresent,
+ },
+ };
+ Object.keys(EMBER_MODULES).forEach((mod) => {
+ define(mod, () => EMBER_MODULES[mod]);
+ });
+
+ // TODO: Remove this and have resolver find the templates
+ const prefix = "discourse/templates/";
+ const adminPrefix = "admin/templates/";
+ let len = prefix.length;
+ Object.keys(requirejs.entries).forEach(function (key) {
+ if (key.indexOf(prefix) === 0) {
+ Ember.TEMPLATES[key.substr(len)] = require(key).default;
+ } else if (key.indexOf(adminPrefix) === 0) {
+ Ember.TEMPLATES[key] = require(key).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) => {
+ config.bootstrap = data.bootstrap;
+
+ // We know better, we packaged this.
+ config.bootstrap.setup_data.markdown_it_url =
+ "/assets/discourse-markdown.js";
+
+ let locale = data.bootstrap.locale_script;
+
+ (data.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 = data.bootstrap.plugin_js;
+ if (isTesting()) {
+ // pluginJs = pluginJs.concat(data.bootstrap.plugin_test_js);
+ }
+
+ pluginJs.forEach((src) => {
+ let script = document.createElement("script");
+ script.setAttribute("src", src);
+ head.append(script);
+ });
+
+ loadScript(locale).then(() => {
+ define("I18n", ["exports"], function (exports) {
+ return I18n;
+ });
+ window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
+ let extras = (data.bootstrap.extra_locales || []).map(loadScript);
+ return Promise.all(extras).then(() => {
+ const event = new CustomEvent("discourse-booted", { detail: config });
+ document.dispatchEvent(event);
+ });
+ });
+ });
+})();
diff --git a/app/assets/javascripts/discourse/testem.js b/app/assets/javascripts/discourse/testem.js
new file mode 100644
index 0000000000..bef0cc9d5d
--- /dev/null
+++ b/app/assets/javascripts/discourse/testem.js
@@ -0,0 +1,20 @@
+module.exports = {
+ test_page: "tests/index.html?hidepassed",
+ disable_watching: true,
+ launch_in_ci: ["Chrome"],
+ launch_in_dev: ["Chrome"],
+ browser_args: {
+ Chrome: {
+ ci: [
+ // --no-sandbox is needed when running Chrome inside a container
+ process.env.CI ? "--no-sandbox" : null,
+ "--headless",
+ "--disable-dev-shm-usage",
+ "--disable-software-rasterizer",
+ "--mute-audio",
+ "--remote-debugging-port=0",
+ "--window-size=1440,900",
+ ].filter(Boolean),
+ },
+ },
+};