diff --git a/app/assets/javascripts/admin/components/ace-editor.js b/app/assets/javascripts/admin/components/ace-editor.js index a67d8139654ce0..f4868546757d4f 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js +++ b/app/assets/javascripts/admin/components/ace-editor.js @@ -81,7 +81,7 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); - loadScript("/javascripts/ace/ace.js?v=1.4.12").then(() => { + loadScript("/javascripts/ace/ace.js").then(() => { window.ace.require(["ace/ace"], loadedAce => { loadedAce.config.set("loadWorkerFromBlob", false); loadedAce.config.set("workerPath", getURL("/javascripts/ace")); // Do not use CDN for workers diff --git a/app/assets/javascripts/discourse/app/lib/load-script.js b/app/assets/javascripts/discourse/app/lib/load-script.js index 52babb1952f96d..1a014fcb248dd3 100644 --- a/app/assets/javascripts/discourse/app/lib/load-script.js +++ b/app/assets/javascripts/discourse/app/lib/load-script.js @@ -1,6 +1,7 @@ import { default as getURL, getURLWithCDN } from "discourse-common/lib/get-url"; import { run } from "@ember/runloop"; import { ajax } from "discourse/lib/ajax"; +import { PUBLIC_JS_VERSIONS } from "discourse/lib/public_js_versions"; import { Promise } from "rsvp"; const _loaded = {}; @@ -16,7 +17,7 @@ function loadWithTag(path, cb) { Ember.Test.registerWaiter(() => finished); } - s.onload = s.onreadystatechange = function(_, abort) { + s.onload = s.onreadystatechange = function (_, abort) { finished = true; if ( abort || @@ -50,6 +51,10 @@ export default function loadScript(url, opts) { return Promise.resolve(); } + if (PUBLIC_JS_VERSIONS && !opts.css) { + url = cacheBuster(url); + } + // Scripts should always load from CDN // CSS is type text, to accept it from a CDN we would need to handle CORS const fullUrl = opts.css ? getURL(url) : getURLWithCDN(url); @@ -62,7 +67,7 @@ export default function loadScript(url, opts) { } }); - return new Promise(function(resolve) { + return new Promise(function (resolve) { // If we already loaded this url if (_loaded[fullUrl]) { return resolve(); @@ -72,15 +77,15 @@ export default function loadScript(url, opts) { } let done; - _loading[fullUrl] = new Promise(function(_done) { + _loading[fullUrl] = new Promise(function (_done) { done = _done; }); - _loading[fullUrl].then(function() { + _loading[fullUrl].then(function () { delete _loading[fullUrl]; }); - const cb = function(data) { + const cb = function (data) { if (opts && opts.css) { $("head").append(""); } @@ -94,7 +99,7 @@ export default function loadScript(url, opts) { ajax({ url: fullUrl, dataType: "text", - cache: true + cache: true, }).then(cb); } else { // Always load JavaScript with script tag to avoid Content Security Policy inline violations @@ -102,3 +107,17 @@ export default function loadScript(url, opts) { } }); } + +export function cacheBuster(url) { + if (PUBLIC_JS_VERSIONS) { + const pathParts = url.split("/"); + if (pathParts[1] === "javascripts") { + const version = PUBLIC_JS_VERSIONS[pathParts[2]]; + if (version !== undefined) { + return `${url}?v=${version}`; + } + } + } + + return url; +} diff --git a/app/assets/javascripts/discourse/app/lib/public_js_versions.js b/app/assets/javascripts/discourse/app/lib/public_js_versions.js new file mode 100644 index 00000000000000..453e04069351f5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/public_js_versions.js @@ -0,0 +1,12 @@ +// DO NOT EDIT THIS FILE!!! +// Update it by running `rake javascript:update` + +export const PUBLIC_JS_VERSIONS = { + ace: "1.4.12", + "Chart.min.js": "2.9.3", + "chartjs-plugin-datalabels.min.js": "0.7.0", + "jquery.magnific-popup.min.js": "1.1.0", + "pikaday.js": "1.8.0", + "spectrum.js": "1.8.0", + workbox: "4.3.1", +}; diff --git a/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js b/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js index 15b9ef82922cbf..7728f903d6f577 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/discourse-bootstrap.js @@ -6,7 +6,7 @@ import { setEnvironment, isTesting, isProduction, - isDevelopment + isDevelopment, } from "discourse-common/config/environment"; import { setupURL, setupS3CDN } from "discourse-common/lib/get-url"; import deprecated from "discourse-common/lib/deprecated"; @@ -32,7 +32,7 @@ export default { if (preloadedDataElement) { const preloaded = JSON.parse(preloadedDataElement.dataset.preloaded); - Object.keys(preloaded).forEach(function(key) { + Object.keys(preloaded).forEach(function (key) { PreloadStore.store(key, JSON.parse(preloaded[key])); if (setupData.debugPreloadedAppData === "true") { @@ -48,20 +48,20 @@ export default { get() { deprecated(`use "get-url" helpers instead of Discourse.BaseUrl`, { since: "2.5", - dropFrom: "2.6" + dropFrom: "2.6", }); return baseUrl; - } + }, }); let baseUri = setupData.baseUri; Object.defineProperty(app, "BaseUri", { get() { deprecated(`use "get-url" helpers instead of Discourse.BaseUri`, { since: "2.5", - dropFrom: "2.6" + dropFrom: "2.6", }); return baseUri; - } + }, }); setupURL(setupData.cdn, baseUrl, setupData.baseUri); setEnvironment(setupData.environment); @@ -102,7 +102,7 @@ export default { setupS3CDN(setupData.s3BaseUrl, setupData.s3Cdn); } - RSVP.configure("onerror", function(e) { + RSVP.configure("onerror", function (e) { // Ignore TransitionAborted exceptions that bubble up if (e && e.message === "TransitionAborted") { return; @@ -125,5 +125,5 @@ export default { window.onerror(e && e.message, null, null, null, e); }); - } + }, }; diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index a5f4a99c4f94ea..79168dce552ef4 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -16,10 +16,10 @@ def library_src "#{Rails.root}/node_modules" end -def write_template(path, template) +def write_template(path, task_name, template) header = <<~HEADER // DO NOT EDIT THIS FILE!!! - // Update it by running `rake javascript:update_constants` + // Update it by running `rake javascript:#{task_name}` HEADER basename = File.basename(path) @@ -32,13 +32,15 @@ def write_template(path, template) end task 'javascript:update_constants' => :environment do - write_template("discourse/app/lib/constants.js", <<~JS) + task_name = 'update_constants' + + write_template("discourse/app/lib/constants.js", task_name, <<~JS) export const SEARCH_PRIORITIES = #{Searchable::PRIORITIES.to_json}; export const SEARCH_PHRASE_REGEXP = '#{Search::PHRASE_MATCH_REGEXP_PATTERN}'; JS - write_template("pretty-text/addon/emoji/data.js", <<~JS) + write_template("pretty-text/addon/emoji/data.js", task_name, <<~JS) export const emojis = #{Emoji.standard.map(&:name).flatten.inspect}; export const tonableEmojis = #{Emoji.tonable_emojis.flatten.inspect}; export const aliases = #{Emoji.aliases.inspect.gsub("=>", ":")}; @@ -47,7 +49,7 @@ task 'javascript:update_constants' => :environment do export const replacements = #{Emoji.unicode_replacements_json}; JS - write_template("pretty-text/addon/emoji/version.js", <<~JS) + write_template("pretty-text/addon/emoji/version.js", task_name, <<~JS) export const IMAGE_VERSION = "#{Emoji::EMOJI_VERSION}"; JS end @@ -84,7 +86,8 @@ task 'javascript:update' do public: true }, { source: 'spectrum-colorpicker/spectrum.css', - public: true + public: true, + skip_versioning: true }, { source: 'favcount/favcount.js' }, { @@ -173,6 +176,7 @@ task 'javascript:update' do ] + versions = {} start = Time.now dependencies.each do |f| @@ -206,6 +210,12 @@ task 'javascript:update' do if f[:public_root] dest = "#{public_root}/#{filename}" elsif f[:public] + unless f[:skip_versioning] + package_name = f[:source].split('/').first + package_version = JSON.parse(File.read("#{library_src}/#{package_name}/package.json"))["version"] + versions[filename] = package_version + end + dest = "#{public_js}/#{filename}" else dest = "#{vendor_js}/#{filename}" @@ -214,6 +224,7 @@ task 'javascript:update' do if src.include? "ace.js" ace_root = "#{library_src}/ace-builds/src-min-noconflict/" addtl_files = [ "ext-searchbox", "mode-html", "mode-scss", "mode-sql", "theme-chrome", "worker-html"] + FileUtils.mkdir(dest) unless File.directory?(dest) addtl_files.each do |file| FileUtils.cp_r("#{ace_root}#{file}.js", dest) end @@ -236,5 +247,9 @@ task 'javascript:update' do end end + write_template("discourse/app/lib/public_js_versions.js", "update", <<~JS) + export const PUBLIC_JS_VERSIONS = #{versions.to_json}; + JS + STDERR.puts "Completed copying dependencies: #{(Time.now - start).round(2)} secs" end diff --git a/test/javascripts/lib/load-script-test.js b/test/javascripts/lib/load-script-test.js index e73a1bafa96ee0..cd457d3c275bb5 100644 --- a/test/javascripts/lib/load-script-test.js +++ b/test/javascripts/lib/load-script-test.js @@ -1,10 +1,11 @@ -import loadScript from "discourse/lib/load-script"; +import { loadScript, cacheBuster } from "discourse/lib/load-script"; +import { PUBLIC_JS_VERSIONS as jsVersions } from "discourse/lib/public_js_versions"; QUnit.module("lib:load-script"); QUnit.skip( "load with a script tag, and callbacks are only executed after script is loaded", - async assert => { + async (assert) => { assert.ok( typeof window.ace === "undefined", "ensures ace is not previously loaded" @@ -19,3 +20,25 @@ QUnit.skip( ); } ); + +QUnit.test("works when a value is not present", async (assert) => { + assert.equal( + cacheBuster("/javascripts/my-script.js"), + "/javascripts/my-script.js" + ); + assert.equal( + cacheBuster("/javascripts/my-project/script.js"), + "/javascripts/my-project/script.js" + ); +}); + +QUnit.test("generates URLs with a hash", async (assert) => { + assert.equal( + cacheBuster("/javascripts/pikaday.js"), + `/javascripts/pikaday.js?v=${jsVersions["pikaday.js"]}` + ); + assert.equal( + cacheBuster("/javascripts/ace/ace.js"), + `/javascripts/ace/ace.js?v=${jsVersions["ace"]}` + ); +});