DEV: Replace JS build system with Rolldown (#35963)

This replaces the old ember-cli build with a modern Rolldown build. In
local testing, this provides an 80% improvement in build times, while
remaining 100% backwards compatible for themes and plugins.

As part of this move, we have decided to stop using a proxy in front of
Discourse for development. Development should now be done directly
against the Rails server.

`bin/ember-cli -u` has been replaced with `bin/dev`. This will launch
Rails on `:3000`, and will run the rolldown build in the background. Log
output from both processes will be shown with an appropriate prefix. You
should visit `:3000` in your browser. `:4200` will no longer serve
anything.

To help with migration, `bin/ember-cli` is now a backwards-compatible
shim. It will print help information, and will launch a lightweight
server on `:4200` with instructions to move to `:3000`.

If you prefer to launch Rails and the JS build as separate commands, you
can still do that. Rails boot commands are unchanged, and the rolldown
development builder can be run using `bin/dev --only ember`.

https://meta.discourse.org/t/403908

---------

Co-authored-by: Jarek Radosz <jarek@cvx.dev>
Co-authored-by: Chris Manson <chris@manson.ie>
This commit is contained in:
David Taylor
2026-05-29 11:11:55 +01:00
committed by GitHub
co-authored by Jarek Radosz Chris Manson
parent 64ee16f7b9
commit 9527868295
164 changed files with 3818 additions and 5196 deletions
+1 -2
View File
@@ -6,8 +6,7 @@
"postStartCommand": "./.devcontainer/scripts/start.rb",
"forwardPorts": [
9292, // bin/pitchfork
3000, // bin/rails s
4200, // ember-cli
3000, // bin/dev
8025, // mailhog
9229 // chrome remote debug
],
+1 -1
View File
@@ -26,7 +26,7 @@ puts <<~TXT
1. Cmd/Ctrl + Shift + B to run the shortcuts/boot-dev task
2. Wait for the server to start
3. Run the "dev/admin/create" task once to create an admin account
4. Open your browser to http://localhost:4200
4. Open your browser to http://localhost:3000
Running tests:
Run the "deps/testing" task once to install Playwright + discourse_test DB
+2
View File
@@ -100,6 +100,7 @@
# Front-end
dist
frontend/discourse/tmp/compat-prebuild
node_modules
yarn-error.log
.pnpm-store
@@ -123,6 +124,7 @@ openapi/*
# direnv.net
.direnv
/app/assets/javascripts/discourse/tmp
# Types
frontend/discourse-types/declarations
-1
View File
@@ -36,7 +36,6 @@
"line-stream": "0.0.0",
"messageformat": "0.1.5",
"regenerator-transform": "0.10.1",
"source-map": "0.1.43",
"sourcemap-validator": "1.1.1",
"spawn-command": "0.0.2",
"taffydb": "2.6.2"
@@ -123,7 +123,7 @@ end
**When a system test fails, diagnose before fixing.** Guessing at fixes without understanding the failure burns retries and lands the wrong patch.
If you changed frontend code (`.js` / `.hbs` / `.gjs` / `.gts`) and the behavior suggests your changes aren't being picked up, the asset build is stale. Run `bin/ember-cli --build` to rebuild, then re-run the test. This is only needed when `bin/ember-cli` isn't already running in the background.
If you changed frontend code (`.js` / `.hbs` / `.gjs` / `.gts`) and the behavior suggests your changes aren't being picked up, the asset build is stale. Run `pnpm build` to rebuild, then re-run the test. This is only needed when `bin/dev` isn't already running in the background.
For runtime visibility, add `puts "DEBUG: …"` in Ruby (controllers, models, services, jobs) or `console.log("DEBUG: …")` in JavaScript (components, services, routes). Place logs at the entry point of the code path, around conditional branches, and at the line where the failure occurs. Re-run with documentation format so the output reads cleanly:
@@ -139,7 +139,7 @@ Common failure patterns:
|---|---|---|
| Element not found | Selector wrong, element not rendered, timing | `console.log` in the component, double-check the selector in the test |
| Unexpected content | Wrong data, rendering issue | `puts` in the controller/serializer to check data flow |
| JS changes not reflected | Assets not rebuilt | Run `bin/ember-cli --build` |
| JS changes not reflected | Assets not rebuilt | Run `pnpm build` |
| Flaky pass/fail | Timing issue | Add waits, check for async operations |
| 404/500 in test | Route or controller issue | `puts` in the route handler, check server logs |
+1 -1
View File
@@ -59,7 +59,7 @@
{
"label": "dev/server",
"type": "shell",
"command": "bin/ember-cli -u",
"command": "bin/dev",
"options": {
"env":{
"DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE": "1"
-15
View File
@@ -1,15 +0,0 @@
body.requires-ember-cli {
margin: 2rem;
font-family: Arial, Helvetica, sans-serif;
background-color: white;
}
pre {
background-color: lightgrey;
}
pre,
code {
margin: 0;
padding: 0.5rem;
}
+8 -11
View File
@@ -119,24 +119,15 @@ class ApplicationController < ActionController::Base
response.headers.delete("X-Frame-Options") if SiteSetting.allow_embedding_site_in_an_iframe
end
def ember_cli_required?
Rails.env.development? && ENV["ALLOW_EMBER_CLI_PROXY_BYPASS"] != "1" &&
request.headers["X-Discourse-Ember-CLI"] != "true"
end
def application_layout
ember_cli_required? ? "ember_cli" : "application"
end
def set_layout
case request.headers["Discourse-Render"]
when "desktop"
return application_layout
return "application"
when "crawler"
return "crawler"
end
use_crawler_layout? ? "crawler" : application_layout
use_crawler_layout? ? "crawler" : "application"
end
class RenderEmpty < StandardError
@@ -149,6 +140,12 @@ class ApplicationController < ActionController::Base
with_resolved_locale { render "default/empty" }
end
rescue_from EmberCli::BuildError do |e|
@build_error = e.details
response.headers["Cache-Control"] = "no-store"
render "default/build_error", layout: false, status: :service_unavailable
end
rescue_from ArgumentError do |e|
if e.message == "string contains null byte"
raise Discourse::InvalidParameters, e.message
-77
View File
@@ -15,81 +15,4 @@ class BootstrapController < ApplicationController
};
JS
end
def core_css_for_tests
targets = %w[color_definitions common desktop admin]
render_css_for_tests(targets)
end
def plugin_test_info
target = params[:target]
required_plugins = []
testing_plugins = []
if target == "all" || target == "plugins"
required_plugins.push(*Discourse.plugins.map(&:directory_name))
testing_plugins.push(*Discourse.plugins.map(&:directory_name))
elsif target == "core"
# no plugins
elsif target_plugin = Discourse.plugins.find { |p| p.directory_name == target }
required_plugins << target_plugin.directory_name
testing_plugins << target_plugin.directory_name
target_plugin.test_required_plugins&.map do |plugin_name|
additional_plugin = Discourse.plugins.find { |p| p.directory_name == plugin_name }
required_plugins << additional_plugin.directory_name if additional_plugin
end
required_plugins.push(*QunitController::ALWAYS_LOADED_PLUGINS)
else
return render plain: "Target '#{target}' not found", status: :not_found
end
plugin_js_string =
render_to_string partial: "layouts/plugin_js",
locals: {
opts: {
include_disabled: true,
include_admin_asset: true,
include_test_assets_for: testing_plugins,
only: required_plugins,
},
},
formats: [:html],
layout: false
plugin_css_string =
Discourse
.find_plugin_css_assets(
include_disabled: true,
desktop_view: true,
include_admin: true,
only: required_plugins,
)
.map { |file| helpers.discourse_stylesheet_link_tag(file) }
.join("\n")
render json: {
all_plugins: Discourse.plugins.map(&:directory_name),
html: "#{plugin_js_string}\n#{plugin_css_string}",
}
end
private
def render_css_for_tests(targets)
urls =
targets.map do |target|
details = Stylesheet::Manager.new().stylesheet_details(target, "all")
details[0][:new_href]
end
stylesheet = <<~CSS
/* For use in tests only */
#{urls.map { |url| "@import \"#{url}\";" }.join("\n")}
CSS
render plain: stylesheet, content_type: "text/css"
end
end
+48 -12
View File
@@ -10,7 +10,49 @@ class QunitController < ApplicationController
redirect_to_login_if_required
redirect_to_profile_if_required
]
layout false
around_action :ensure_locale_en
def index
raise Discourse::NotFound.new if !can_see_theme_qunit?
@suggested_themes =
Theme
.where(id: ThemeField.where(target_id: Theme.targets[:tests_js]).distinct.pluck(:theme_id))
.order(updated_at: :desc)
.pluck(:id, :name)
end
def core
@has_test_bundle = EmberCli.has_tests?
request.env[:resolved_theme_id] = nil
target = params[:target] || "core"
@required_plugins = []
@testing_plugins = []
if target == "plugins"
@required_plugins.push(*Discourse.plugins.map(&:directory_name))
@testing_plugins.push(*Discourse.plugins.map(&:directory_name))
elsif target == "core"
# no plugins
elsif target_plugin = Discourse.plugins.find { |p| p.directory_name == target }
@required_plugins << target_plugin.directory_name
@testing_plugins << target_plugin.directory_name
target_plugin.test_required_plugins&.map do |plugin_name|
additional_plugin = Discourse.plugins.find { |p| p.directory_name == plugin_name }
@required_plugins << additional_plugin.directory_name if additional_plugin
end
@required_plugins.push(*QunitController::ALWAYS_LOADED_PLUGINS)
else
return render plain: "Target '#{target}' not found", status: :not_found
end
render "qunit"
end
def theme
raise Discourse::NotFound.new if !can_see_theme_qunit?
@@ -18,7 +60,6 @@ class QunitController < ApplicationController
@has_test_bundle = EmberCli.has_tests?
param_key = nil
@suggested_themes = nil
if (id = get_param(:id)).present?
theme = Theme.find_by(id: id.to_i)
param_key = :id
@@ -37,17 +78,6 @@ class QunitController < ApplicationController
)
end
if !param_key
@suggested_themes =
Theme
.where(
id: ThemeField.where(target_id: Theme.targets[:tests_js]).distinct.pluck(:theme_id),
)
.order(updated_at: :desc)
.pluck(:id, :name)
return
end
about_json =
JSON.parse(theme.theme_fields.find_by(target_id: Theme.targets[:about])&.value || "{}")
@required_plugins =
@@ -59,6 +89,8 @@ class QunitController < ApplicationController
request.env[:resolved_theme_id] = theme.id
request.env[:skip_theme_ids_transformation] = true
render "qunit"
end
protected
@@ -73,4 +105,8 @@ class QunitController < ApplicationController
def get_param(key)
params[:"theme_#{key}"] || params[key]
end
def ensure_locale_en
I18n.with_locale(:en) { yield }
end
end
+1 -1
View File
@@ -13,7 +13,7 @@ class RobotsTxtController < ApplicationController
DISALLOWED_PATHS = %w[
/admin/
/auth/
/assets/browser-update*.js
/assets/js/browser-update*.js
/email/
/session
/user-api-key
+18 -25
View File
@@ -130,39 +130,30 @@ module ApplicationHelper
end
if is_brotli_req?
if path.include?("/assets/js/")
path = path.sub("/assets/js/", "/assets/br/")
else
path = path.sub(/\.([^.]+)\z/, '.br.\1')
end
path = path.sub("/assets/js/", "/assets/br/")
elsif is_gzip_req?
if path.include?("/assets/js/")
path = path.sub("/assets/js/", "/assets/gz/")
else
path = path.sub(/\.([^.]+)\z/, '.gz.\1')
end
path = path.sub("/assets/js/", "/assets/gz/")
end
end
path
end
def preload_script(script, attrs: {})
scripts = []
def preload_script(script, type_module: false, attrs: {})
resolved_script = EmberCli.script_chunks[script]&.first || script
path = script_asset_path(resolved_script)
preload_script_url(path, entrypoint: script, type_module:, attrs:).html_safe
end
if chunks = EmberCli.script_chunks[script]
scripts.push(*chunks)
else
scripts.push(script)
end
def module_preloads_for(*scripts)
resolved_preload_scripts =
scripts.compact.flat_map { |script| EmberCli.script_chunks[script] }.compact.uniq
scripts
.map do |name|
path = script_asset_path(name)
preload_script_url(path, entrypoint: script, attrs: attrs)
end
.join("\n")
.html_safe
modulepreload_tags = resolved_preload_scripts.map { |script| <<~HTML }
<link rel="modulepreload" href="#{script_asset_path script}" nonce="#{csp_nonce_placeholder}">
HTML
modulepreload_tags.join("\n").html_safe
end
def preload_script_url(url, entrypoint: nil, type_module: false, attrs: nil)
@@ -995,12 +986,14 @@ module ApplicationHelper
svg_sprite_path: SvgSprite.path(theme_id),
media_optimization_bundle:
script_asset_path(
EmberCli.script_chunks["media-optimization-bundle"]&.first || "media-optimization-bundle",
EmberCli.script_chunks["media-optimization-bundle"]&.first ||
"/media-optimization-bundle.js",
),
enable_js_error_reporting: GlobalSetting.enable_js_error_reporting,
color_scheme_is_dark: dark_color_scheme?,
user_color_scheme_id: user_scheme_id || -1,
user_dark_scheme_id: user_dark_scheme_id || -1,
is_staff: staff?,
}
if Rails.env.development?
@@ -3,4 +3,4 @@
data-nonce="<%= csp_nonce_placeholder %>"
data-container-id="<%= SiteSetting.gtm_container_id %>" />
<%= preload_script 'google-tag-manager' %>
<%= preload_script 'js/google-tag-manager' %>
@@ -5,8 +5,8 @@
} %>
<% if SiteSetting.ga_version == "v3_analytics" %>
<%= preload_script "google-universal-analytics-v3" %>
<%= preload_script "js/google-universal-analytics-v3" %>
<% elsif SiteSetting.ga_version == "v4_gtag" %>
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= SiteSetting.ga_universal_tracking_code %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%= preload_script "google-universal-analytics-v4" %>
<%= preload_script "js/google-universal-analytics-v4" %>
<% end %>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Frontend build error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e;
color: #f0f0f0;
margin: 0;
padding: 2rem;
line-height: 1.5;
}
main {
max-width: 960px;
margin: 0 auto;
}
h1 {
color: #ff6b6b;
margin-top: 0;
}
pre {
background: #111;
color: #f0f0f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<main>
<h1>Frontend build error</h1>
<% error = @build_error["error"] || {} %>
<% if error["messageHtml"] %>
<pre><%= error["messageHtml"].html_safe %></pre>
<% elsif error["message"] %>
<pre><%= error["message"] %></pre>
<% end %>
<script nonce="<%= csp_nonce_placeholder %>">
const clientId = crypto.randomUUID();
const SEP = "\r\n|\r\n";
let lastId = -1;
(async function poll() {
try {
const res = await fetch(`/message-bus/${clientId}/poll`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ "/file-change": String(lastId) }),
});
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
let sep;
while ((sep = buffer.indexOf(SEP)) !== -1) {
const batch = JSON.parse(buffer.slice(0, sep));
buffer = buffer.slice(sep + SEP.length);
for (const m of batch) {
if (m.channel === "/file-change") {
return window.location.reload();
} else if (m.channel === "/__status") {
lastId = m.data["/file-change"] ?? lastId;
}
}
}
}
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
poll();
})();
</script>
</main>
</body>
</html>
+1 -3
View File
@@ -48,7 +48,7 @@
</div>
</div>
<%= preload_script('onpopstate-handler') %>
<%= preload_script('js/onpopstate-handler') %>
<%- end %>
<%- if @group&.dig(:allow_membership_requests) %>
@@ -61,5 +61,3 @@
<%= build_plugin_html 'server:not-found-before-topics' %>
<%= @topics_partial %>
+15 -11
View File
@@ -31,12 +31,11 @@
<%= build_plugin_html 'server:before-script-load' %>
<% add_resource_preload_list(script_asset_path("start-discourse"), "script") %>
<% add_resource_preload_list(script_asset_path("browser-update"), "script") %>
<link rel="preload" href="<%= script_asset_path "start-discourse" %>" as="script" nonce="<%= csp_nonce_placeholder %>">
<link rel="preload" href="<%= script_asset_path "browser-update" %>" as="script" nonce="<%= csp_nonce_placeholder %>">
<%= preload_script 'browser-detect' %>
<script nonce="<%= csp_nonce_placeholder %>">
window.EmberENV = {
_DEFAULT_ASYNC_OBSERVERS: true,
};
</script>
<%= preload_script_url ExtraLocalesController.url("main"), type_module: true %>
<%= preload_script_url ExtraLocalesController.url("mf"), type_module: true %>
@@ -53,8 +52,13 @@
<%= theme_translations_lookup %>
<%- end %>
<%= preload_script "vendor" %>
<%= preload_script "discourse" %>
<link rel="preload" href="<%= script_asset_path "js/browser-update" %>" as="script" nonce="<%= csp_nonce_placeholder %>">
<%= preload_script 'js/browser-detect' %>
<%= preload_script "vendor", type_module: true %>
<%= module_preloads_for "discourse", (staff? ? "admin/compat-modules" : nil) %>
<%- if staff? %>
<% EmberCli.script_chunks["chunk.admin"]&.each do |script_name| %>
@@ -143,12 +147,12 @@
</form>
<% end %>
<script defer src="<%= script_asset_path "start-discourse" %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%= preload_script "discourse", type_module: true %>
<script defer src="<%= script_asset_path "js/browser-update" %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%= yield :data %>
<script defer src="<%= script_asset_path "browser-update" %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%- unless customization_disabled? %>
<%= theme_lookup("body_tag") %>
<%- end %>
+1 -1
View File
@@ -13,7 +13,7 @@
<%- end %>
<meta id="data-embedded" data-referer="<%= @data_referer %>">
<%= preload_script 'embed-application' %>
<%= preload_script 'js/embed-application' %>
<%= yield :head %>
</head>
-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title><%= content_for?(:title) ? yield(:title) : SiteSetting.title %></title>
<%= discourse_stylesheet_link_tag(:ember_cli) %>
</head>
<body class="requires-ember-cli">
<div class='warning'>
<h1>Ember CLI is Required in Development Mode</h1>
<p>To run Ember CLI in development mode, please do the following:</p>
<pre><code>$ bin/ember-cli</code></pre>
<p>Then visit the following URL to use Discourse:</p>
<h3><a href="http://<%= Discourse.current_hostname %>:4200<%= Discourse.base_path %>">http://<%= Discourse.current_hostname %>:4200<%= Discourse.base_path %></a></h3>
<p>To disable this warning and allow direct Rails access, start the server with <code>ALLOW_EMBER_CLI_PROXY_BYPASS=1</code></p>
</div>
</body>
</html>
+1 -1
View File
@@ -18,7 +18,7 @@
<%= build_plugin_html 'server:before-head-close' %>
<%- end -%>
<%= preload_script "pageview" %>
<%= preload_script "js/pageview" %>
</head>
<body class="no-ember <%= @custom_body_class %>">
<%- if allow_plugins? %>
+2 -2
View File
@@ -8,8 +8,8 @@
<%= render_google_tag_manager_head_code %>
<%= render_google_universal_analytics_code %>
<%= render_adobe_analytics_tags_code %>
<%= preload_script 'publish' %>
<%= preload_script 'pageview' %>
<%= preload_script 'js/publish' %>
<%= preload_script 'js/pageview' %>
</head>
<body class="<%= @body_classes.to_a.join(' ') %>">
<%= theme_lookup("header") %>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>Theme QUnit Tests</title>
<%= discourse_color_scheme_stylesheets %>
<meta name="color-scheme" content="light dark">
<style>
html {
font-family: Arial;
}
</style>
</head>
<body>
<h2>Theme QUnit Test Runner</h2>
<%- if @suggested_themes.empty? %>
<p>Cannot find any theme tests.</p>
<%- else %>
<h3>Select a theme/component: </h3>
<%- @suggested_themes.each do |(id, name)| %>
<h4><%= link_to name, theme_qunit_path(id: id) %></h4>
<%- end %>
<%- end %>
</body>
</html>
@@ -1,75 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<title>Theme QUnit Test Runner</title>
<title>Discourse QUnit Test Runner</title>
<%= discourse_color_scheme_stylesheets %>
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<meta name="color-scheme" content="light dark">
<%- if @has_test_bundle && !@suggested_themes %>
<%- if @has_test_bundle %>
<%= preload_script_url ExtraLocalesController.url("main"), type_module: true %>
<%= preload_script_url ExtraLocalesController.url("mf"), type_module: true %>
<%= preload_script_url ExtraLocalesController.url("admin"), type_module: true %>
<%= preload_script_url ExtraLocalesController.url("wizard"), type_module: true %>
<%= preload_script_url "/bootstrap/site-settings-for-tests.js", type_module: true %>
<%= theme_translations_lookup %>
<%= preload_script "vendor" %>
<script nonce="<%= csp_nonce_placeholder %>">
window._discourseQunitPluginNames = <%= Discourse.plugins.map(&:directory_name).to_json.html_safe %>;
</script>
<%= tag.iframe srcdoc: tag.script(
src: script_asset_path(EmberCli.script_chunks["qunit-live-reload"].first),
nonce: csp_nonce_placeholder,
type: "module",
), style: "display: none;" %>
<%= preload_script "test-support" %>
<%= preload_script "discourse" %>
<%= preload_script "test" %>
<%= render "layouts/plugin_js",
opts: {
include_disabled: true,
include_admin_asset: true,
include_test_assets_for: @testing_plugins,
only: @required_plugins
}
%>
<%= preload_script_url "/bootstrap/site-settings-for-tests.js", type_module: true %>
<%= theme_lookup("head_tag") %>
<%= theme_tests %>
<%= tag.meta id: 'data-discourse-setup', data: client_side_setup_data %>
<meta property="og:title" content="">
<meta property="og:url" content="">
<meta name="twitter:title" content="">
<meta name="twitter:url" content="/">
<meta name="discourse/config/environment" content="<%=u discourse_config_environment(testing: true) %>" />
<meta name="theme-color" content="#ffffff">
<link rel="canonical" href="/">
<style>
<%= File.read("#{Rails.root}/frontend/discourse/node_modules/qunit/qunit/qunit.css").html_safe %>
#ember-testing * {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
transition: none !important;
}
</style>
<%= discourse_stylesheet_link_tag(:common, theme_id: nil) %>
<%= discourse_stylesheet_link_tag(:desktop, theme_id: nil) %>
<%= discourse_stylesheet_link_tag(:admin) %>
<%= discourse_stylesheet_link_tag(:wizard) %>
<%- Discourse.find_plugin_css_assets(include_disabled: true, only: @required_plugins, mobile_view: false, desktop_view: true, request: request).each do |file| %>
<%= discourse_stylesheet_link_tag(file) %>
<%- end %>
<%- else %>
<style>
html {
font-family: Arial;
}
</style>
<%- end %>
<%- if params['testem'] %>
<script defer src="/assets/testem.js" nonce="<%= csp_nonce_placeholder %>"></script>
<script defer src="/testem.js" nonce="<%= csp_nonce_placeholder %>"></script>
<%- end %>
</head>
<body>
<%- if !@has_test_bundle %>
This is a production installation of Discourse, and cannot be used for theme testing.
For more information, see <a href="https://meta.discourse.org/t/66857">this guide</a>.
<% elsif @suggested_themes %>
<h2>Theme QUnit Test Runner</h2>
<%- if @suggested_themes.empty? %>
<p>Cannot find any theme tests.</p>
<%- else %>
<h3>Select a theme/component: </h3>
<%- @suggested_themes.each do |(id, name)| %>
<h4><%= link_to name, theme_qunit_path(id: id) %></h4>
<%- end %>
<%- end %>
<% else %>
<%= preload_script "scripts/discourse-test-listen-boot" %>
<%= preload_script "scripts/discourse-boot" %>
<discourse-assets-icons></discourse-assets-icons>
<script nonce="<%= csp_nonce_placeholder %>">
window.EmberENV = {
_DEFAULT_ASYNC_OBSERVERS: true,
};
</script>
<%= preload_script "test-entrypoint", type_module: true %>
<%- end %>
<%= discourse_stylesheet_link_tag("qunit-custom", theme_id: nil) %>
+1 -1
View File
@@ -197,7 +197,7 @@
<% if @topic_view.print %>
<% content_for :after_body do %>
<%= preload_script('print-page') %>
<%= preload_script('js/print-page') %>
<% end %>
<% end %>
<% end %>
+88 -2
View File
@@ -1,3 +1,89 @@
#!/bin/bash
#!/usr/bin/env node
/* eslint-disable no-console */
bin/ember-cli -u
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { parseArgs } = require("util");
const concurrently = require("concurrently");
const RAILS_ROOT = path.resolve(__dirname, "..");
let nodeModulesOutdated = false;
try {
const installed = fs.readFileSync(
path.join(RAILS_ROOT, "node_modules/.pnpm/lock.yaml"),
"utf8"
);
const lockfile = fs.readFileSync(
path.join(RAILS_ROOT, "pnpm-lock.yaml"),
"utf8"
);
nodeModulesOutdated = installed !== lockfile;
} catch (e) {
nodeModulesOutdated = true;
}
if (nodeModulesOutdated) {
console.log(
"[bin/dev] Detected outdated or missing node_modules. Running pnpm install..."
);
const result = spawnSync("pnpm", [`--dir=${RAILS_ROOT}`, "install"], {
stdio: "inherit",
});
if (result.status !== 0) {
if (result.error && result.error.code === "ENOENT") {
console.error("pnpm is not installed. run `npm install -g pnpm`");
}
process.exit(1);
}
}
const serverEnv = { ...process.env };
serverEnv.UNICORN_PORT ||= "3000";
if (process.env.CODESPACE_NAME) {
serverEnv.DISCOURSE_PORT = "443";
serverEnv.DISCOURSE_FORCE_HTTPS = "1";
serverEnv.DISCOURSE_FORCE_HOSTNAME = `${process.env.CODESPACE_NAME}-${serverEnv.UNICORN_PORT}.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`;
}
const { values: args } = parseArgs({
options: { only: { type: "string" } },
});
const commands = [];
if (!args.only || args.only === "rails") {
commands.push({
name: "rails",
command: path.join(RAILS_ROOT, "bin/pitchfork"),
cwd: RAILS_ROOT,
env: serverEnv,
prefixColor: "#22c4f1",
});
}
if (!args.only || args.only === "ember") {
commands.push({
name: "ember",
command: "./rolldown.mjs",
cwd: path.join(RAILS_ROOT, "frontend/discourse"),
prefixColor: "#f27a21",
});
}
if (commands.length === 0) {
console.error(`[bin/dev] --only must be 'rails' or 'ember'`);
process.exit(1);
}
const { result } = concurrently(commands, {
killOthersOn: ["success", "failure"],
killSignal: "SIGTERM",
});
result.then(
() => process.exit(0),
() => process.exit(1)
);
-2
View File
@@ -100,7 +100,6 @@ fi
docker run -d \
-p $local_publish:8025:8025 \
-p $local_publish:3000:3000 \
-p $local_publish:4200:4200 \
-p $local_publish:9292:9292 \
-p $local_publish:9405:9405 \
-v "$DATA_DIR:/shared/postgres_data:delegated" \
@@ -128,4 +127,3 @@ if [ "${initialize}" = "initialize" ]; then
echo "Creating admin user..."
"${SCRIPTPATH}/rake" admin:create
fi
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exec "$(dirname "$0")/exec" bin/dev "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
exec "$(dirname "$0")/exec" bin/ember-cli "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exec "$(dirname "$0")/exec" pnpm "$@"
+84 -162
View File
@@ -1,182 +1,104 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "pathname"
require "open3"
require "webrick"
RAILS_ROOT = File.expand_path("../../", Pathname.new(__FILE__).realpath)
PORT = ENV["UNICORN_PORT"] ||= "3000"
HOSTNAME = ENV["DISCOURSE_HOSTNAME"] ||= "127.0.0.1"
CUSTOM_ARGS = %w[--try --test --build --server --unicorn -u --forward-host]
PROXY =
if ARGV.include?("--try")
"https://try.discourse.org"
else
"http://#{HOSTNAME}:#{PORT}"
end
def process_running?(pid)
!!Process.kill(0, pid)
rescue Errno::ESRCH
false
end
def kill_tree(root_pid, signal)
return unless root_pid
children = `pgrep -P #{root_pid} 2>/dev/null`.to_s.split.map(&:to_i)
children.each { |c| kill_tree(c, signal) }
Process.kill(signal, root_pid)
rescue Errno::ESRCH, Errno::EPERM
# already gone
end
command =
if ARGV.include?("--test")
"test"
elsif ARGV.include?("--build")
"build"
else
"server"
end
class String
def cyan
"\e[36m#{self}\e[0m"
end
def red
"\033[31m#{self}\e[0m"
end
end
ALLOWED_ARGS = %w[-u --build]
EXTRA_ARGS = ARGV - ALLOWED_ARGS
if ARGV.include?("-h") || ARGV.include?("--help")
puts "ember-cli OPTIONS"
puts "#{"--try".cyan} To proxy try.discourse.org"
puts "#{"--test".cyan} To run the test suite"
puts "#{"--server, -u".cyan} To run a server as well"
puts "The rest of the arguments are passed to ember server per:", ""
exec "pnpm ember #{command} --help"
puts <<~MSG
bin/ember-cli is deprecated. Use bin/dev instead.
Usage (backwards-compatible):
bin/ember-cli Runs `bin/dev --only ember` (frontend bundler only)
bin/ember-cli -u Runs `bin/dev` (Rails + frontend bundler)
bin/ember-cli --build Runs `pnpm build` (one-off frontend build, no watching)
MSG
exit 0
end
args = ["--dir=frontend/discourse", "ember", command] + (ARGV - CUSTOM_ARGS)
abort <<~MSG if !EXTRA_ARGS.empty?
bin/ember-cli no longer accepts arguments: #{EXTRA_ARGS.join(" ")}
if !args.include?("test") && !args.include?("build") && !args.include?("--proxy")
args << "--proxy"
args << PROXY
end
Discourse now runs from a single port via `bin/dev`. Please switch to:
bin/dev
node_modules_outdated =
begin
File.read("node_modules/.pnpm/lock.yaml") != File.read("pnpm-lock.yaml")
rescue Errno::ENOENT
true
bin/ember-cli is retained only for backwards compatibility and supports:
bin/ember-cli (equivalent to `bin/dev --only ember`)
bin/ember-cli -u (equivalent to `bin/dev`)
bin/ember-cli --build (equivalent to `pnpm build`)
MSG
child_command, display_command, run_stub_server =
if ARGV.include?("--build")
[%w[pnpm build], "pnpm build", false]
elsif ARGV.include?("-u")
[[File.expand_path("dev", __dir__)], "bin/dev", true]
else
[[File.expand_path("dev", __dir__), "--only", "ember"], "bin/dev --only ember", true]
end
if node_modules_outdated
puts "[bin/ember-cli] Detected outdated or missing node_modules. Running pnpm install..."
warn "\e[31m[bin/ember-cli] DEPRECATED: please use `#{display_command}` instead...\e[0m"
if !system "pnpm", "--dir=#{RAILS_ROOT}", "install"
if !system("command -v pnpm >/dev/null;")
abort "pnpm is not installed. run `npm install -g pnpm`"
end
exit 1
end
end
stub_port = (ENV["EMBER_CLI_STUB_PORT"] || 4200).to_i
target_port = ENV["UNICORN_PORT"] || "3000"
pnpm_env = {
"TERM" => "dumb", # simple output from ember-cli, so we can parse/forward it more easily
}
pnpm_env["FORWARD_HOST"] = "true" if ARGV.include?("--forward-host")
stub_message = <<~MSG
<!doctype html>
<html>
<head>
<title>Ember CLI Removed</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 36rem; margin: 4rem auto; padding: 0 1rem; color: #222; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
code { background: #f3f3f3; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.95em; }
a { color: #08c; }
</style>
</head>
<body>
<h1>Ember CLI has been replaced with Rolldown</h1>
<p>Discourse is now served from a single port in development. Visit <a href="http://localhost:#{target_port}">http://localhost:#{target_port}</a>.</p>
<p>To start Rails and the Rolldown build in a single command, use <code>bin/dev</code>.</p>
<p>To launch the standalone frontend bundler, use <code>bin/dev --only ember</code>.</p>
</body>
</html>
MSG
if ARGV.include?("-u") || ARGV.include?("--server") || ARGV.include?("--unicorn")
server_env = { "DISCOURSE_PORT" => ENV["DISCOURSE_PORT"] || "4200" }
if command == "server" && ENV["CODESPACE_NAME"]
server_env.merge!(
{
"DISCOURSE_PORT" => "443",
"DISCOURSE_FORCE_HTTPS" => "1",
"DISCOURSE_FORCE_HOSTNAME" =>
"#{ENV["CODESPACE_NAME"]}-4200.#{ENV["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"]}",
},
)
end
# Own process group so terminal Ctrl+C only hits bin/ember-cli.
# The INT trap below is the only signaller.
server_pid = spawn(server_env, "#{__dir__}/pitchfork", pgroup: true)
ember_cli_pid = nil
sigint_received = false
Thread.new do
Open3.popen2e(pnpm_env, "pnpm", *args.to_a.flatten) do |i, oe, t|
ember_cli_pid = t.pid
puts "Ember CLI running on PID: #{ember_cli_pid}"
oe.each do |line|
if line.include?("\e[32m200\e") || line.include?("\e[36m304\e[0m") ||
line.include?("POST /message-bus")
# skip 200s and 304s and message bus
else
puts "[ember-cli] #{line}"
end
end
end
# On Ctrl+C the INT trap already TERMs the server. Only do it here if
# ember-cli exited on its own.
if !sigint_received && process_running?(server_pid)
puts "[bin/ember-cli] ember-cli process stopped. Terminating server."
Process.kill("TERM", server_pid)
stub_server =
if run_stub_server
begin
WEBrick::HTTPServer.new(
Port: stub_port,
BindAddress: "127.0.0.1",
Logger: WEBrick::Log.new(File::NULL),
AccessLog: [],
)
rescue Errno::EADDRINUSE
warn "[bin/ember-cli] Port #{stub_port} already in use; skipping deprecation stub server."
nil
end
end
int_count = 0
trap("INT") do
sigint_received = true
int_count += 1
if int_count == 1
# TERM the supervisor. Its trap forwards to the master, which gracefully
# stops workers and Demon::Sidekiq.
begin
Process.kill("TERM", server_pid)
rescue StandardError
nil
end
if ember_cli_pid
begin
Process.kill("TERM", ember_cli_pid)
rescue StandardError
nil
end
end
# If shutdown drags, show the force-quit option.
Thread.new do
sleep 2
if process_running?(server_pid)
puts "\n[bin/ember-cli] Shutting down... press Ctrl+C again to force quit."
end
end
else
puts "\n[bin/ember-cli] Forcing shutdown..."
# Catch orphans (e.g. stuck sidekiq).
kill_tree(server_pid, "KILL")
kill_tree(ember_cli_pid, "KILL") if ember_cli_pid
exit!(1)
end
if stub_server
stub_server.mount_proc "/" do |_req, res|
res.status = 410
res["Content-Type"] = "text/html; charset=utf-8"
res.body = stub_message
end
Process.wait(server_pid)
# Drain pgroup so shutdown messages don't print after the exit.
deadline = Time.now + 10
sleep 0.05 while Time.now < deadline && !`pgrep -g #{server_pid} 2>/dev/null`.strip.empty?
if ember_cli_pid && process_running?(ember_cli_pid)
puts "[bin/ember-cli] server process stopped. Terminating ember-cli."
Process.kill("TERM", ember_cli_pid)
end
else
exec(pnpm_env, "pnpm", *args.to_a.flatten)
Thread.new { stub_server.start }
end
child_pid = spawn(*child_command)
# Children in the foreground process group already receive INT from the terminal;
# swallow it here so we can clean up the stub server.
trap("INT") {}
begin
Process.wait(child_pid)
ensure
stub_server&.shutdown
end
exit($?.exitstatus || 0)
+3 -4
View File
@@ -503,11 +503,9 @@ class QunitRunner
def build_theme_test_pages(query)
pages =
if ENV["THEME_IDS"] && !ENV["THEME_IDS"].empty?
ENV["THEME_IDS"]
.split("|")
.map { |theme_id| "#{@qunit_path}?#{query}&testem=1&id=#{theme_id}" }
ENV["THEME_IDS"].split("|").map { |theme_id| "#{@qunit_path}?#{query}&id=#{theme_id}" }
else
["#{@qunit_path}?#{query}&testem=1"]
["#{@qunit_path}?#{query}"]
end
pages.shuffle.join(",")
@@ -532,6 +530,7 @@ class QunitRunner
params["theme_id"] = @theme_id if @theme_id
params["target"] = resolved_target if resolved_target && !@plugin_targets.any?
params["report_requests"] = "1" if @report_requests
params["testem"] = "1"
encode_query_string(params)
end
+2 -1
View File
@@ -54,7 +54,8 @@ if defined?(Rack::MiniProfiler) && defined?(Rack::MiniProfiler::Config)
/topics/timings
/uploads/
/user_avatar/
].map { |path| "#{Discourse.base_path}#{path}" }.concat([/.*theme-qunit/])
/theme-qunit/
].map { |path| "#{Discourse.base_path}#{path}" }.concat([%r{/(\d+/)?(theme-qunit|tests)}])
# we DO NOT WANT mini-profiler loading on anything but real desktops and laptops
# so let's rule out all handheld, tablet, and mobile devices
+4
View File
@@ -12,6 +12,8 @@ Rails.application.config.assets.version = "2-#{GlobalSetting.asset_url_salt}"
Rails.application.config.assets.paths.push(
"#{Rails.public_path.join("javascripts")}",
"#{Rails.root.join("frontend/discourse/dist/assets")}",
"#{Rails.root.join("frontend/discourse/dist/@embroider/virtual")}",
"#{Rails.root.join("frontend/discourse/scripts")}",
)
Rails.application.config.assets.paths.push(
@@ -29,3 +31,5 @@ Rails.application.config.assets.excluded_paths.push(
Rails.application.config.assets.compilers.filter! do |type, compiler|
type == "text/javascript" && compiler == Propshaft::Compiler::SourceMappingUrls
end
Mime::Type.register "application/wasm", :wasm
+2
View File
@@ -137,6 +137,8 @@ before_service_worker_ready do |server, service_worker|
end
if Rails.env.development?
EmberCli.watch!
workers = server.worker_processes
parts = ["#{workers} worker#{"s" if workers != 1}"]
parts << "#{sidekiqs} sidekiq#{"s" if sidekiqs != 1}" if sidekiqs > 0
+12 -10
View File
@@ -25,9 +25,7 @@ Discourse::Application.routes.draw do
get "/404-body" => "exceptions#not_found_body"
if Rails.env.local?
get "/bootstrap/core-css-for-tests.css" => "bootstrap#core_css_for_tests"
get "/bootstrap/site-settings-for-tests.js" => "bootstrap#site_settings_for_tests"
get "/bootstrap/plugin-test-info" => "bootstrap#plugin_test_info"
end
# This is not a valid production route and is causing routing errors to be raised in
@@ -1903,17 +1901,21 @@ Discourse::Application.routes.draw do
get "/dev-mode" => "dev_mode#index"
post "/dev-mode" => "dev_mode#enter", :as => "dev_mode_enter"
get "/theme-qunit" => "qunit#index",
:constraints => ->(req) do
req.params["id"].nil? && req.params["name"].nil? && req.params["url"].nil?
end
get "/theme-qunit" => "qunit#theme"
get "/theme-tests", to: redirect("/theme-qunit")
# This is a special route that is used when theme QUnit tests are run through testem which appends a testem_id to the
# path. Unfortunately, testem's proxy support does not allow us to easily remove this from the path, so we have to
# handle it here.
if Rails.env.development?
get "/testem-theme-qunit/:testem_id/theme-qunit" => "qunit#theme",
:constraints => {
testem_id: /\d+/,
}
if Rails.env.local?
get "/tests" => "qunit#core"
# This is a special route that is used when theme QUnit tests are run through testem which appends a testem_id to the
# path. Unfortunately, testem's proxy support does not allow us to easily remove this from the path, so we have to
# handle it here.
get "/:testem_id/theme-qunit" => "qunit#theme", :constraints => { testem_id: /\d+/ }
get "/:testem_id/tests" => "qunit#core", :constraints => { testem_id: /\d+/ }
end
post "/push_notifications/subscribe" => "push_notification#subscribe"
@@ -34,7 +34,7 @@ Dev Containers can be used in a number of different IDEs, or directly using thei
1. Run the `dev/admin/create` task. Open the command palette and search for "Tasks: Run Tasks". It will present a menu of tasks; select `dev/admin/create` off of that list. You'll be prompted to enter an email address and a password for your admin user.
1. Visit `http://localhost:4200` in your browser to see your new Discourse instance
1. Visit `http://localhost:3000` in your browser to see your new Discourse instance
1. All done! You can now make changes to Discourse's source code and see them reflected in the preview.
@@ -28,7 +28,7 @@ id: github-codespaces
1. Run the `dev/admin/create` task. Open the command palette with <kbd>Cmd/Ctrl + Shift + P</kbd> and search for "Tasks: Run Tasks". It will present a menu of tasks; select `dev/admin/create` off of that list. You'll be prompted to enter an email address and a password for your admin user.
1. Visit the "Ports" tab, and click the :globe_with_meridians: button for port 4200. This will open a new tab showing your development copy of Discourse
1. Visit the "Ports" tab, and click the :globe_with_meridians: button for port 3000. This will open a new tab showing your development copy of Discourse
![Codespaces ports tab|1198x282,40%](/assets/codespaces-4.png)
@@ -70,10 +70,10 @@ d/boot_dev --init
d/rails s
# And in a separate terminal
d/ember-cli
d/dev --only ember
```
...then open a browser on http://localhost:4200 and _voila!_, you should see Discourse.
...then open a browser on http://localhost:3000 and _voila!_, you should see Discourse.
## Plugin Symlinks
@@ -77,7 +77,7 @@ RAILS_ENV=test bundle exec rake db:create db:migrate
Start rails + Ember servers, you have two options here.
**Option 1**: using two separate Terminal tabs/windows, run Rails and Ember CLI separately via
**Option 1**: using two separate Terminal tabs/windows, run Rails and the frontend bundler separately via
```sh
bundle exec rails server
@@ -86,16 +86,16 @@ bundle exec rails server
and
```sh
bin/ember-cli
bin/dev --only ember
```
**Option 2**: using only one Terminal tab/window:
```sh
bin/ember-cli -u # will run the Pitchfork server in the background
bin/dev # runs Pitchfork and the frontend bundler together
```
:tada: You should now be able to navigate to [http://localhost:4200](http://localhost:4200) to see your local Discourse installation. (Note that the first load can take up to a minute as the server is warmed up.)
:tada: You should now be able to navigate to [http://localhost:3000](http://localhost:3000) to see your local Discourse installation. (Note that the first load can take up to a minute as the server is warmed up.)
You can also try running the specs:
@@ -96,20 +96,20 @@ bin/rails db:migrate
RAILS_ENV=test bin/rails db:create db:migrate
```
Start rails and ember server:
Start rails and the frontend bundler:
```sh
bin/ember-cli -u
bin/dev
```
If the images are not appearing, use this command instead:
(_you can also specify an IP if you are working on a remote server_)
```sh
DISCOURSE_HOSTNAME=localhost UNICORN_LISTENER=localhost:3000 bin/ember-cli -u
DISCOURSE_HOSTNAME=localhost UNICORN_LISTENER=localhost:3000 bin/dev
```
You should now be able to navigate to [http://localhost:4200](http://localhost:4200) to see your local Discourse installation.
You should now be able to navigate to [http://localhost:3000](http://localhost:3000) to see your local Discourse installation.
## Create New Admin
@@ -110,15 +110,13 @@ bundle exec rails server
You should now be able to connect with your Discourse app on [http://localhost:3000](http://localhost:3000) - try it out!
**Starting with Discourse 2.5+ EmberCLI is required in development and these additional steps will be required:**
In a separate terminal instance, navigate to your discourse folder (`cd ~/discourse`) and run:
```sh
bin/ember-cli
bin/dev --only ember
```
You should now be able to navigate to [http://localhost:4200](http://localhost:4200) to see your local Discourse installation.
This starts the frontend bundler; the app remains served from [http://localhost:3000](http://localhost:3000).
[/quote]
## Creating a Command to Start Discourse
@@ -16,6 +16,6 @@ If you've followed the [instructions to set up your local discourse](https://met
1. Re-start the server.
1. If the plugin has settings, you can edit them by going to `http://localhost:4200/admin/plugins` and clicking on "Settings" next to its name.
1. If the plugin has settings, you can edit them by going to `http://localhost:3000/admin/plugins` and clicking on "Settings" next to its name.
If you'd like to install a plugin in production, [follow this guide](https://meta.discourse.org/t/install-plugins-in-discourse/19157/1).
@@ -33,10 +33,10 @@ rake db:migrate
_In general, however, running rake tasks without `RAILS_DB` set will target the default site._
To access the site, you'll need to run ember-cli with the `--forward-host` option.
To access the site, start the dev server:
```sh
bin/ember-cli -u --forward-host
bin/dev
```
You may now be able to view the your new site at http://alternate.localhost:4200, but if you cannot, you may need to add `alternate.localhost` to your `/etc/hosts` file or equivalent.
You may now be able to view the your new site at http://alternate.localhost:3000, but if you cannot, you may need to add `alternate.localhost` to your `/etc/hosts` file or equivalent.
@@ -37,21 +37,21 @@ To obtain two different URLs, there are two main approaches:
If your change is small enough to be easily feature-flagged, then you could add logic to toggle it based on a **URL query parameter**. Then your two urls could be
```
http://localhost:4200?flag=before
http://localhost:4200?flag=after
http://localhost:3000?flag=before
http://localhost:3000?flag=after
```
If the change is too large for that, then you could clone Discourse into a second directory and launch a **second copy of ember-cli**. It can be proxied to the same Rails server using a command like
If the change is too large for that, then you could clone Discourse into a second directory and launch a **second copy of rails**.
```sh
EMBER_ENV=production pnpm ember serve --port 4201 --proxy http://localhost:3000
EMBER_ENV=production UNICORN_PORT=3001 bin/dev
```
And then your two URLs would be
```
http://localhost:4200
http://localhost:4201
http://localhost:3000
http://localhost:3001
```
If you take this approach, make sure that both copies of the app have the performance telemetry you introduced in step 1 of this guide
@@ -72,11 +72,11 @@ This is my `bench.json` file, which will take 300 samples of each target:
},
"expand": [
{
"url": "http://localhost:4200",
"url": "http://localhost:3000",
"name": "before"
},
{
"url": "http://localhost:4201",
"url": "http://localhost:3001",
"name": "after"
}
]
@@ -10,13 +10,13 @@ Rails system specs are used to simulate the actions of a real user using the app
We currently only support running system specs in Chrome, make sure you have Chrome installed before proceeding. Run `pnpm i` to ensure Playwright is correctly set up.
Since the Discourse app is an Ember Single Page Application, there are some unique constraints and challenges to writing system specs. It's important to keep in mind that you should always be observing for changes in the DOM in your tests, not manually waiting for things to happen or adding artificial sleep time. Also, the JavaScript build is separate from the Rails server, which means you must be running Ember CLI when writing system specs.
Since the Discourse app is an Ember Single Page Application, there are some unique constraints and challenges to writing system specs. It's important to keep in mind that you should always be observing for changes in the DOM in your tests, not manually waiting for things to happen or adding artificial sleep time. Also, the JavaScript build is separate from the Rails server, which means you must be running the frontend bundler when writing system specs.
## Running system specs
Any system spec can be run with the `bin/rspec FILENAME.rb` command. By default the specs are run in a headless version of Chrome, meaning no browser window will open while the spec is running.
> :warning: If you do not already have the Discourse rails server running with `bin/ember-cli -u`, you will need to run `bin/ember-cli --build` after every JavaScript change to see these reflected in the headless browser. **It is recommended you just keep your local server running while writing system specs.**
> :warning: If you do not already have the Discourse rails server running with `bin/dev`, you will need to run `pnpm --dir=frontend/discourse build` after every JavaScript change to see these reflected in the headless browser. **It is recommended you just keep your local server running while writing system specs.**
>
> Also, ensure you run rails migrations any time you make modifications to your local database schema.
@@ -34,11 +34,11 @@ Once you've created this file, you should restart your local server and the plug
### An important Gotcha!
If you're used to regular rails development you might notice that plugins aren't quite as nice when it comes to reloading. In general, when you make changes to your plugin, you should <kbd>Ctrl</kbd>+<kbd>c</kbd> the server to stop it running, then run it again using `bin/ember-cli -u`.
If you're used to regular rails development you might notice that plugins aren't quite as nice when it comes to reloading. In general, when you make changes to your plugin, you should <kbd>Ctrl</kbd>+<kbd>c</kbd> the server to stop it running, then run it again using `bin/dev`.
### My changes weren't picked up! :warning:
Sometimes the cache isn't cleared fully, especially when you create new files or delete old files. To get around this issue, remove your `tmp` folder and start rails again. On a mac you can do it in one command: `rm -rf tmp; bin/ember-cli -u`.
Sometimes the cache isn't cleared fully, especially when you create new files or delete old files. To get around this issue, remove your `tmp` folder and start rails again. On a mac you can do it in one command: `rm -rf tmp; bin/dev`.
### Checking that your plugin was loaded
@@ -10,7 +10,7 @@ Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-par
Did you know that Discourse has two large test suites for its code base? On the server side, our Ruby code has a test suite that uses [rspec](https://rspec.info/). For the browser application, we have a [qunit](https://qunitjs.com/) suite that has [ember-testing](https://guides.emberjs.com/release/testing/testing-application/) included.
Assuming you have a development environment set up, if you visit the `http://localhost:4200/tests` URL you will start running the JavaScript test suite in your browser. One fun aspect is that you can see it testing the application in a miniature window in the bottom right corner:
Assuming you have a development environment set up, if you visit the `http://localhost:3000/tests` URL you will start running the JavaScript test suite in your browser. One fun aspect is that you can see it testing the application in a miniature window in the bottom right corner:
<img src="//assets-meta-cdck-prod-meta.s3.dualstack.us-west-1.amazonaws.com/original/3X/6/2/62a63eca67d134def1580fd9fbd84ff62b531ee1.png" width="690" height="481">
@@ -76,7 +76,7 @@ After we simulate a click on the button, we can check whether the tentacle appea
assert.ok(exists(".tentacle"), "the tentacle wants to rule the world!");
```
Not too bad is it? You can try the test yourself by visiting `http://localhost:4200/tests?qunit_single_plugin=purple-tentacle&qunit_skip_core=1` on your development machine. You should very quickly see the purple tentacle appear and all tests will pass.
Not too bad is it? You can try the test yourself by visiting `http://localhost:3000/tests?qunit_single_plugin=purple-tentacle&qunit_skip_core=1` on your development machine. You should very quickly see the purple tentacle appear and all tests will pass.
If you want to run the plugin qunit tests on the command line using PhantomJS, you can run
@@ -18,7 +18,7 @@ In your local terminal, navigate to a directory where you want to store your new
When setup is done, you'll see `✅ Done!`, and you'll be prompted to start "watching" the theme. Answer yes, and then work through the watching setup.
For the root URL, enter the base URL of the Discourse site you'd like to sync the theme to. For a local development environment, use something like `http://localhost:4200`. For a production site, use something like `https://meta.discourse.org`. Or for Theme Creator, use `https://discourse.theme-creator.io`.
For the root URL, enter the base URL of the Discourse site you'd like to sync the theme to. For a local development environment, use something like `http://localhost:3000`. For a production site, use something like `https://meta.discourse.org`. Or for Theme Creator, use `https://discourse.theme-creator.io`.
Now you'll need to generate an API key. For a local development environment or production forum, visit the "API Keys" section of the admin panel, choose "Add API key", and create one associated with your user account, and a "Global" scope. Then paste it into the `discourse_theme` CLI.
+9
View File
@@ -30,4 +30,13 @@ export default [
},
},
},
{
languageOptions: {
parserOptions: {
babelOptions: {
configFile: false,
},
},
},
},
];
-1
View File
@@ -1 +0,0 @@
engine-strict = true
-329
View File
@@ -1,329 +0,0 @@
"use strict";
const express = require("express");
const cleanBaseURL = require("clean-base-url");
const path = require("path");
const fs = require("fs");
const fsPromises = fs.promises;
const { Buffer } = require("node:buffer");
const { env } = require("node:process");
const { glob } = require("glob");
const { HTMLRewriter } = require("html-rewriter-wasm");
async function listDistAssets(outputPath) {
const files = await glob("**/*.js", {
nodir: true,
cwd: `${outputPath}/assets`,
});
return new Set(files);
}
function updateScriptReferences({
chunkInfos,
rewriter,
selector,
attribute,
baseURL,
distAssets,
}) {
const handledEntrypoints = new Set();
rewriter.on(selector, {
element(element) {
const entrypointName = element.getAttribute("data-discourse-entrypoint");
if (handledEntrypoints.has(entrypointName)) {
element.remove();
return;
}
let chunks = chunkInfos[`assets/${entrypointName}.js`]?.assets;
if (!chunks) {
if (distAssets.has(`${entrypointName}.js`)) {
chunks = [`assets/${entrypointName}.js`];
} else if (entrypointName === "vendor") {
// support embroider-fingerprinted vendor when running with `-prod` flag
const vendorFilename = [...distAssets].find((key) =>
key.startsWith("vendor.")
);
chunks = [`assets/${vendorFilename}`];
} else {
// Not an ember-cli asset, do not rewrite
return;
}
}
const newElements = chunks.map((chunk) => {
let newElement = `<${element.tagName}`;
for (const [attr, value] of element.attributes) {
if (attr === attribute) {
newElement += ` ${attribute}="${baseURL}${chunk}"`;
} else if (value === "") {
newElement += ` ${attr}`;
} else {
newElement += ` ${attr}="${value}"`;
}
}
newElement += ` data-ember-cli-rewritten="true"`;
newElement += `>`;
if (element.tagName === "script") {
newElement += `</script>`;
}
return newElement;
});
if (
entrypointName === "discourse" &&
element.tagName.toLowerCase() === "script"
) {
let nonce = "";
for (const [attr, value] of element.attributes) {
if (attr === "nonce") {
nonce = value;
break;
}
}
if (!nonce) {
// eslint-disable-next-line no-console
console.error(
"Expected to find a nonce= attribute on the main discourse script tag, but none was found. ember-cli-live-reload may not work correctly."
);
}
// ember-cli-live-reload doesn't select ports correctly, so we use _lr/livereload directly
// (important for cloud development environments like GitHub CodeSpaces)
newElements.unshift(
`<script nonce="${nonce}">window.LiveReloadOptions = { "path": "_lr/livereload", "host": location.hostname, "port": location.port || (location.protocol === "https:" ? 443 : 80), "https": location.protocol === "https:" }</script>`,
`<script async src="/_lr/livereload.js" nonce="${nonce}"></script>`
);
}
element.replace(newElements.join("\n"), { html: true });
handledEntrypoints.add(entrypointName);
},
});
}
async function handleRequest(proxy, baseURL, req, res, outputPath) {
// x-forwarded-host is used in e.g. GitHub CodeSpaces
let originalHost = req.headers["x-forwarded-host"] || req.headers.host;
if (env["FORWARD_HOST"] === "true") {
if (/^localhost(\:|$)/.test(originalHost)) {
// Can't access default site in multisite via "localhost", redirect to 127.0.0.1
res.redirect(
307,
`http://${originalHost.replace("localhost", "127.0.0.1")}${req.path}`
);
return;
} else {
req.headers.host = originalHost;
}
} else {
req.headers.host = new URL(proxy).host;
}
if (req.headers["Origin"]) {
req.headers["Origin"] = req.headers["Origin"]
.replace(req.headers.host, originalHost)
.replace(/^https/, "http");
}
if (req.headers["Referer"]) {
req.headers["Referer"] = req.headers["Referer"]
.replace(req.headers.host, originalHost)
.replace(/^https/, "http");
}
let url = `${proxy}${req.path}`;
const queryLoc = req.url.indexOf("?");
if (queryLoc !== -1) {
url += req.url.slice(queryLoc);
}
if (req.method === "GET") {
req.headers["X-Discourse-Ember-CLI"] = "true";
}
const { default: fetch } = await import("node-fetch");
const response = await fetch(url, {
method: req.method,
body: /GET|HEAD/.test(req.method) ? null : req.body,
headers: req.headers,
redirect: "manual",
});
response.headers.forEach((value, header) => {
if (header === "set-cookie") {
// Special handling to get array of multiple Set-Cookie header values
// per https://github.com/node-fetch/node-fetch/issues/251#issuecomment-428143940
res.set("set-cookie", response.headers.raw()["set-cookie"]);
} else {
res.set(header, value);
}
});
res.set("content-encoding", null);
const location = response.headers.get("location");
if (location) {
const newLocation = location.replace(proxy, `http://${originalHost}`);
res.set("location", newLocation);
}
const contentType = response.headers.get("content-type");
const isHTML = contentType?.startsWith("text/html");
res.status(response.status);
if (isHTML) {
const [responseText, chunkInfoText, distAssets] = await Promise.all([
response.text(),
fsPromises.readFile(`${outputPath}/assets.json`, "utf-8"),
listDistAssets(outputPath),
]);
const chunkInfos = JSON.parse(chunkInfoText);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let output = "";
const rewriter = new HTMLRewriter((outputChunk) => {
output += decoder.decode(outputChunk);
});
updateScriptReferences({
chunkInfos,
rewriter,
selector: "script[data-discourse-entrypoint]",
attribute: "src",
baseURL,
distAssets,
});
updateScriptReferences({
chunkInfos,
rewriter,
selector: "link[rel=preload][data-discourse-entrypoint]",
attribute: "href",
baseURL,
distAssets,
});
try {
await rewriter.write(encoder.encode(responseText));
await rewriter.end();
} finally {
rewriter.free();
}
res.send(output);
} else {
res.send(Buffer.from(await response.arrayBuffer()));
}
}
module.exports = {
name: require("./package").name,
isDevelopingAddon() {
return true;
},
serverMiddleware(config) {
const app = config.app;
let { proxy, rootURL, baseURL } = config.options;
const outputPath = config.options.path ?? config.options.outputPath;
if (!proxy) {
// eslint-disable-next-line no-console
console.error(`
Discourse can't be run without a \`--proxy\` setting, because it needs a Rails application
to serve API requests. For example:
pnpm ember serve --proxy "http://localhost:3000"\n`);
throw "--proxy argument is required";
}
baseURL = rootURL === "" ? "/" : cleanBaseURL(rootURL || baseURL);
const rawMiddleware = express.raw({ type: () => true, limit: "100mb" });
const pathRestrictedRawMiddleware = (req, res, next) => {
if (this.shouldHandleRequest(req, baseURL)) {
return rawMiddleware(req, res, next);
} else {
return next();
}
};
app.use(
"/favicon.ico",
express.static(
path.join(
__dirname,
"../../../../../../public/images/discourse-logo-sketch-small.png"
)
)
);
app.use(pathRestrictedRawMiddleware, async (req, res, next) => {
try {
if (this.shouldHandleRequest(req, baseURL)) {
await handleRequest(proxy, baseURL, req, res, outputPath);
} else {
// Fixes issues when using e.g. "localhost" instead of loopback IP address
req.headers.host = "127.0.0.1";
}
} catch (error) {
res.send(`
<html>
<h1>Discourse Ember CLI Proxy Error</h1>
<pre><code>${error.stack}</code></pre>
</html>
`);
} finally {
if (!res.headersSent) {
next();
}
}
});
},
shouldHandleRequest(request, baseURL) {
if (
[
`${baseURL}tests/index.html`,
`${baseURL}ember-cli-live-reload.js`,
`${baseURL}testem.js`,
`${baseURL}assets/test-i18n.js`,
].includes(request.path)
) {
return false;
}
// All JS assets are served by Ember CLI, except for
// plugin assets which end in _extra.js
if (
request.path.startsWith(`${baseURL}assets/`) &&
!request.path.endsWith("_extra.js")
) {
return false;
}
if (request.path.startsWith(`${baseURL}_lr/`)) {
return false;
}
if (request.path.startsWith(`${baseURL}message-bus/`)) {
return false;
}
return true;
},
};
-33
View File
@@ -1,33 +0,0 @@
{
"name": "custom-proxy",
"version": "1.0.0",
"description": "Express.js middleware which injects ember-cli asset URLs into Discourse's HTML",
"author": "Discourse",
"license": "GPL-2.0-only",
"keywords": [
"ember-addon"
],
"ember-addon": {
"before": [
"broccoli-serve-files",
"proxy-server-middleware"
],
"after": [
"broccoli-watcher"
]
},
"devDependencies": {
"clean-base-url": "^1.0.0",
"express": "^5.2.1",
"glob": "^13.0.6",
"html-entities": "^2.6.0",
"html-rewriter-wasm": "^0.4.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">= 20",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": "^10"
}
}
+2 -3
View File
@@ -20,15 +20,14 @@
"dependencies": {
"@embroider/addon-shim": "^1.9.0",
"discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.13.1",
"markdown-it": "^14.1.1",
"pretty-text": "workspace:1.0.0",
"xss": "^1.0.15"
"xss": "^1.0.15",
"discourse": "workspace:0.0.0"
},
"peerDependencies": {
"discourse-i18n": "workspace:1.0.0",
"pretty-text": "workspace:1.0.0",
"discourse": "workspace:0.0.0",
"xss": "*"
},
"engines": {
@@ -0,0 +1,9 @@
const rawModules = import.meta.glob("./**/*.{gjs,js}", { eager: true });
const adminCompatModules = {};
for (let [key, mod] of Object.entries(rawModules)) {
key = key.replace(/\.(gjs|js)$/, "");
adminCompatModules[key] = mod;
}
export default adminCompatModules;
+2 -3
View File
@@ -1,5 +1,4 @@
import EmberObject from "@ember/object";
import MessageBus from "message-bus-client";
import { ajax } from "discourse/lib/ajax";
export default class Backup extends EmberObject {
@@ -15,7 +14,7 @@ export default class Backup extends EmberObject {
type: "POST",
data: {
with_uploads: withUploads,
client_id: MessageBus.clientId,
client_id: window.MessageBus.clientId,
},
});
}
@@ -39,7 +38,7 @@ export default class Backup extends EmberObject {
restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
data: { client_id: window.MessageBus.clientId },
});
}
}
+34 -29
View File
@@ -1,20 +1,14 @@
import "./global-compat";
import "./setup-deprecation-workflow";
import "./array-shim";
import "decorator-transforms/globals";
import "./loader-shims";
import "./ui-kit-shims";
import "./module-shims";
import "./discourse-common-loader-shims";
import "./global-compat";
import dialogHolderCompatModules from "discourse/dialog-holder/dialog-holder-compat-modules";
import floatKitCompatModules from "discourse/float-kit/float-kit-compat-modules";
import selectKitCompatModules from "discourse/select-kit/select-kit-compat-modules";
import truthHelperCompatModules from "discourse/truth-helpers/truth-helpers-compat-modules";
defineModules("select-kit", selectKitCompatModules);
defineModules("float-kit", floatKitCompatModules);
defineModules("truth-helpers", truthHelperCompatModules);
defineModules("dialog-holder", dialogHolderCompatModules);
import embroiderCompatModules from "@embroider/virtual/compat-modules";
import { registerDiscourseImplicitInjections } from "discourse/lib/implicit-injections";
import { defineModules } from "./lib/loader-shim";
// Register Discourse's standard implicit injections on common framework classes.
registerDiscourseImplicitInjections();
@@ -22,13 +16,34 @@ registerDiscourseImplicitInjections();
import { DEBUG } from "@glimmer/env";
import Application from "@ember/application";
import { VERSION } from "@ember/version";
import setupInspector from "@embroider/legacy-inspector-support/ember-source-4.12";
import { importSync } from "@embroider/macros";
import require from "require";
import { normalizeEmberEventHandling } from "discourse/lib/ember-events";
import { isRailsTesting, isTesting } from "discourse/lib/environment";
import { withPluginApi } from "discourse/lib/plugin-api";
import { populatePreloadStore } from "discourse/lib/preload-store";
import { buildResolver } from "discourse/resolver";
populatePreloadStore();
defineModules(null, embroiderCompatModules);
import dialogHolderCompatModules from "discourse/dialog-holder/compat-modules";
defineModules("discourse/dialog-holder", dialogHolderCompatModules);
import floatKitCompatModules from "discourse/float-kit/compat-modules";
defineModules("discourse/float-kit", floatKitCompatModules);
import selectKitCompatModules from "discourse/select-kit/compat-modules";
defineModules("discourse/select-kit", selectKitCompatModules);
import truthHelpersCompatModules from "discourse/truth-helpers/compat-modules";
defineModules("discourse/truth-helpers", truthHelpersCompatModules);
const _pluginCallbacks = [];
let _unhandledThemeErrors = [];
@@ -41,8 +56,7 @@ window.moduleBroker = {
async function loadThemeFromModulePreload(link) {
const themeId = link.dataset.themeId;
try {
const compatModules = (await import(/* webpackIgnore: true */ link.href))
.default;
const compatModules = (await import(/* @vite-ignore */ link.href)).default;
for (const [key, mod] of Object.entries(compatModules)) {
define(`discourse/theme-${themeId}/${key}`, () => mod);
}
@@ -50,7 +64,7 @@ async function loadThemeFromModulePreload(link) {
// eslint-disable-next-line no-console
console.error(
`Failed to load theme ${link.dataset.themeId} from ${link.href}`,
String(error)
window.Testem ? String(error) : error
);
if (DEBUG && (isRailsTesting() || isTesting())) {
@@ -64,8 +78,7 @@ async function loadThemeFromModulePreload(link) {
async function loadPluginFromModulePreload(link) {
const pluginName = link.dataset.pluginName;
try {
const compatModules = (await import(/* webpackIgnore: true */ link.href))
.default;
const compatModules = (await import(/* @vite-ignore */ link.href)).default;
for (const [key, mod] of Object.entries(compatModules)) {
define(`discourse/plugins/${pluginName}/${key}`, () => mod);
}
@@ -73,7 +86,7 @@ async function loadPluginFromModulePreload(link) {
// eslint-disable-next-line no-console
console.error(
`Failed to load plugin ${link.dataset.pluginName} from ${link.href}`,
error
String(error)
);
if (DEBUG) {
@@ -100,20 +113,10 @@ export async function loadThemesAndPlugins() {
await Promise.all(promises);
}
function defineModules(name, compatModules) {
for (const [key, mod] of Object.entries(compatModules)) {
define(`discourse/${name}/${key.slice(2)}`, () => mod);
}
}
export async function loadAdmin() {
defineModules(
"admin",
(
await import(
/* webpackChunkName: "admin" */ "discourse/admin/admin-compat-modules"
)
).default
"discourse/admin",
(await import("discourse/admin/compat-modules")).default
);
}
@@ -121,6 +124,8 @@ class Discourse extends Application {
modulePrefix = "discourse";
rootElement = "#main";
inspector = setupInspector(this);
customEvents = {
paste: "paste",
};
@@ -0,0 +1,23 @@
import loadConfigFromMeta from "@embroider/config-meta-loader";
import { isTesting } from "@embroider/macros";
let output;
if (isTesting()) {
output = {
modulePrefix: "discourse",
rootURL: "/",
locationType: "none",
APP: {
autoboot: false,
rootElement: "#ember-testing",
},
EmberENV: {
_DEFAULT_ASYNC_OBSERVERS: true,
},
};
} else {
output = loadConfigFromMeta("discourse");
}
export default output;
-24
View File
@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!--
👋 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>
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css" />
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/discourse.css" />
<script defer src="{{rootURL}}assets/vendor.js"></script>
<script defer src="{{rootURL}}assets/discourse.js"></script>
</head>
<body>
</body>
</html>
@@ -1,8 +1,10 @@
import config from "discourse/config/environment";
export default {
initialize() {
const params = new URLSearchParams(window.location.search);
if (params.get("safe_mode")?.split(",").includes("deprecation_errors")) {
window.EmberENV.RAISE_ON_DEPRECATION = true;
config.RAISE_ON_DEPRECATION = true;
return;
}
},
@@ -6,7 +6,6 @@ import * as environment from "discourse/lib/environment";
import { setDefaultOwner } from "discourse/lib/get-owner";
import { setupS3CDN, setupURL } from "discourse/lib/get-url";
import { setIconList } from "discourse/lib/icon-library";
import PreloadStore from "discourse/lib/preload-store";
import { setURLContainer } from "discourse/lib/url";
import Session from "discourse/models/session";
import I18n from "discourse-i18n";
@@ -32,26 +31,6 @@ export default {
setupData = setupDataElement.dataset;
}
let preloaded;
const preloadedDataElement = document.getElementById("data-preloaded");
if (preloadedDataElement) {
preloaded = JSON.parse(preloadedDataElement.dataset.preloaded);
}
const keys = Object.keys(preloaded);
if (keys.length === 0) {
throw "No preload data found in #data-preloaded. Unable to boot Discourse.";
}
keys.forEach(function (key) {
PreloadStore.store(key, JSON.parse(preloaded[key]));
if (setupData.debugPreloadedAppData === "true") {
// eslint-disable-next-line no-console
console.log(key, PreloadStore.get(key));
}
});
setupURL(setupData.cdn, setupData.baseUrl, setupData.baseUri);
// the `if DEBUG` forces the code inside the conditional block to be tree-shaken in
+7 -4
View File
@@ -30,12 +30,15 @@ export default function deprecated(msg, options = {}) {
return;
}
let config;
if (require.has("discourse/config/environment")) {
config = require("discourse/config/environment").default;
}
const raiseError =
options.raiseError ||
DeprecationWorkflow.shouldThrow(
id,
globalThis.EmberENV?.RAISE_ON_DEPRECATION
);
(config &&
DeprecationWorkflow.shouldThrow(id, config.RAISE_ON_DEPRECATION));
const formattedMessage = buildDeprecationMessage(msg, options, raiseError);
const resolvedConsolePrefix = getConsolePrefix(source);
@@ -0,0 +1,13 @@
import { helper } from "@ember/component/helper";
import { deprecate } from "@ember/debug";
/**
* Calls @ember/debug `deprecate` for each provided set of `deprecate` params.
*/
const deprecationsHelper = helper(([deprecationsJson]) => {
for (const deprecation of JSON.parse(deprecationsJson)) {
deprecate(...deprecation);
}
});
export default deprecationsHelper;
+2 -1
View File
@@ -1,3 +1,4 @@
import { isTesting as embroiderIsTesting } from "@embroider/macros";
import deprecated from "discourse/lib/deprecated";
export const INPUT_DELAY = 250;
@@ -14,7 +15,7 @@ export function setEnvironment(e) {
* Returns true if running in the qunit test harness
*/
export function isTesting() {
return environment === "qunit-testing";
return embroiderIsTesting();
}
/**
@@ -106,7 +106,7 @@ async function loadLanguageInitializer(langFile) {
// Load site-specific language bundle generated by Rails HighlightJsController
// URL constructor used to add hostname to relative URLs
const url = new URL(getURLWithCDN(langFile), window.location);
const module = await import(/* webpackIgnore: true */ url);
const module = await import(/* @vite-ignore */ url);
return module.default;
}
@@ -3,5 +3,6 @@ import { waitForPromise } from "@ember/test-waiters";
export default async function loadAce() {
const promise = import("discourse/static/ace-editor-bundle");
waitForPromise(promise);
return await promise;
return (await promise).default;
}
+11
View File
@@ -1,3 +1,5 @@
import "../loader";
// Webpack has bugs, using globalThis is the safest
// https://github.com/embroider-build/embroider/issues/1545
let { define: __define__, require: __require__ } = globalThis;
@@ -56,3 +58,12 @@ export default function loaderShim(pkg, callback) {
__define__(pkg, callback);
}
}
export function defineModules(name, compatModules) {
for (let [key, mod] of Object.entries(compatModules)) {
if (key.startsWith("./")) {
key = key.slice(2);
}
define(`${name ? `${name}/` : ""}${key}`, () => mod);
}
}
+32 -1
View File
@@ -1,8 +1,9 @@
// We can insert data into the PreloadStore when the document is loaded.
// The data can be accessed once by a key, after which it is removed
import { Promise } from "rsvp";
import { isTesting } from "discourse/lib/environment";
export default {
const PreloadStore = {
data: new Map(),
store(key, value) {
@@ -56,3 +57,33 @@ export default {
this.data = new Map();
},
};
export function populatePreloadStore() {
let setupData;
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);
}
const keys = preloaded ? Object.keys(preloaded) : [];
if (keys.length === 0 && !isTesting()) {
throw "No preload data found in #data-preloaded. Unable to boot Discourse.";
}
keys.forEach(function (key) {
PreloadStore.store(key, JSON.parse(preloaded[key]));
if (setupData.debugPreloadedAppData === "true") {
// eslint-disable-next-line no-console
console.log(key, PreloadStore.get(key));
}
});
}
export default PreloadStore;
@@ -18,7 +18,7 @@ export function loadSprites(spritePath, spriteName) {
spriteContainer.appendChild(sprites);
}
loadScript(spritePath).then(() => {
return loadScript(spritePath).then(() => {
sprites.innerHTML = window.__svg_sprite;
// we got to clean up here... this is one giant string
delete window.__svg_sprite;
+76 -12
View File
@@ -51,6 +51,9 @@ loaderShim("@ember/render-modifiers/modifiers/did-insert", () =>
loaderShim("@ember/render-modifiers/modifiers/did-update", () =>
importSync("@ember/render-modifiers/modifiers/did-update")
);
loaderShim("@ember/render-modifiers/modifiers/will-destroy", () =>
importSync("@ember/render-modifiers/modifiers/will-destroy")
);
loaderShim("@ember/routing", () => importSync("@ember/routing"));
loaderShim("@ember/routing/route", () => importSync("@ember/routing/route"));
loaderShim("@ember/runloop", () => importSync("@ember/runloop"));
@@ -77,44 +80,96 @@ loaderShim("ember-route-template", () => importSync("ember-route-template"));
loaderShim("ember", () => importSync("ember"));
loaderShim("jquery", () => importSync("jquery"));
loaderShim("js-yaml", () => importSync("js-yaml"));
loaderShim("message-bus-client", () => importSync("message-bus-client"));
loaderShim("moment", () => importSync("moment"));
loaderShim("rsvp", () => importSync("rsvp"));
loaderShim("truth-helpers", () => importSync("truth-helpers"));
loaderShim("discourse/truth-helpers", () =>
importSync("discourse/truth-helpers")
);
loaderShim("truth-helpers", () => importSync("discourse/truth-helpers"));
loaderShim("truth-helpers/helpers/and", () =>
importSync("truth-helpers/helpers/and")
importSync("discourse/truth-helpers/helpers/and")
);
loaderShim("truth-helpers/helpers/eq", () =>
importSync("truth-helpers/helpers/eq")
importSync("discourse/truth-helpers/helpers/eq")
);
loaderShim("truth-helpers/helpers/gt", () =>
importSync("truth-helpers/helpers/gt")
importSync("discourse/truth-helpers/helpers/gt")
);
loaderShim("truth-helpers/helpers/gte", () =>
importSync("truth-helpers/helpers/gte")
importSync("discourse/truth-helpers/helpers/gte")
);
loaderShim("truth-helpers/helpers/includes", () =>
importSync("truth-helpers/helpers/includes")
importSync("discourse/truth-helpers/helpers/includes")
);
loaderShim("truth-helpers/helpers/lt", () =>
importSync("truth-helpers/helpers/lt")
importSync("discourse/truth-helpers/helpers/lt")
);
loaderShim("truth-helpers/helpers/lte", () =>
importSync("truth-helpers/helpers/lte")
importSync("discourse/truth-helpers/helpers/lte")
);
loaderShim("truth-helpers/helpers/not-eq", () =>
importSync("truth-helpers/helpers/not-eq")
importSync("discourse/truth-helpers/helpers/not-eq")
);
loaderShim("truth-helpers/helpers/not", () =>
importSync("truth-helpers/helpers/not")
importSync("discourse/truth-helpers/helpers/not")
);
loaderShim("truth-helpers/helpers/or", () =>
importSync("truth-helpers/helpers/or")
importSync("discourse/truth-helpers/helpers/or")
);
loaderShim("xss", () => importSync("xss"));
loaderShim("ember-this-fallback/deprecations-helper", () =>
importSync("./lib/ember-this-fallback-deprecation-helper")
);
loaderShim("pretty-text/allow-lister", () =>
importSync("pretty-text/allow-lister")
);
loaderShim("pretty-text/censored-words", () =>
importSync("pretty-text/censored-words")
);
loaderShim("pretty-text/emoji", () => importSync("pretty-text/emoji"));
loaderShim("pretty-text/emoji/data", () =>
importSync("pretty-text/emoji/data")
);
loaderShim("pretty-text/emoji/version", () =>
importSync("pretty-text/emoji/version")
);
loaderShim("pretty-text/guid", () => importSync("pretty-text/guid"));
loaderShim("pretty-text/inline-oneboxer", () =>
importSync("pretty-text/inline-oneboxer")
);
loaderShim("pretty-text/mentions", () => importSync("pretty-text/mentions"));
loaderShim("pretty-text/oneboxer", () => importSync("pretty-text/oneboxer"));
loaderShim("pretty-text/oneboxer-cache", () =>
importSync("pretty-text/oneboxer-cache")
);
loaderShim("pretty-text/pretty-text", () =>
importSync("pretty-text/pretty-text")
);
loaderShim("pretty-text/sanitizer", () => importSync("pretty-text/sanitizer"));
loaderShim("pretty-text/text-replace", () =>
importSync("pretty-text/text-replace")
);
loaderShim("pretty-text/upload-short-url", () =>
importSync("pretty-text/upload-short-url")
);
loaderShim("@ember-decorators/component", () =>
importSync("@ember-decorators/component")
);
loaderShim("@ember-decorators/object", () =>
importSync("@ember-decorators/object")
);
loaderShim("discourse/lib/transformer/registry", () =>
importSync("discourse/lib/registry/transformers")
);
loaderShim("discourse/modifiers/did-insert", () =>
importSync("@ember/render-modifiers/modifiers/did-insert")
);
loaderShim("discourse/modifiers/did-update", () =>
importSync("@ember/render-modifiers/modifiers/did-update")
);
loaderShim("discourse/modifiers/will-destroy", () =>
importSync("@ember/render-modifiers/modifiers/will-destroy")
);
loaderShim("ember-this-fallback/deprecations-helper", () =>
importSync("./lib/ember-this-fallback/deprecations-helper")
);
@@ -127,3 +182,12 @@ loaderShim("ember-this-fallback/this-fallback-helper", () =>
loaderShim("ember-this-fallback/try-lookup-helper", () =>
importSync("./lib/ember-this-fallback/try-lookup-helper")
);
loaderShim("ember-buffered-proxy/helpers", () =>
importSync("ember-buffered-proxy/helpers")
);
loaderShim("ember-buffered-proxy/mixin", () =>
importSync("ember-buffered-proxy/mixin")
);
loaderShim("ember-buffered-proxy/proxy", () =>
importSync("ember-buffered-proxy/proxy")
);
+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
function dict() {
let obj = Object.create(null);
obj["__"] = undefined;
delete obj["__"];
return obj;
}
// Save off the original values of these globals, so we can restore them if someone asks us to
// var oldGlobals = {
// loader: loader,
// define: define,
// requireModule: requireModule,
// require: require,
// requirejs: requirejs,
// };
window.requirejs =
window.require =
window.requireModule =
function (id) {
let pending = [];
let mod = findModule(id, "(require)", pending);
for (let i = pending.length - 1; i >= 0; i--) {
pending[i].exports();
}
return mod.module.exports;
};
window.loader = {
noConflict: function (aliases) {
// var oldName, newName;
// for (oldName in aliases) {
// if (aliases.hasOwnProperty(oldName)) {
// if (oldGlobals.hasOwnProperty(oldName)) {
// newName = aliases[oldName];
// global[newName] = global[oldName];
// global[oldName] = oldGlobals[oldName];
// }
// }
// }
},
// Option to enable or disable the generation of default exports
makeDefaultExport: true,
};
let registry = dict();
let seen = dict();
let uuid = 0;
function unsupportedModule(length) {
throw new Error(
"an unsupported module was defined, expected `define(id, deps, module)` instead got: `" +
length +
"` arguments to define`"
);
}
let defaultDeps = ["require", "exports", "module"];
function Module(id, deps, callback, alias) {
this.uuid = uuid++;
this.id = id;
this.deps = !deps.length && callback.length ? defaultDeps : deps;
this.module = { exports: {} };
this.callback = callback;
this.hasExportsAsDep = false;
this.isAlias = alias;
this.reified = new Array(deps.length);
/*
Each module normally passes through these states, in order:
new : initial state
pending : this module is scheduled to be executed
reifying : this module's dependencies are being executed
reified : this module's dependencies finished executing successfully
errored : this module's dependencies failed to execute
finalized : this module executed successfully
*/
this.state = "new";
}
Module.prototype.makeDefaultExport = function () {
let exports = this.module.exports;
if (
exports !== null &&
(typeof exports === "object" || typeof exports === "function") &&
exports["default"] === undefined &&
Object.isExtensible(exports)
) {
exports["default"] = exports;
}
};
Module.prototype.exports = function () {
// if finalized, there is no work to do. If reifying, there is a
// circular dependency so we must return our (partial) exports.
if (this.state === "finalized" || this.state === "reifying") {
return this.module.exports;
}
if (window.loader.wrapModules) {
this.callback = window.loader.wrapModules(this.id, this.callback);
}
this.reify();
let result = this.callback.apply(this, this.reified);
this.reified.length = 0;
this.state = "finalized";
if (!(this.hasExportsAsDep && result === undefined)) {
this.module.exports = result;
}
if (window.loader.makeDefaultExport) {
this.makeDefaultExport();
}
return this.module.exports;
};
Module.prototype.unsee = function () {
this.state = "new";
this.module = { exports: {} };
};
Module.prototype.reify = function () {
if (this.state === "reified") {
return;
}
this.state = "reifying";
try {
this.reified = this._reify();
this.state = "reified";
} finally {
if (this.state === "reifying") {
this.state = "errored";
}
}
};
Module.prototype._reify = function () {
let reified = this.reified.slice();
for (let i = 0; i < reified.length; i++) {
let mod = reified[i];
reified[i] = mod.exports ? mod.exports : mod.module.exports();
}
return reified;
};
Module.prototype.findDeps = function (pending) {
if (this.state !== "new") {
return;
}
this.state = "pending";
let deps = this.deps;
for (let i = 0; i < deps.length; i++) {
let dep = deps[i];
let entry = (this.reified[i] = { exports: undefined, module: undefined });
if (dep === "exports") {
this.hasExportsAsDep = true;
entry.exports = this.module.exports;
} else if (dep === "require") {
entry.exports = this.makeRequire();
} else if (dep === "module") {
entry.exports = this.module;
} else {
entry.module = findModule(resolve(dep, this.id), this.id, pending);
}
}
};
Module.prototype.makeRequire = function () {
let id = this.id;
let r = function (dep) {
return window.require(resolve(dep, id));
};
r["default"] = r;
r.moduleId = id;
r.has = function (dep) {
return has(resolve(dep, id));
};
return r;
};
window.define = function (id, deps, callback) {
let module = registry[id];
// If a module for this id has already been defined and is in any state
// other than `new` (meaning it has been or is currently being required),
// then we return early to avoid redefinition.
if (module && module.state !== "new") {
return;
}
if (arguments.length < 2) {
unsupportedModule(arguments.length);
}
if (!Array.isArray(deps)) {
callback = deps;
deps = [];
}
if (callback instanceof Alias) {
registry[id] = new Module(callback.id, deps, callback, true);
} else {
registry[id] = new Module(id, deps, callback, false);
}
};
window.define.exports = function (name, defaultExport) {
let module = registry[name];
// If a module for this name has already been defined and is in any state
// other than `new` (meaning it has been or is currently being required),
// then we return early to avoid redefinition.
if (module && module.state !== "new") {
return;
}
module = new Module(name, [], noop, null);
module.module.exports = defaultExport;
module.state = "finalized";
registry[name] = module;
return module;
};
function noop() {}
// we don't support all of AMD
// define.amd = {};
function Alias(id) {
this.id = id;
}
window.define.alias = function (id, target) {
if (arguments.length === 2) {
return window.define(target, new Alias(id));
}
return new Alias(id);
};
function missingModule(id, referrer) {
throw new Error(
"Could not find module `" + id + "` imported from `" + referrer + "`"
);
}
function findModule(id, referrer, pending) {
let mod = registry[id] || registry[id + "/index"];
while (mod && mod.isAlias) {
mod = registry[mod.id] || registry[mod.id + "/index"];
}
if (!mod) {
missingModule(id, referrer);
}
if (pending && mod.state !== "pending" && mod.state !== "finalized") {
mod.findDeps(pending);
pending.push(mod);
}
return mod;
}
function resolve(child, id) {
if (child.charAt(0) !== ".") {
return child;
}
let parts = child.split("/");
let nameParts = id.split("/");
let parentBase = nameParts.slice(0, -1);
for (let i = 0, l = parts.length; i < l; i++) {
let part = parts[i];
if (part === "..") {
if (parentBase.length === 0) {
throw new Error("Cannot access parent module of root");
}
parentBase.pop();
} else if (part === ".") {
continue;
} else {
parentBase.push(part);
}
}
return parentBase.join("/");
}
function has(id) {
return !!(registry[id] || registry[id + "/index"]);
}
window.requirejs.entries = window.requirejs._eak_seen = registry;
window.requirejs.has = has;
window.requirejs.unsee = function (id) {
findModule(id, "(unsee)", false).unsee();
};
window.requirejs.clear = function () {
window.requirejs.entries = window.requirejs._eak_seen = registry = dict();
seen = dict();
};
// This code primes the JS engine for good performance by warming the
// JIT compiler for these functions.
// define('foo', function () {});
// define('foo/bar', [], function () {});
// define('foo/asdf', ['module', 'exports', 'require'], function (
// module,
// exports,
// require
// ) {
// if (require.has('foo/bar')) {
// require('foo/bar');
// }
// });
// define('foo/baz', [], define.alias('foo'));
// define('foo/quz', define.alias('foo'));
// define.alias('foo', 'foo/qux');
// define('foo/bar', [
// 'foo',
// './quz',
// './baz',
// './asdf',
// './bar',
// '../foo',
// ], function () {});
// define('foo/main', ['foo/bar'], function () {});
// define.exports('foo/exports', {});
// require('foo/exports');
// require('foo/main');
// require.unsee('foo/bar');
requirejs.clear();
+6
View File
@@ -1,3 +1,4 @@
import { dasherize } from "@ember/string";
import EmbroiderRouter from "@embroider/router";
import { isTesting } from "discourse/lib/environment";
import getURL from "discourse/lib/get-url";
@@ -7,6 +8,11 @@ import applyRouterHomepageOverrides from "./lib/homepage-router-overrides";
class BareRouter extends EmbroiderRouter {
location = isTesting() ? "none" : "history";
lazyRoute(routeName) {
routeName = dasherize(routeName);
return super.lazyRoute(routeName);
}
setupRouter() {
const didSetup = super.setupRouter(...arguments);
if (didSetup) {
+6
View File
@@ -367,6 +367,12 @@ export function buildResolver(baseName) {
}
}
addModules(modules) {
for (const [key, value] of Object.entries(modules)) {
define(key, () => value);
}
}
/**
* Builds a list of template path candidates based on various naming conventions.
* Supports legacy naming patterns (underscored, dasherized, etc.) for backwards compatibility.
@@ -1,4 +1,4 @@
import MessageBus from "message-bus-client";
import "message-bus-client";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
@disableImplicitInjections
@@ -6,6 +6,6 @@ export default class MessageBusService {
static isServiceFactory = true;
static create() {
return MessageBus;
return window.MessageBus;
}
}
@@ -1,4 +1,4 @@
export * from "ace-builds/src-noconflict/ace";
export { default } from "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-scss";
import "ace-builds/src-noconflict/mode-html";
+70
View File
@@ -0,0 +1,70 @@
const { buildMacros } = require("@embroider/macros/babel");
const macros = buildMacros({
configure(macrosConfig) {
macrosConfig.setGlobalConfig(__filename, "@embroider/core", {
active: true,
});
},
});
module.exports = {
plugins: [
[
"babel-plugin-ember-template-compilation",
{
compilerPath: "ember-source/ember-template-compiler/index.js",
enableLegacyModules: [
"ember-cli-htmlbars",
"ember-cli-htmlbars-inline-precompile",
"htmlbars-inline-precompile",
],
transforms: [...macros.templateMacros],
},
],
[
"module:decorator-transforms",
{
runtime: {
import: require.resolve("decorator-transforms/runtime-esm"),
},
},
],
[
"@babel/plugin-transform-runtime",
{
absoluteRuntime: __dirname,
useESModules: true,
regenerator: false,
},
],
[
require.resolve("babel-plugin-debug-macros"),
{
flags: [
{
source: "@glimmer/env",
flags: {
DEBUG: process.env.EMBER_ENV !== "production",
CI: !!process.env.CI,
},
},
],
debugTools: {
isDebug: process.env.EMBER_ENV !== "production",
source: "@ember/debug",
assertPredicateIndex: 1,
},
externalizeHelpers: {
module: "@ember/debug",
},
},
"@ember/debug stripping",
],
...macros.babelMacros,
],
generatorOpts: {
compact: false,
},
};
@@ -0,0 +1,9 @@
const rawModules = import.meta.glob("./**/*.{gjs,js}", { eager: true });
const compatModules = {};
for (let [key, mod] of Object.entries(rawModules)) {
key = key.replace(/\.(gjs|js)$/, "");
compatModules[key] = mod;
}
export default compatModules;
+25
View File
@@ -0,0 +1,25 @@
import App, { loadAdmin, loadThemesAndPlugins } from "discourse/app";
(async function () {
if (window.unsupportedBrowser) {
throw "Unsupported browser detected";
}
let element = document.querySelector(
`meta[name="discourse/config/environment"]`
);
const config = JSON.parse(
decodeURIComponent(element.getAttribute("content"))
);
performance.mark("discourse-init");
if (document.querySelector('#data-discourse-setup[data-is-staff="true"]')) {
await loadAdmin();
}
await loadThemesAndPlugins();
const app = App.create(config.detail);
app.start();
})();
-287
View File
@@ -1,287 +0,0 @@
"use strict";
const EmberApp = require("ember-cli/lib/broccoli/ember-app");
const path = require("path");
const mergeTrees = require("broccoli-merge-trees");
const generateScriptsTree = require("./lib/scripts");
const funnel = require("broccoli-funnel");
const DeprecationSilencer = require("deprecation-silencer");
const { compatBuild } = require("@embroider/compat");
const { Webpack } = require("@embroider/webpack");
const { StatsWriterPlugin } = require("webpack-stats-plugin");
const { RetryChunkLoadPlugin } = require("webpack-retry-chunk-load-plugin");
const withSideWatch = require("./lib/with-side-watch");
const crypto = require("crypto");
const commonBabelConfig = require("./lib/common-babel-config");
const TerserPlugin = require("terser-webpack-plugin");
const {
CustomizeChunkUrlPlugin,
} = require("./lib/webpack-customize-chunk-url-plugin");
const { BroccoliMergeFiles } = require("broccoli-merge-files");
const { mkdirSync } = require("fs");
process.env.BROCCOLI_ENABLED_MEMOIZE = true;
function compatModulesFor(name) {
return funnel(
new BroccoliMergeFiles([name], {
outputFileName: `${name}-compat-modules.js`,
async merge(files) {
const lines = [`const compatModules = {};`];
let i = 1;
for (const [filename] of files) {
const withoutExtension = filename.replace(/\..*$/, "");
lines.push(
`import * as Module${i} from "./${withoutExtension}";`,
`compatModules["./${withoutExtension}"] = Module${i};`
);
i++;
}
lines.push("export default compatModules;");
return lines.join("\n");
},
}),
{ destDir: name }
);
}
module.exports = function (defaults) {
const discourseRoot = path.resolve("../..");
mkdirSync(path.join(discourseRoot, "app/assets/generated"), {
recursive: true,
});
// Silence deprecations which we are aware of - see `lib/deprecation-silencer.js`
DeprecationSilencer.silence(console, "warn");
DeprecationSilencer.silence(console, "log");
DeprecationSilencer.silence(defaults.project.ui, "writeWarnLine");
const isProduction = EmberApp.env().includes("production");
const app = new EmberApp(defaults, {
autoRun: false,
"ember-qunit": {
insertContentForTestBody: false,
},
"ember-template-imports": {
inline_source_map: true,
},
sourcemaps: {
// There seems to be a bug with broccoli-concat when sourcemaps are disabled
// that causes the `app.import` statements below to fail in production mode.
// This forces the use of `fast-sourcemap-concat` which works in production.
enabled: true,
},
fingerprint: {
// Handled by Rails asset pipeline
enabled: false,
},
SRI: {
// We don't use SRI in Rails. Disable here to match:
enabled: false,
},
"ember-cli-deprecation-workflow": {
enabled: true,
},
"ember-cli-terser": {
enabled: isProduction,
exclude: ["**/highlightjs/*", "**/javascripts/*"],
},
...commonBabelConfig(),
trees: {
app: withSideWatch(
mergeTrees([
"app",
funnel("admin", { destDir: "admin" }),
compatModulesFor("admin"),
funnel("select-kit", { destDir: "select-kit" }),
compatModulesFor("select-kit"),
funnel("float-kit", { destDir: "float-kit" }),
compatModulesFor("float-kit"),
funnel("truth-helpers", { destDir: "truth-helpers" }),
compatModulesFor("truth-helpers"),
funnel("dialog-holder", { destDir: "dialog-holder" }),
compatModulesFor("dialog-holder"),
]),
{
watching: ["../discourse-markdown-it", "../../app/assets/generated"],
}
),
},
});
// WARNING: We should only import scripts here if they are not in NPM.
app.import(discourseRoot + "/frontend/polyfills.js");
app.import(
discourseRoot + "/frontend/discourse/public/assets/scripts/module-shims.js"
);
app.project.liveReloadFilterPatterns = [/.*\.scss/];
const terserPlugin = app.project.findAddonByName("ember-cli-terser");
const applyTerser = (tree) => terserPlugin.postprocessTree("all", tree);
let extraPublicTrees = [
funnel(`${discourseRoot}/public/javascripts`, { destDir: "javascripts" }),
applyTerser(generateScriptsTree(app)),
];
const assetCachebuster = process.env["DISCOURSE_ASSET_URL_SALT"] || "";
const cachebusterHash = crypto
.createHash("md5")
.update(assetCachebuster)
.digest("hex")
.slice(0, 8);
const appTree = compatBuild(app, Webpack, {
staticEmberSource: true,
splitAtRoutes: ["wizard"],
staticAppPaths: [
"static",
"admin",
"select-kit",
"float-kit",
"truth-helpers",
"dialog-holder",
],
packagerOptions: {
webpackConfig: {
devtool:
process.env.CHEAP_SOURCE_MAPS === "1"
? "cheap-source-map"
: "source-map",
output: {
publicPath: "auto",
filename: `assets/chunk.[chunkhash].${cachebusterHash}.js`,
chunkFilename: `assets/chunk.[chunkhash].${cachebusterHash}.js`,
assetModuleFilename: `assets/chunk.[hash].${cachebusterHash}[ext][query]`,
},
optimization: {
minimize: isProduction,
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
}),
],
},
cache: isProduction
? false
: {
type: "memory",
maxGenerations: 1,
},
entry: {
"assets/media-optimization-bundle.js": {
import: "./static/media-optimization-bundle",
chunkLoading: "import-scripts",
runtime: false,
},
},
externals: [
function ({ context, request }, callback) {
if (
context.includes("discourse-markdown-it/src") &&
request.startsWith("discourse/")
) {
// v1 ember apps can't be imported from addons. Workaround via commonjs.
// Won't be necessary once we move to a v2 app.
callback(null, request, "commonjs");
} else if (
!request.includes("-embroider-implicit") &&
(request.startsWith("discourse/plugins/") ||
request.startsWith("discourse/theme-"))
) {
callback(null, request, "commonjs");
} else {
callback();
}
},
],
module: {
parser: {
javascript: {
exportsPresence: "error",
},
},
},
plugins: [
// The server use this output to map each asset to its chunks
new StatsWriterPlugin({
filename: "assets.json",
stats: {
all: false,
entrypoints: true,
chunks: true,
},
transform({ chunks, entrypoints }) {
let names = Object.keys(entrypoints);
let output = {};
for (let name of names.sort()) {
let assets = entrypoints[name].assets.map(
(asset) => asset.name
);
let parent = names.find((parentName) =>
name.startsWith(parentName + "/")
);
if (parent) {
name = name.slice(parent.length + 1);
output[parent][name] = { assets };
} else {
output[name] = { assets };
}
}
for (const chunk of chunks) {
if (chunk.entry) {
continue;
}
for (const name of chunk.names) {
const outputName = `assets/chunk.${name}.js`;
output[outputName] ??= { assets: [] };
output[outputName].assets.push(...chunk.files);
}
}
return JSON.stringify(output, null, 2);
},
}),
new CustomizeChunkUrlPlugin(),
new RetryChunkLoadPlugin({
retryDelay: 200,
maxRetries: 2,
}),
],
experiments: {
asyncWebAssembly: true,
},
},
},
skipBabel: [
{
package: "qunit",
},
{
package: "sinon",
},
{
package: "@json-editor/json-editor",
},
{
package: "ace-builds",
},
],
});
return mergeTrees([appTree, mergeTrees(extraPublicTrees)]);
};
@@ -0,0 +1,9 @@
const rawModules = import.meta.glob("./**/*.{gjs,js}", { eager: true });
const compatModules = {};
for (let [key, mod] of Object.entries(rawModules)) {
key = key.replace(/\.(gjs|js)$/, "");
compatModules[key] = mod;
}
export default compatModules;
@@ -1,22 +0,0 @@
module.exports = function generateCommonBabelConfig() {
return {
"ember-cli-babel": {
throwUnlessParallelizable: true,
disableDecoratorTransforms: true,
},
babel: {
sourceMaps: false,
plugins: [
require.resolve("deprecation-silencer"),
[
require.resolve("decorator-transforms"),
{
runEarly: true,
},
],
require.resolve("./babel-transform-module-renames"),
],
},
};
};
@@ -0,0 +1,26 @@
import { buildResolverOptions } from "@embroider/core/module-resolver-options";
import fs from "node:fs";
import path from "node:path";
export default function writeResolverConfig(config, extra) {
const resolverPath = path.resolve("./node_modules/.embroider/resolver.json");
const embroiderDir = path.resolve("./node_modules/.embroider");
if (fs.existsSync(embroiderDir)) {
fs.rmSync(embroiderDir, {
recursive: true,
force: true,
});
}
const embroiderResolverOptions = {
...buildResolverOptions(config),
...extra,
};
fs.mkdirSync(path.dirname(resolverPath), { recursive: true });
fs.writeFileSync(
resolverPath,
JSON.stringify(embroiderResolverOptions, null, 2)
);
}
+55
View File
@@ -0,0 +1,55 @@
import { and, code, id, include, not, or } from "@rolldown/pluginutils";
import { babel } from "@rollup/plugin-babel";
const babelRequiredImports = [
// Templates
"@ember/template-compiler",
"@ember/template-compilation",
"ember-cli-htmlbars",
"ember-cli-htmlbars-inline-precompile",
"htmlbars-inline-precompile",
// Macros
"@embroider/macros",
"@glimmer/env",
"@ember/debug",
"@ember/application/deprecations",
];
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const importsRegex = new RegExp(
babelRequiredImports.map(escapeRegExp).join("|")
);
const decoratorRegex = /(?<![\w'"`])(?<!\*\s)(?<!\/\/[^\n]*)@\w+/;
// └────┬─────┘└───┬───┘└──────┬──────┘└┬─┘
// │ │ │ │
// │ │ │ └── the `@decorator`
// │ │ └── not on a `//` line comment
// │ └── not a JSDoc tag (`* @param`)
// └── not mid-identifier or inside a string
const nodeModulesPattern = /\/node_modules\//;
export default function maybeBabel(config) {
const plugin = babel(config);
// Extract existing regex filter from babel plugin
const extensionRegex = plugin.transform.filter.id;
plugin.transform.filter = [
include(
and(
id(extensionRegex), // Is one of the babel-supported extensions
or(
code(importsRegex), // Imports one of our listed modules
and(not(id(nodeModulesPattern)), code(decoratorRegex)) // Is local app code which uses a decorator
)
)
),
];
return plugin;
}
@@ -0,0 +1,58 @@
import { ResolverLoader } from "@embroider/core";
import { resolver, templateTag } from "@embroider/vite";
import { id, importerId, include, or } from "@rolldown/pluginutils";
import { viteAliasPlugin } from "rolldown/experimental";
function toSpecifier(s) {
return s.replace(/(?:\/index)?\.js$/, "");
}
/*
* Embroider's resolver handles a number of static module aliases. For example - the
* fake packages like `@ember/component`. We can fetch that list from the resolver upfront
* and pipe it into the highly-optimized viteAliasPlugin. This means that they will be
* resolved entirely on the rust side - no round-trip to node.
*/
function staticEmbroiderAliases() {
const { renameModules = {} } = new ResolverLoader(process.cwd()).resolver
.options;
const entries = Object.entries(renameModules).map(([find, replacement]) => ({
find: toSpecifier(find),
replacement: toSpecifier(replacement),
}));
return viteAliasPlugin({ entries });
}
/*
* By default, the embroider resolver runs for every resolveId call.
* We have moved its static lookup rules into the highly-optimised viteAliasPlugin,
* and we have no need for its hbs-related synthetic modules or app-tree merging.
* Therefore we can filter the plugin so that it's only called for `@embroider/*`
* virtual modules, and for any imports *from* a -embroider- module.
*/
function filteredEmberResolver() {
const plugin = resolver();
plugin.resolveId = {
filter: [
include(
or(
// @embroider/* could be dynamic modules
id(/^@embroider\//),
// /-embroider-* need access to the app-tree-merge result so that addon-contributed app-tree-merge modules are available in compatModules
importerId(/\/-embroider-/)
)
),
],
handler: plugin.resolveId,
};
return plugin;
}
/**
* Drop-in replacement for `@embroider/vite`'s `ember()`.
* Skips the two vite-specific plugins, since we're using rolldown.
* Uses an optimized strategy for the resolver.
*/
export default function optimizedEmber() {
return [staticEmbroiderAliases(), templateTag(), filteredEmberResolver()];
}
-55
View File
@@ -1,55 +0,0 @@
const mergeTrees = require("broccoli-merge-trees");
const funnel = require("broccoli-funnel");
const concat = require("broccoli-concat");
const fs = require("fs");
// Each file under `scripts/{name}.js` is run through babel, sourcemapped, and then output to `/assets/{name}.js
module.exports = function scriptsTree(app) {
let babelAddon = app.project.findAddonByName("ember-cli-babel");
let babelConfig = {
babel: { sourceMaps: "inline" },
"ember-cli-babel": { compileModules: false },
};
const trees = [];
const scripts = fs
.readdirSync("scripts", { withFileTypes: true })
.filter((dirent) => dirent.isFile());
for (let script of scripts) {
let source = funnel(`scripts`, {
files: [script.name],
destDir: "scripts",
});
// Babel will append a base64 sourcemap to the file
let transpiled = babelAddon.transpileTree(source, babelConfig);
// We don't actually need to concat any source files... but this will move the base64
// source map into its own file
let transpiledWithDecodedSourcemap = concat(transpiled, {
outputFile: `assets/${script.name}`,
});
trees.push(transpiledWithDecodedSourcemap);
}
// start-discourse.js is a combination of start-app and discourse-boot
let startDiscourseTree = funnel(`public/assets/scripts`, {
files: ["start-app.js", "discourse-boot.js"],
destDir: "scripts",
});
startDiscourseTree = babelAddon.transpileTree(
startDiscourseTree,
babelConfig
);
startDiscourseTree = concat(startDiscourseTree, {
outputFile: `assets/start-discourse.js`,
headerFiles: [`scripts/start-app.js`],
inputFiles: [`scripts/discourse-boot.js`],
});
trees.push(startDiscourseTree);
return mergeTrees(trees);
};
@@ -0,0 +1,34 @@
const TEST_FILE_RE = /tests\/(?!helpers\/).*-test\.(?:gjs|js)$/;
export default function wrapTestModulesPlugin() {
return {
name: "wrap-test-modules",
transform: {
filter: { id: TEST_FILE_RE },
handler(code, id, { magicString }) {
const ast = this.parse(code);
let lastImportEnd = 0;
for (const node of ast.body) {
if (node.type === "ImportDeclaration") {
lastImportEnd = node.end;
}
}
if (lastImportEnd >= code.length) {
return null;
}
magicString.appendLeft(
lastImportEnd,
"\n\nexport default function () {\n"
);
magicString.append("\n}\n");
return {
code: magicString,
};
},
},
};
}
@@ -43,16 +43,6 @@ function logIfDebug(...messages) {
}
}
function setPublicPath(settings) {
// This variable assignment is re-written by webpack at build time.
// It ensures that the WASM files are loaded from the CDN, just like this JS entrypoint.
// eslint-disable-next-line no-undef
__webpack_public_path__ = new URL(
`${settings.mediaOptimizationBundle}/../..`,
location.href
).toString();
}
function buildMozJpegOptions(settings) {
return {
quality: settings.encode_quality,
@@ -124,7 +114,6 @@ globalThis.optimize = async function (
originalFileSize,
settings
) {
setPublicPath(settings);
const mozJpegOptions = buildMozJpegOptions(settings);
const initialSize = imageData.byteLength;
@@ -176,8 +165,6 @@ globalThis.convert = async function (
originalFileSize,
settings
) {
setPublicPath(settings);
logIfDebug(`Converting ${fileName} (${fileType}, ${originalFileSize} bytes)`);
let imageData;
@@ -240,8 +227,6 @@ globalThis.convertAnimated = async function (
originalFileSize,
settings
) {
setPublicPath(settings);
logIfDebug(
`Converting animated ${fileName} (${originalFileSize} bytes) to animated WEBP`
);
+31 -36
View File
@@ -20,9 +20,8 @@
"test": "tests"
},
"scripts": {
"build": "ember build",
"start": "ember serve",
"test": "ember test"
"build": "rolldown -c rolldown.config.mjs",
"start": "./rolldown.mjs"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -31,6 +30,14 @@
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0",
"@discourse/gif": "^1.0.0",
"@discourse/heic": "^1.0.0",
"@discourse/jpeg": "^1.0.0",
"@discourse/jxl": "^1.0.0",
"@discourse/png": "^3.1.1",
"@discourse/resize": "^2.1.0",
"@discourse/webp": "^1.0.0",
"@embroider/legacy-inspector-support": "^0.1.3",
"@faker-js/faker": "^10.4.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
@@ -48,13 +55,6 @@
"@lezer/highlight": "^1.2.3",
"@lezer/javascript": "^1.5.4",
"@lezer/lr": "^1.4.8",
"@discourse/gif": "^1.0.0",
"@discourse/heic": "^1.0.0",
"@discourse/jpeg": "^1.0.0",
"@discourse/jxl": "^1.0.0",
"@discourse/png": "^3.1.1",
"@discourse/resize": "^2.1.0",
"@discourse/webp": "^1.0.0",
"ace-builds": "^1.43.6",
"chart.js": "4.5.1",
"chartjs-adapter-moment": "^1.0.1",
@@ -104,22 +104,28 @@
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/standalone": "^7.29.4",
"@colors/colors": "^1.6.0",
"@discourse/itsatrap": "^2.0.10",
"@ember-compat/tracked-built-ins": "^0.9.1",
"@ember/optional-features": "^3.0.0",
"@ember-decorators/component": "^6.1.1",
"@ember-decorators/object": "^6.1.1",
"@ember-decorators/utils": "^6.1.1",
"@ember/render-modifiers": "^3.0.0",
"@ember/string": "^4.0.1",
"@ember/test-helpers": "^5.4.2",
"@ember/test-waiters": "^4.1.1",
"@embroider/compat": "^3.9.3",
"@embroider/core": "^3.5.9",
"@embroider/macros": "^1.16.12",
"@embroider/router": "^2.1.12",
"@embroider/webpack": "^4.1.0",
"@embroider/compat": "^4.1.19",
"@embroider/config-meta-loader": "1.0.0",
"@embroider/core": "^4.6.0",
"@embroider/macros": "^1.20.3",
"@embroider/router": "^3.0.6",
"@embroider/vite": "^1.7.2",
"@floating-ui/dom": "^1.7.5",
"@glimmer/component": "^2.0.0",
"@rolldown/pluginutils": "1.0.0",
"@rollup/plugin-babel": "github:davidtaylorhq/rollup-plugins#babel-parallel-built&path:/packages/babel",
"@swc/core": "^1.15.33",
"@types/jquery": "^3.5.33",
"@types/qunit": "^2.19.13",
@@ -131,33 +137,21 @@
"@uppy/utils": "^7.2.0",
"@uppy/xhr-upload": "^5.2.0",
"a11y-dialog": "8.1.5",
"babel-import-util": "^3.0.1",
"ansi-to-html": "^0.7.2",
"babel-plugin-debug-macros": "^2.0.0",
"babel-plugin-ember-template-compilation": "^4.0.0",
"broccoli-asset-rev": "^3.0.0",
"broccoli-merge-files": "^0.8.0",
"custom-proxy": "workspace:1.0.0",
"deprecation-silencer": "workspace:1.0.0",
"discourse-i18n": "workspace:1.0.0",
"discourse-i18n": "workspace:*",
"discourse-markdown-it": "workspace:1.0.0",
"ember-async-data": "^2.0.1",
"ember-auto-import": "^2.13.1",
"ember-buffered-proxy": "^2.1.1",
"ember-cli": "~6.12.0",
"ember-cli-app-version": "^7.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-deprecation-workflow": "^4.0.1",
"ember-cli-htmlbars": "^7.0.1",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-progress-ci": "workspace:1.0.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-decorators": "^6.1.1",
"ember-exam": "^10.1.0",
"ember-load-initializers": "^3.0.1",
"ember-modifier": "^4.3.0",
"ember-qunit": "^9.0.4",
"ember-template-imports": "^4.4.0",
"ember-test-selectors": "^7.1.0",
"jquery": "^3.7.1",
"js-yaml": "^4.1.1",
"jsuites": "^5.13.3",
@@ -166,15 +160,12 @@
"pretender": "^3.4.7",
"qunit": "^2.25.0",
"qunit-dom": "^3.5.1",
"rolldown": "1.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sinon": "^22.0.0",
"source-map": "^0.7.6",
"terser": "^5.47.1",
"testem": "^3.20.0",
"typescript": "^5.9.3",
"util": "^0.12.5",
"webpack": "5.99.9",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3",
"xss": "^1.0.15"
},
"engines": {
@@ -185,5 +176,9 @@
},
"ember": {
"edition": "octane"
},
"ember-addon": {
"type": "app",
"version": 2
}
}
+7 -1
View File
@@ -38,7 +38,13 @@ module.exports = function patchTestemOutput() {
const label = labelForRunner(this);
socket.on("browser-console", (type, ...args) => {
console.log(`[${label}] [${type}] ${args.join(" ")}`);
if (type === "group") {
type = `「group」`;
} else {
type = `[${type}]`;
}
console.log(`[${label}] ${type} ${args.join(" ")}`);
});
socket.on("top-level-error", (msg, url, line) => {
console.log(`[${label}] [error] ${msg} at ${url}:${line}`);
@@ -1,14 +0,0 @@
(function () {
if (window.unsupportedBrowser) {
throw "Unsupported browser detected";
}
let element = document.querySelector(
`meta[name="discourse/config/environment"]`
);
const config = JSON.parse(
decodeURIComponent(element.getAttribute("content"))
);
const event = new CustomEvent("discourse-init", { detail: config });
document.dispatchEvent(event);
})();
@@ -1,9 +0,0 @@
const environment = require("discourse/lib/environment");
const { withSilencedDeprecations } = require("discourse/lib/deprecated");
environment.setEnvironment("qunit-testing");
require("discourse/deprecation-workflow").default.setEnvironment(environment);
withSilencedDeprecations("discourse.native-array-extensions.[]", () => {
require("discourse/tests/test-boot-ember-cli");
});
@@ -1,62 +0,0 @@
const dynamicJsTemplate = document.querySelector("#dynamic-test-js");
const outputNode = document.querySelector("discourse-dynamic-test-js");
const params = new URLSearchParams(document.location.search);
const target = params.get("target") || "core";
if (target === "theme-qunit") {
window.location.href = window.location.origin + "/theme-qunit";
}
(async function setup() {
const rootUrl = document.querySelector("link[rel='canonical']").href;
const response = await fetch(
`${rootUrl}bootstrap/plugin-test-info.json?target=${target}`
);
const pluginTestInfo = await response.json();
dynamicJsTemplate.content.firstElementChild.insertAdjacentHTML(
"beforebegin",
pluginTestInfo.html
);
window._discourseQunitPluginNames = pluginTestInfo.all_plugins;
for (const element of dynamicJsTemplate.content.childNodes) {
if (
element.tagName === "SCRIPT" &&
element.innerHTML.includes("EmberENV.TESTS_FILE_LOADED")
) {
// Inline script introduced by ember-cli. Incompatible with CSP and our custom plugin JS loading system
// https://github.com/ember-cli/ember-cli/blob/04a38fda2c/lib/utilities/ember-app-utils.js#L131
// We re-implement in test-boot-ember-cli.js
continue;
}
if (element.type === "importmap") {
const importmap = document.createElement("script");
importmap.type = "importmap";
importmap.textContent = element.textContent;
outputNode.append(importmap);
continue;
} else if (element.tagName === "SCRIPT") {
const script = document.createElement("script");
script.src = element.src;
for (const [key, value] of Object.entries(element.dataset)) {
script.dataset[key] = value;
}
script.defer = element.defer;
script.async = false; // Weirdly, this is true by default when programmatically creating script tags
outputNode.append(script);
continue;
}
const clone = element.cloneNode(true);
outputNode.appendChild(clone);
if (clone.tagName === "LINK" && clone["rel"] === "stylesheet") {
await new Promise((resolve) => (clone.onload = resolve));
}
}
})();
@@ -1,20 +0,0 @@
document.addEventListener("discourse-init", async (e) => {
performance.mark("discourse-init");
const config = e.detail;
const { default: klass, loadThemesAndPlugins, loadAdmin } = require(
`${config.modulePrefix}/app`
);
if (
document.querySelector(
'link[rel="preload"][data-discourse-entrypoint="admin"]'
)
) {
await loadAdmin();
}
await loadThemesAndPlugins();
const app = klass.create(config);
app.start();
});
+9
View File
@@ -0,0 +1,9 @@
import "message-bus-client";
import jQuery from "jquery";
window.jQuery = jQuery;
window.MessageBus.ajax = jQuery.ajax;
window.MessageBus.subscribe("/file-change", () => {
window.parent.location.reload();
});
+249
View File
@@ -0,0 +1,249 @@
import * as fs from "fs";
import { basename, relative } from "path";
import { viteAliasPlugin, viteImportGlobPlugin } from "rolldown/experimental";
import writeResolverConfig from "./lib/embroider-vite-resolver-options.mjs";
import maybeBabel from "./lib/maybe-babel.mjs";
import optimizedEmber from "./lib/optimized-ember.mjs";
import wrapTestModulesPlugin from "./lib/wrap-test-modules-plugin.mjs";
writeResolverConfig(
{
staticAppPaths: ["static", "admin"],
splitAtRoutes: [{ type: "string", value: "wizard" }],
},
{
options: {
staticInvokables: false,
allowUnsafeDynamicComponents: false,
},
}
);
const extensions = [".gjs", ".mjs", ".js", ".mts", ".gts", ".ts", ".hbs"];
const aliases = [
{ find: "pretty-text", replacement: "pretty-text/addon" },
{
find: "ember-buffered-proxy/helpers",
replacement: "ember-buffered-proxy/addon/helpers",
},
{
find: "ember-buffered-proxy/mixin",
replacement: "ember-buffered-proxy/addon/mixin",
},
{
find: "ember-buffered-proxy/proxy",
replacement: "ember-buffered-proxy/addon/proxy",
},
{
find: "@ember-decorators/object",
replacement: "@ember-decorators/object/addon",
},
{
find: "@ember-decorators/utils/decorator",
replacement: "@ember-decorators/utils/addon/decorator",
},
{
find: "@ember-decorators/utils/collapse-proto",
replacement: "@ember-decorators/utils/addon/collapse-proto",
},
{
find: "@ember-decorators/component",
replacement: "@ember-decorators/component/addon",
},
{
find: "ember-exam/test-support/load",
replacement: "ember-exam/addon-test-support/load",
},
{
find: "@ember/render-modifiers",
replacement: "@ember/render-modifiers/addon",
},
];
export function buildConfig({ devMode } = {}) {
const isProduction = process.env.EMBER_ENV === "production";
if (!isProduction) {
process.env.NODE_ENV = "development";
}
return {
resolve: {
extensions,
},
experimental: {
incrementalBuild: true,
resolveNewUrlToAsset: true,
nativeMagicString: true,
},
moduleTypes: {
".wasm": "asset",
},
input: {
discourse: "discourse.js",
vendor: "vendor.js",
"media-optimization-bundle": "media-optimization-bundle.js",
...(!isProduction || process.env.FORCE_BUILD_TESTS
? {
"test-entrypoint": "tests/test-entrypoint.js",
"qunit-live-reload": "qunit-live-reload.js",
}
: undefined),
},
output: {
minify: isProduction,
dir: "dist",
sourcemap: true,
cleanDir: !devMode,
hashCharacters: "base36",
assetFileNames: (asset) => {
if (asset.names?.some((n) => n.endsWith(".wasm"))) {
return "assets/wasm/[name]-[hash].digested[extname]";
}
return "assets/js/[name]-[hash].digested[extname]";
},
chunkFileNames: "assets/js/[name]-[hash].digested.js",
entryFileNames: "assets/js/[name]-[hash].digested.js",
},
watch: {
clearScreen: false,
},
preserveEntrySignatures: "strict",
plugins: [
viteAliasPlugin({ entries: aliases }),
optimizedEmber(),
viteImportGlobPlugin(),
maybeBabel({
babelHelpers: "runtime",
extensions,
parallel: true,
skipPreflightCheck: true, // Skip per-file config verification
babelrc: false, // Skip per-file `.babelrc`/`.babelignore` checks
}),
wrapTestModulesPlugin(),
{
name: "resolve-externals",
resolveId(source) {
if (
source.startsWith("/extra-locales/") ||
source.startsWith("/bootstrap/")
) {
return { external: true, id: source };
}
},
},
{
name: "css-loader",
transform: {
filter: {
id: /\.css$/,
},
handler(code, id) {
return {
code: `
const style = document.createElement("style");
style.innerHTML = ${JSON.stringify(code)};
style.dataset.rolldownModuleId = ${JSON.stringify(relative(import.meta.dirname, id))};
document.head.append(style);
`,
moduleType: "js",
};
},
},
},
{
name: "move-sourcemaps",
generateBundle(_options, bundle) {
const mapEntries = Object.entries(bundle).filter(([f]) =>
f.endsWith(".map")
);
for (const [oldFileName, asset] of mapEntries) {
// assets/js/foo.js.map → assets/map/foo.js.map
const newFileName = oldFileName.replace(
/^assets\/js\//,
"assets/map/"
);
this.emitFile({
type: "asset",
fileName: newFileName,
source: asset.source,
});
delete bundle[oldFileName];
// Patch the corresponding JS chunk
const jsFileName = oldFileName.slice(0, -4);
const chunk = bundle[jsFileName];
if (chunk?.code) {
chunk.code = chunk.code.replace(
/\/\/# sourceMappingURL=.+/,
`//# sourceMappingURL=../map/${basename(oldFileName)}`
);
}
}
},
},
{
name: "bundle-manifest",
generateBundle(_outputOptions, bundle) {
const manifest = {
entrypoints: {},
dynamicEntrypoints: {},
chunks: {},
};
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type !== "chunk") {
continue;
}
const facadeModuleId = chunk.facadeModuleId
? relative(import.meta.dirname, chunk.facadeModuleId)
: null;
if (chunk.isEntry) {
manifest.entrypoints[chunk.name] = fileName;
} else if (
chunk.isDynamicEntry &&
facadeModuleId &&
!facadeModuleId.startsWith("../")
) {
manifest.dynamicEntrypoints[facadeModuleId] = fileName;
}
manifest.chunks[fileName] = {
file: fileName,
facadeModuleId,
name: chunk.name,
isEntry: chunk.isEntry,
isDynamicEntry: chunk.isDynamicEntry,
imports: chunk.imports,
};
}
if (devMode) {
// Workaround rolldown devEngine bug?
fs.mkdirSync("./dist/manifest", { recursive: true });
fs.writeFileSync(
"./dist/manifest/manifest.json",
JSON.stringify(manifest, null, 2)
);
} else {
this.emitFile({
type: "asset",
fileName: "manifest/manifest.json",
source: JSON.stringify(manifest, null, 2),
});
}
},
},
],
};
}
export default buildConfig({ devMode: false });
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import AnsiToHtml from "ansi-to-html";
import * as fs from "fs";
import * as path from "path";
import { dev } from "rolldown/experimental";
import { buildConfig } from "./rolldown.config.mjs";
const ansiConverter = new AnsiToHtml({ newline: true, escapeXML: true });
const CWD_PREFIX = `${process.cwd()}/`;
const MANIFEST_DIR = "./dist/manifest";
const BUILD_STATUS_FILE = `${MANIFEST_DIR}/build.json`;
let buildStart = Date.now();
let initialBuild = true;
let hasError = false;
let pendingChangedFiles = [];
function ansiToHtml(str) {
if (str != null) {
return ansiConverter.toHtml(str);
}
}
function stripCwd(file) {
const relative = path.relative(CWD_PREFIX, file);
return relative;
}
function writeBuildStatus(status) {
fs.mkdirSync(MANIFEST_DIR, { recursive: true });
const payload = {
pid: process.pid,
timestamp: new Date().toISOString(),
...status,
};
fs.writeFileSync(BUILD_STATUS_FILE, JSON.stringify(payload, null, 2));
}
function serializeError(err) {
const base = (rawMessage) => ({
message: rawMessage,
messageHtml: ansiToHtml(rawMessage),
});
if (err == null) {
return base("Unknown error");
}
if (err instanceof Error) {
return { ...base(err.message), name: err.name };
}
if (typeof err === "object") {
return {
...base(err.message ?? String(err)),
location: err.loc || err.location,
frame: ansiToHtml(err.frame || err.codeFrame),
id: err.id,
};
}
return base(String(err));
}
fs.rmSync("./dist", { recursive: true, force: true });
fs.mkdirSync("./dist");
writeBuildStatus({ status: "building" });
console.log("Starting rolldown dev server...");
const resolvedConfig = buildConfig({ devMode: true });
const devEngine = await dev(resolvedConfig, resolvedConfig.output, {
// Avoid `rebuildStrategy: "always"` — it panics in scan_stage_cache when
// recovering from a parse error. Drive rebuilds manually using ensureLatestBuildOutput.
onHmrUpdates(result) {
if (result instanceof Error) {
console.error("Build error:", result.message);
writeBuildStatus({
status: "error",
error: serializeError(result),
});
hasError = true;
return;
}
pendingChangedFiles = result.changedFiles.map(stripCwd);
hasError = false;
buildStart = Date.now();
console.log(`Rebuilding (${pendingChangedFiles.length} changed)...`);
devEngine.ensureLatestBuildOutput();
},
onOutput(result) {
if (hasError) {
return;
} else if (result instanceof Error) {
console.error("Build error:", result.message);
writeBuildStatus({
status: "error",
error: serializeError(result),
});
return;
}
const elapsed = ((Date.now() - buildStart) / 1000).toFixed(2);
const count = result.output.length;
if (initialBuild) {
initialBuild = false;
console.log(`Initial build complete in ${elapsed}s (${count} files)`);
} else {
console.log(
`Rebuild complete in ${elapsed}s (${count} files): ${pendingChangedFiles.join(", ")}`
);
}
writeBuildStatus({ status: "ok" });
},
});
await devEngine.run();
// run() resolves after the initial build and rolldown's native watcher
// doesn't hold the process open reliably
setInterval(() => {}, 0x7fffffff);

Some files were not shown because too many files have changed in this diff Show More