diff --git a/README.md b/README.md
index 6e040a0..2451737 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,36 @@ Or with addons:
//= require react-with-addons
```
+
+#### React Helper & Server Rendering
+
+`react_component` is a helper that will let you pass in data from ruby code and have it rendered via React. This rendering can be done on the client (default), or on the server, which is handy for SEO.
+
+```
+<%= react_component "Book", {data: book } %>
+```
+
+For server prerendering you need to include the `react_ujs` javascript component to wire up the prendered content. So:
+
+```
+<%= react_component "Book", {data: book }, {prerender: true} %>
+```
+
+In your main `.js` file:
+
+```
+//= require react_ujs
+```
+
+And in `config.rb`:
+
+```
+after_configuration do
+ sprockets.append_path File.dirname(Middleman::React::react_ujs_path)
+end
+```
+
+
#### A note on versioning
The version for this gem will reflect that of the underlying version of `react-source`, meaning that using `0.12.1` of this gem will give you version `0.12.1` of React. This is the same approach that `react-rails` takes.
diff --git a/fixtures/app/config.rb b/fixtures/app/config.rb
index e059c70..d1d7adb 100644
--- a/fixtures/app/config.rb
+++ b/fixtures/app/config.rb
@@ -1 +1,8 @@
+set :js_dir, 'assets/javascripts'
+
activate :react
+
+after_configuration do
+ sprockets.append_path File.dirname(::React::Source.bundled_path_for('react.js'))
+ sprockets.append_path File.dirname(Middleman::React::react_ujs_path)
+end
\ No newline at end of file
diff --git a/fixtures/app/source/assets/javascripts/all.js b/fixtures/app/source/assets/javascripts/all.js
new file mode 100644
index 0000000..fda0750
--- /dev/null
+++ b/fixtures/app/source/assets/javascripts/all.js
@@ -0,0 +1,3 @@
+//= require react
+//= require react_ujs
+//= require ./components.js
\ No newline at end of file
diff --git a/fixtures/app/source/assets/javascripts/components.js.jsx b/fixtures/app/source/assets/javascripts/components.js.jsx
new file mode 100644
index 0000000..7fbb383
--- /dev/null
+++ b/fixtures/app/source/assets/javascripts/components.js.jsx
@@ -0,0 +1,7 @@
+var HelloWorld = React.createClass({
+ render: function() {
+ return (
+
Hello {this.props}
+ );
+ }
+});
\ No newline at end of file
diff --git a/fixtures/app/source/index.html.erb b/fixtures/app/source/index.html.erb
new file mode 100644
index 0000000..69387c4
--- /dev/null
+++ b/fixtures/app/source/index.html.erb
@@ -0,0 +1,2 @@
+<%= javascript_include_tag 'all' %>
+<%= react_component "HelloWorld", { data: "World" }, {prerender: true} %>
\ No newline at end of file
diff --git a/javascript/react_ujs.js b/javascript/react_ujs.js
new file mode 100644
index 0000000..583f3ed
--- /dev/null
+++ b/javascript/react_ujs.js
@@ -0,0 +1,104 @@
+/*globals React, Turbolinks*/
+
+// Unobtrusive scripting adapter for React
+;(function(document, window) {
+ // jQuery is optional. Use it to support legacy browsers.
+ var $ = (typeof window.jQuery !== 'undefined') && window.jQuery;
+
+ // create the namespace
+ window.ReactRailsUJS = {
+ CLASS_NAME_ATTR: 'data-react-class',
+ PROPS_ATTR: 'data-react-props',
+ RAILS_ENV_DEVELOPMENT: true, //<%= Rails.env == "development" %>,
+ // helper method for the mount and unmount methods to find the
+ // `data-react-class` DOM elements
+ findDOMNodes: function(searchSelector) {
+ // we will use fully qualified paths as we do not bind the callbacks
+ var selector;
+ if (typeof searchSelector === 'undefined') {
+ var selector = '[' + window.ReactRailsUJS.CLASS_NAME_ATTR + ']';
+ } else {
+ var selector = searchSelector + ' [' + window.ReactRailsUJS.CLASS_NAME_ATTR + ']';
+ }
+
+ if ($) {
+ return $(selector);
+ } else {
+ return document.querySelectorAll(selector);
+ }
+ },
+
+ mountComponents: function(searchSelector) {
+ var nodes = window.ReactRailsUJS.findDOMNodes(searchSelector);
+
+ for (var i = 0; i < nodes.length; ++i) {
+ var node = nodes[i];
+ var className = node.getAttribute(window.ReactRailsUJS.CLASS_NAME_ATTR);
+
+ // Assume className is simple and can be found at top-level (window).
+ // Fallback to eval to handle cases like 'My.React.ComponentName'.
+ var constructor = window[className] || eval.call(window, className);
+ var propsJson = node.getAttribute(window.ReactRailsUJS.PROPS_ATTR);
+ var props = propsJson && JSON.parse(propsJson);
+
+ React.render(React.createElement(constructor, props), node);
+ }
+ },
+
+ unmountComponents: function(searchSelector) {
+ var nodes = window.ReactRailsUJS.findDOMNodes(searchSelector);
+
+ for (var i = 0; i < nodes.length; ++i) {
+ var node = nodes[i];
+
+ React.unmountComponentAtNode(node);
+ }
+ }
+ };
+
+ // functions not exposed publicly
+ function handleTurbolinksEvents () {
+ var handleEvent;
+ var unmountEvent;
+
+ if ($) {
+ handleEvent = function(eventName, callback) {
+ $(document).on(eventName, callback);
+ };
+
+ } else {
+ handleEvent = function(eventName, callback) {
+ document.addEventListener(eventName, callback);
+ };
+ }
+
+ if (Turbolinks.EVENTS) {
+ unmountEvent = Turbolinks.EVENTS.BEFORE_UNLOAD;
+ } else {
+ unmountEvent = 'page:receive';
+ Turbolinks.pagesCached(0);
+
+ if (window.ReactRailsUJS.RAILS_ENV_DEVELOPMENT) {
+ console.warn('The Turbolinks cache has been disabled (Turbolinks >= 2.4.0 is recommended). See https://github.com/reactjs/react-rails/issues/87 for more information.');
+ }
+ }
+ handleEvent('page:change', function() {window.ReactRailsUJS.mountComponents()});
+ handleEvent(unmountEvent, function() {window.ReactRailsUJS.unmountComponents()});
+ }
+
+ function handleNativeEvents() {
+ if ($) {
+ $(function() {window.ReactRailsUJS.mountComponents()});
+ $(window).unload(function() {window.ReactRailsUJS.unmountComponents()});
+ } else {
+ document.addEventListener('DOMContentLoaded', function() {window.ReactRailsUJS.mountComponents()});
+ window.addEventListener('unload', function() {window.ReactRailsUJS.unmountComponents()});
+ }
+ }
+
+ if (typeof Turbolinks !== 'undefined' && Turbolinks.supported) {
+ handleTurbolinksEvents();
+ } else {
+ handleNativeEvents();
+ }
+})(document, window);
diff --git a/lib/middleman-react/extension.rb b/lib/middleman-react/extension.rb
index 32638c2..de7a501 100644
--- a/lib/middleman-react/extension.rb
+++ b/lib/middleman-react/extension.rb
@@ -3,6 +3,7 @@
require 'middleman-core'
require 'middleman-react/jsx'
require 'middleman-react/jsx/template'
+require 'middleman-react/helpers'
module Middleman
module React
@@ -10,6 +11,8 @@ module React
class Extension < Middleman::Extension
option :harmony, false, 'The option of harmony'
option :strip_types, false, 'The option of stripTypes'
+ option :components, ['components.js'], 'List of files for precompiling'
+ option :replay_console, true, 'Replay console for prerendered components'
def initialize(app, options_hash = {}, &block)
super
@@ -17,8 +20,13 @@ def initialize(app, options_hash = {}, &block)
Middleman::React::Template.harmony = options[:harmony]
Middleman::React::Template.strip_types = options[:strip_types]
+ Middleman::React::Renderer.components = options[:components]
+ Middleman::React::Renderer.replay_console = options[:replay_console]
+
::Tilt.register 'jsx', Middleman::React::Template
::Sprockets.register_engine 'jsx', Middleman::React::Template
+
+ app.helpers Middleman::React::Helpers
end
end
end
diff --git a/lib/middleman-react/helpers.rb b/lib/middleman-react/helpers.rb
new file mode 100644
index 0000000..c369f1a
--- /dev/null
+++ b/lib/middleman-react/helpers.rb
@@ -0,0 +1,33 @@
+require 'middleman-react/renderer'
+
+module Middleman
+ module React
+ # Helper method to render react components
+ module Helpers
+ # rubocop:disable Metrics/MethodLength
+ def react_component(name, args = {}, options = {}, &block)
+ options = { tag: options } if options.is_a?(Symbol)
+ content = ''
+
+ if options[:prerender]
+ content = Middleman::React::Renderer.render(self, name, args)
+ end
+
+ html_options = options.reverse_merge(data: {})
+ html_options[:data].tap do |data|
+ data[:react_class] = name
+ unless args.empty?
+ data[:react_props] = Middleman::React::Renderer.react_props(args)
+ end
+ end
+
+ html_tag = html_options[:tag] || :div
+
+ # remove internally used properties so they aren't rendered to DOM
+ html_options.except!(:tag, :prerender)
+
+ content_tag(html_tag, content, html_options, &block)
+ end
+ end
+ end
+end
diff --git a/lib/middleman-react/renderer.rb b/lib/middleman-react/renderer.rb
new file mode 100644
index 0000000..5a5dd2f
--- /dev/null
+++ b/lib/middleman-react/renderer.rb
@@ -0,0 +1,105 @@
+module Middleman
+ # Handles server-side react rendering
+ module React
+ def self.react_ujs_path
+ File.expand_path('../../../javascript/react_ujs.js', __FILE__)
+ end
+
+ # The rendering logic
+ class Renderer
+ # For when we get a JS error
+ class PrerenderError < RuntimeError
+ def initialize(component_name, props, js_message)
+ message = [
+ "Encountered error \"#{js_message}\" when prerendering" \
+ " #{component_name} with #{props}",
+ js_message.backtrace.join("\n")].join("\n")
+ super(message)
+ end
+ end
+
+ cattr_accessor :components
+ cattr_accessor :replay_console
+
+ # rubocop:disable Metrics/LineLength, Metrics/MethodLength, ClassVars
+ def self.render(app, component, args = {})
+ context = ExecJS.compile(combined_js(app))
+ begin
+ react_props = React::Renderer.react_props(args)
+ jscode = <<-JS
+ (function () {
+ var result = React.renderToString(React.createElement(#{component}, #{react_props}));
+ #{@@replay_console ? Console.replay_as_script_js : ''}
+ return result;
+ })()
+ JS
+ context.eval(jscode).html_safe
+ rescue ExecJS::ProgramError => e
+ raise PrerenderError.new(component, react_props, e)
+ end
+ end
+ # rubocop:enable Metrics/LineLength, Metrics/MethodLength
+
+ def self.react_js
+ @@react_js ||= File.read(::React::Source.bundled_path_for('react.js'))
+ end
+
+ def self.components_js(app)
+ @@components.collect do |comp|
+ app.sprockets[comp].source
+ end.join('\n')
+ end
+
+ def self.combined_js(app)
+ @@combined_js = <<-JS
+ var global = global || this;
+ var self = self || this;
+ var window = window || this;
+ #{Console.polyfill_js}
+ #{react_js};
+ React = global.React;
+ #{components_js(app)};
+ JS
+ end
+
+ def self.react_props(args = {})
+ if args.is_a? String
+ args
+ else
+ args.to_json
+ end
+ end
+ end
+
+ # Overwrite global `console` object with something that can capture
+ # messages to return to client later for debugging
+ class Console
+ # rubocop:disable Metrics/LineLength, Metrics/MethodLength
+ def self.polyfill_js
+ <<-JS
+ var console = { history: [] };
+ ['error', 'log', 'info', 'warn'].forEach(function (fn) {
+ console[fn] = function () {
+ console.history.push({level: fn, arguments: Array.prototype.slice.call(arguments)});
+ };
+ });
+ JS
+ end
+
+ def self.replay_as_script_js
+ <<-JS
+ (function (history) {
+ if (history && history.length > 0) {
+ result += '\\n';
+ history.forEach(function (msg) {
+ result += '\\nconsole.' + msg.level + '.apply(console, ' + JSON.stringify(msg.arguments) + ');';
+ });
+ result += '\\n';
+ }
+ })(console.history);
+ JS
+ end
+ # rubocop:enable Metrics/LineLength, Metrics/MethodLength
+ end
+ end
+end
diff --git a/spec/features/jsx.feature b/spec/features/jsx.feature
index 94714a3..4287e0c 100644
--- a/spec/features/jsx.feature
+++ b/spec/features/jsx.feature
@@ -11,6 +11,7 @@ Feature: Transforming JSX into Javascript
Then the stdout from "cat assets/javascripts/plain_jsx.js" should contain exactly:
"""
/** @jsx React.DOM */
+
React.createElement("div", null);
"""
diff --git a/spec/features/prerender.feature b/spec/features/prerender.feature
new file mode 100644
index 0000000..702db3e
--- /dev/null
+++ b/spec/features/prerender.feature
@@ -0,0 +1,12 @@
+Feature: Prerendering a component
+ Background:
+ Given a fixture app "app"
+ Given a successfully built app at "app"
+
+ Scenario: Rendering Helloworld
+ When I cd to "build"
+ Then the following files should exist:
+ | index.html |
+ When I run `cat index.html`
+ Then the output should contain "Hello "
+ And the output should contain "World"