discourse/lib/discourse_js_processor.rb
Justin DiRose 09b8a61f65
FEATURE: Add Google Universal Analytics v4 as an option (#11123)
Per Google, sites are encouraged to upgrade from Universal Analytics v3 `analytics.js` to v4 `gtag.js` for Google Analytics tracking. We're giving admins the option to stay on the v3 API or migrate to v4. Admins can change the implementation they're using via the `ga_version` site setting. Eventually Google will deprecate v3, but our implementation gives admins the choice on what to use for now.

We chose this implementation to make the change less error prone, as many site admins are using custom events via the v3 UA API. With the site stetting defaulted to `v3_analytics`, site analytics won't break until the admin is ready to make the migration.

Additionally, in the v4 implementation, we do not enable automatic pageview tracking (on by default in the v4 API). Instead we rely on Discourse's page change API to report pageviews on transition to avoid double-tracking.
2020-11-06 14:15:36 -06:00

170 lines
6.4 KiB
Ruby

# frozen_string_literal: true
require 'execjs'
require 'mini_racer'
class DiscourseJsProcessor
def self.plugin_transpile_paths
@@plugin_transpile_paths ||= Set.new
end
def self.call(input)
root_path = input[:load_path] || ''
logical_path = (input[:filename] || '').sub(root_path, '').gsub(/\.(js|es6).*$/, '').sub(/^\//, '')
data = input[:data]
if should_transpile?(input[:filename])
data = transpile(data, root_path, logical_path)
end
# add sourceURL until we can do proper source maps
unless Rails.env.production?
data = "eval(#{data.inspect} + \"\\n//# sourceURL=#{logical_path}\");\n"
end
{ data: data }
end
def self.transpile(data, root_path, logical_path)
transpiler = Transpiler.new(skip_module: skip_module?(data))
transpiler.perform(data, root_path, logical_path)
end
def self.should_transpile?(filename)
filename ||= ''
# es6 is always transpiled
return true if filename.end_with?(".es6") || filename.end_with?(".es6.erb")
# For .js check the path...
return false unless filename.end_with?(".js") || filename.end_with?(".js.erb")
relative_path = filename.sub(Rails.root.to_s, '').sub(/^\/*/, '')
js_root = "app/assets/javascripts"
test_root = "test/javascripts"
return false if relative_path.start_with?("#{js_root}/locales/")
return false if relative_path.start_with?("#{js_root}/plugins/")
return true if %w(
start-discourse
wizard-start
onpopstate-handler
google-tag-manager
google-universal-analytics-v3
google-universal-analytics-v4
activate-account
auto-redirect
embed-application
app-boot
).any? { |f| relative_path == "#{js_root}/#{f}.js" }
return true if plugin_transpile_paths.any? { |prefix| relative_path.start_with?(prefix) }
!!(relative_path =~ /^#{js_root}\/[^\/]+\// ||
relative_path =~ /^#{test_root}\/[^\/]+\//)
end
def self.skip_module?(data)
!!(data.present? && data =~ /^\/\/ discourse-skip-module$/)
end
class Transpiler
@mutex = Mutex.new
@ctx_init = Mutex.new
def self.mutex
@mutex
end
def self.create_new_context
# timeout any eval that takes longer than 15 seconds
ctx = MiniRacer::Context.new(timeout: 15000, ensure_gc_after_idle: 2000)
ctx.eval("var self = this; #{File.read("#{Rails.root}/vendor/assets/javascripts/babel.js")}")
ctx.eval(File.read(Ember::Source.bundled_path_for('ember-template-compiler.js')))
ctx.eval("module = {}; exports = {};")
ctx.attach("rails.logger.info", proc { |err| Rails.logger.info(err.to_s) })
ctx.attach("rails.logger.error", proc { |err| Rails.logger.error(err.to_s) })
ctx.eval <<JS
console = {
prefix: "",
log: function(msg){ rails.logger.info(console.prefix + msg); },
error: function(msg){ rails.logger.error(console.prefix + msg); }
}
JS
source = File.read("#{Rails.root}/lib/javascripts/widget-hbs-compiler.js")
js_source = ::JSON.generate(source, quirks_mode: true)
js = ctx.eval("Babel.transform(#{js_source}, { ast: false, plugins: ['transform-arrow-functions', 'transform-block-scoped-functions', 'transform-block-scoping', 'transform-computed-properties', 'transform-destructuring', 'transform-duplicate-keys', 'transform-for-of', 'transform-function-name', 'transform-literals', 'transform-object-super', 'transform-parameters', 'transform-shorthand-properties', 'transform-spread', 'transform-sticky-regex', 'transform-template-literals', 'transform-typeof-symbol', 'transform-unicode-regex'] }).code")
ctx.eval(js)
ctx
end
def self.reset_context
@ctx&.dispose
@ctx = nil
end
def self.v8
return @ctx if @ctx
# ensure we only init one of these
@ctx_init.synchronize do
return @ctx if @ctx
@ctx = create_new_context
end
@ctx
end
def initialize(skip_module: false)
@skip_module = skip_module
end
def perform(source, root_path = nil, logical_path = nil)
klass = self.class
klass.mutex.synchronize do
klass.v8.eval("console.prefix = 'BABEL: babel-eval: ';")
transpiled = babel_source(
source,
module_name: module_name(root_path, logical_path),
filename: logical_path
)
@output = klass.v8.eval(transpiled)
end
end
def babel_source(source, opts = nil)
opts ||= {}
js_source = ::JSON.generate(source, quirks_mode: true)
if opts[:module_name] && !@skip_module
filename = opts[:filename] || 'unknown'
"Babel.transform(#{js_source}, { moduleId: '#{opts[:module_name]}', filename: '#{filename}', ast: false, presets: ['es2015'], plugins: [['transform-modules-amd', {noInterop: true}], ['proposal-decorators', {legacy: true} ], exports.WidgetHbsCompiler] }).code"
else
"Babel.transform(#{js_source}, { ast: false, plugins: ['proposal-json-strings', 'proposal-nullish-coalescing-operator', 'proposal-logical-assignment-operators', 'proposal-numeric-separator', 'proposal-optional-catch-binding', 'transform-dotall-regex', 'proposal-unicode-property-regex', 'transform-named-capturing-groups-regex', 'proposal-object-rest-spread', 'proposal-optional-chaining', 'transform-arrow-functions', 'transform-block-scoped-functions', 'transform-block-scoping', 'transform-computed-properties', 'transform-destructuring', 'transform-duplicate-keys', 'transform-for-of', 'transform-function-name', 'transform-literals', 'transform-object-super', 'transform-parameters', 'transform-shorthand-properties', 'transform-spread', 'transform-sticky-regex', 'transform-template-literals', 'transform-typeof-symbol', 'transform-unicode-regex', ['proposal-decorators', {legacy: true}], exports.WidgetHbsCompiler] }).code"
end
end
def module_name(root_path, logical_path)
path = nil
root_base = File.basename(Rails.root)
# If the resource is a plugin, use the plugin name as a prefix
if root_path =~ /(.*\/#{root_base}\/plugins\/[^\/]+)\//
plugin_path = "#{Regexp.last_match[1]}/plugin.rb"
plugin = Discourse.plugins.find { |p| p.path == plugin_path }
path = "discourse/plugins/#{plugin.name}/#{logical_path.sub(/javascripts\//, '')}" if plugin
end
# We need to strip the app subdirectory to replicate how ember-cli works.
path || logical_path&.gsub('app/', '')&.gsub('addon/', '')&.gsub('admin/addon', 'admin')
end
end
end