jekyll-mathjax-csp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +70 -0
  3. data/lib/jekyll-mathjax-csp.rb +217 -0
  4. metadata +74 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7e8d81d890fe7c24488fd6fd9573d86504a184d0
4
+ data.tar.gz: 78b3d2fdf8c0065dfbbb6a47d1f3d84652e2e94f
5
+ SHA512:
6
+ metadata.gz: b616058752d9bd91f13d962e0f4a0e4a6d7a8436103d44e9168cea473fffcf20d636a5367fc46313e7103bc34096ed5acb8833d96bde791ab5e8579d651b1e26
7
+ data.tar.gz: c92c0463764c80f2ddeac44b4b1f4e9a7c93581c13b3aa9968b1f9cc63fcce15fc9521a6563065c818f111f40ac178f8b9dd05b6f4a80814818ede6c29a15a8c
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # jekyll-mathjax-csp
2
+
3
+ Render math on the server using [MathJax-node](https://github.com/mathjax/MathJax-node), while maintaining a strict [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) without `'unsafe-inline'`.
4
+
5
+ While MathJax is well equipped to render beautiful math in a browser, letting it run in the client has two distinctive disadvantages: It is quite CPU-intensive and crucially relies on inline `style` attributes and elements. This Jekyll plugin aims to resolve both issues at once by rendering formulas to SVG images on the server, extracting all generated `style` attributes into a single `<style>` element in the head of the page and computing a hash over its content that can then be added as a CSP `style-src`.
6
+
7
+ The plugin runs the output of Jekyll's markdown parser [kramdown](http://kramdown.gettalong.org/) through the CLI converter `mjpage` offered by the npm package [`mathjax-node-page`](https://github.com/pkra/mathjax-node-page) and thus behaves exactly as client-side MathJax in SVG rendering mode would.
8
+
9
+ ## Usage
10
+
11
+ 1. Install the npm package `mathjax-node-page` from your top-level Jekyll directory:
12
+
13
+ ```bash
14
+ npm init -f # only if you don't have a package.json yet
15
+ npm install mathjax-node-page@2.X
16
+ ```
17
+
18
+ 2. Install `jekyll-mathjax-csp`:
19
+
20
+ ```bash
21
+ gem install jekyll-mathjax-csp
22
+ ```
23
+
24
+ 3. Ensure that your `_config.yml` contains the following settings:
25
+
26
+ ```yaml
27
+ plugins:
28
+ - jekyll-mathjax-csp
29
+
30
+ exclude:
31
+ - node_modules
32
+ - package.json
33
+ - package-lock.json
34
+ ```
35
+
36
+ 4. Add the `{% mathjax_sources %}` Liquid tag where you want the CSP `'sha256-...'` hashes for `<style>` elements to be emitted. Don't forget to add the YAML front matter (two lines of `---`) to such files. If you specify your CSP in a different way, add the `style-src` sources the plugins prints to the console during build.
37
+
38
+ 5. Include beautiful math in your posts!
39
+
40
+ ## Dependencies
41
+
42
+ * `mathjax-node-page` (npm): 2.0+
43
+ * `html-pipeline`: 2.3+
44
+ * `jekyll`: 3.0+
45
+
46
+ ## Configuration
47
+
48
+ 'mathjax-node-page' adds a fixed inline stylesheet to every page containing math. If you want to serve this stylesheet as an external `.css`, you can advise the plugin to strip it from the output by adding the following lines to your `_config.yml`:
49
+
50
+ ```yaml
51
+ mathjax_csp:
52
+ strip_css: true
53
+ ```
54
+
55
+ ## Local testing
56
+
57
+ If you want to try out your CSP locally, you can specify headers in your `_config.yml`:
58
+
59
+ ```yaml
60
+ webrick:
61
+ headers:
62
+ Content-Security-Policy: >-
63
+ default-src 'none'; script-src ...
64
+ ```
65
+
66
+ It is unfortunately not possible to have Liquid tags in `_config.yml`, so you will have to update your CSP manually. Don't forget to restart `jekyll` for it to pick up the config changes.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,217 @@
1
+ # Server-side, CSP-aware math rendering for Jekyll using mathjax-node-page
2
+ #
3
+ # The MIT License (MIT)
4
+ # =====================
5
+ #
6
+ # Copyright © 2018 Fabian Henneke
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person
9
+ # obtaining a copy of this software and associated documentation
10
+ # files (the “Software”), to deal in the Software without
11
+ # restriction, including without limitation the rights to use,
12
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ # copies of the Software, and to permit persons to whom the
14
+ # Software is furnished to do so, subject to the following
15
+ # conditions:
16
+ #
17
+ # The above copyright notice and this permission notice shall be
18
+ # included in all copies or substantial portions of the Software.
19
+ #
20
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27
+ # OTHER DEALINGS IN THE SOFTWARE.
28
+
29
+ require "html/pipeline"
30
+ require "jekyll"
31
+
32
+ require "digest"
33
+ require "open3"
34
+ require "securerandom"
35
+ require "set"
36
+
37
+ module Jekyll
38
+
39
+ # Run Jekyll documents through mathjax-node-page, transform style attributes into inline style
40
+ # tags and compute their hashes
41
+ class Mathifier
42
+ MATH_TAG_REGEX = /<script[^>]*type="math\/tex/i
43
+
44
+ class << self
45
+ attr_accessor :csp_hashes
46
+
47
+ # Extract all style attributes from SVG elements and replace them by a new CSS class with a
48
+ # deterministic name
49
+ def extractStyleAttributes(parsed_doc)
50
+ style_attributes = {}
51
+ svg_tags = parsed_doc.css("svg[style]")
52
+ for svg_tag in svg_tags do
53
+ style_attribute = svg_tag["style"]
54
+ digest = Digest::MD5.hexdigest(style_attribute)[0..15]
55
+ style_attributes[digest] = style_attribute
56
+
57
+ digest_class = "mathjax-inline-#{digest}"
58
+ svg_tag["class"] = "#{svg_tag["class"] || ""} #{digest_class}"
59
+ svg_tag.remove_attribute("style")
60
+ end
61
+ return style_attributes
62
+ end
63
+
64
+ # Compute a CSP hash source (using SHA256)
65
+ def hashStyleTag(style_tag)
66
+ csp_digest = "'sha256-#{Digest::SHA256.base64digest(style_tag.content)}'"
67
+ style_tag.add_previous_sibling("<!-- #{csp_digest} -->")
68
+ @csp_hashes.add(csp_digest)
69
+ end
70
+
71
+ # Compile all style attributes into CSS classes in a single <style> element in the head
72
+ def compileStyleElement(parsed_doc, style_attributes)
73
+ style_content = ""
74
+ style_attributes.each do |digest, style_attribute|
75
+ style_content += ".mathjax-inline-#{digest}{#{style_attribute}}"
76
+ end
77
+ style_tag = parsed_doc.at_css("head").add_child("<style>#{style_content}</style>")[0]
78
+ hashStyleTag(style_tag)
79
+ end
80
+
81
+ # Run mathjax-node-page on a String containing an HTML doc
82
+ def run_mjpage(output)
83
+ mathified = ""
84
+ exit_status = 0
85
+ begin
86
+ Open3.popen2("node_modules/mathjax-node-page/bin/mjpage") {|i,o,t|
87
+ i.print output
88
+ i.close
89
+ o.each {|line|
90
+ mathified.concat(line)
91
+ }
92
+ exit_status = t.value
93
+ }
94
+ return mathified unless exit_status != 0
95
+ Jekyll.logger.abort_with "mathjax_csp:", "'node_modules/mathjax-node-page/mjpage' not found"
96
+ rescue
97
+ Jekyll.logger.abort_with "mathjax_csp:", "Failed to execute 'node_modules/mathjax-node-page/mjpage'"
98
+ end
99
+
100
+ end
101
+
102
+ # Render math
103
+ def mathify(doc, config)
104
+ return unless MATH_TAG_REGEX.match?(doc.output)
105
+
106
+ Jekyll.logger.info "Rendering math:", doc.relative_path
107
+ parsed_doc = Nokogiri::HTML::Document.parse(doc.output)
108
+ # Ensure that all styles we pick up weren't present before mjpage ran
109
+ unless parsed_doc.css("svg[style]").empty?()
110
+ Jekyll.logger.error "mathjax_csp:", "Inline style on <svg> element present before running mjpage"
111
+ Jekyll.logger.abort_with "", "due to a misconfiguration or server-side style injection."
112
+ end
113
+
114
+ mjpage_output = run_mjpage(doc.output)
115
+ parsed_doc = Nokogiri::HTML::Document.parse(mjpage_output)
116
+ last_child = parsed_doc.at_css("head").last_element_child()
117
+ if last_child.name == "style"
118
+ # Set strip_css to true in _config.yml if you load the styles mjpage adds to the head
119
+ # from an external *.css file
120
+ if config["strip_css"]
121
+ Jekyll.logger.info "", "Remember to <link> in external stylesheet!"
122
+ last_child.remove
123
+ else
124
+ hashStyleTag(last_child)
125
+ end
126
+ end
127
+
128
+ style_attributes = extractStyleAttributes(parsed_doc)
129
+ compileStyleElement(parsed_doc, style_attributes)
130
+ doc.output = parsed_doc.to_html
131
+ end
132
+
133
+ def mathable?(doc)
134
+ (doc.is_a?(Jekyll::Page) || doc.write?) &&
135
+ doc.output_ext == ".html" || (doc.permalink && doc.permalink.end_with?("/"))
136
+ end
137
+ end
138
+ end
139
+
140
+ # Register the page with the {% mathjax_csp_sources %} Liquid tag for the second pass and
141
+ # temporarily emit a placeholder, later to be replaced by the list of MathJax-related CSP hashes
142
+ class MathJaxSourcesTag < Liquid::Tag
143
+
144
+ class << self
145
+ attr_accessor :final_source_list, :second_pass, :second_pass_docs, :unrendered_docs
146
+ end
147
+
148
+ def initialize(tag_name, text, tokens)
149
+ super
150
+ end
151
+
152
+ def render(context)
153
+ page = context.registers[:page]
154
+ if self.class.second_pass
155
+ return self.class.final_source_list
156
+ else
157
+ self.class.second_pass_docs.add(page["path"])
158
+ return ""
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ Liquid::Template.register_tag("mathjax_csp_sources", Jekyll::MathJaxSourcesTag)
165
+
166
+ # Set up plugin config
167
+ Jekyll::Hooks.register :site, :pre_render do |site|
168
+ # A set of CSP hash sources to be added as style sources; populated automatically
169
+ Jekyll::Mathifier.csp_hashes = Set.new([])
170
+ # This is the first pass (mathify & collect hashes), the second pass (insert hashes into CSP
171
+ # rules) is triggered manually
172
+ Jekyll::MathJaxSourcesTag.second_pass = false
173
+ # A set of Jekyll documents to which the hash sources should be added; populated automatically
174
+ Jekyll::MathJaxSourcesTag.second_pass_docs = Set.new([])
175
+ # The original file content of documents
176
+ Jekyll::MathJaxSourcesTag.unrendered_docs = {}
177
+ end
178
+
179
+ # Keep original (Markdown) content of documents around for the second rendering pass
180
+ Jekyll::Hooks.register [:documents, :pages], :pre_render do |doc|
181
+ Jekyll::MathJaxSourcesTag.unrendered_docs[doc.relative_path] = doc.content
182
+ end
183
+
184
+ # Replace math blocks with SVG renderings using mathjax-node-page and collect inline styles in a
185
+ # single <style> element
186
+ Jekyll::Hooks.register [:documents, :pages], :post_render do |doc|
187
+ if Jekyll::Mathifier.mathable?(doc)
188
+ Jekyll::Mathifier.mathify(doc, doc.site.config["mathjax_csp"])
189
+ end
190
+ end
191
+
192
+ # Run over all documents with {% mathjax_sources %} Liquid tags again and insert the list of CSP
193
+ # hash sources coming from MathJax styles
194
+ Jekyll::Hooks.register :site, :post_render do |site, payload|
195
+ Jekyll::MathJaxSourcesTag.second_pass = true
196
+ Jekyll::MathJaxSourcesTag.final_source_list = Jekyll::Mathifier.csp_hashes.to_a().join(" ")
197
+ if Jekyll::MathJaxSourcesTag.second_pass_docs.empty?()
198
+ Jekyll.logger.info "mathjax_csp:", "Add the following to the style-src part of your CSP:"
199
+ Jekyll.logger.info "", Jekyll::MathJaxSourcesTag.final_source_list
200
+ else
201
+ second_pass_docs_str = Jekyll::MathJaxSourcesTag.second_pass_docs.to_a().join(" ")
202
+ Jekyll.logger.info "Adding CSP sources:", second_pass_docs_str
203
+ rerender = proc { |docs, uses_absolute_path|
204
+ docs.each do |doc|
205
+ relative_path = uses_absolute_path ? doc.relative_path : doc.path
206
+ if Jekyll::MathJaxSourcesTag.second_pass_docs.include?(relative_path)
207
+ # Rerender the page
208
+ doc.content = Jekyll::MathJaxSourcesTag.unrendered_docs[relative_path]
209
+ doc.output = Jekyll::Renderer.new(site, doc, payload).run()
210
+ doc.trigger_hooks(:post_render)
211
+ end
212
+ end
213
+ }
214
+ rerender.call(site.pages, false)
215
+ rerender.call(site.docs_to_write, true)
216
+ end
217
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-mathjax-csp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Fabian Henneke
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-02-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: html-pipeline
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jekyll
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ description: Server-side MathJax rendering for Jekyll with a strict CSP
42
+ email:
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files:
46
+ - README.md
47
+ files:
48
+ - README.md
49
+ - lib/jekyll-mathjax-csp.rb
50
+ homepage: https://github.com/FabianHenneke/jekyll-mathjax-csp
51
+ licenses:
52
+ - MIT
53
+ metadata: {}
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubyforge_project:
70
+ rubygems_version: 2.6.13
71
+ signing_key:
72
+ specification_version: 4
73
+ summary: Server-side MathJax & CSP for Jekyll
74
+ test_files: []