jektex 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c8a9820fea56e754c67d39351bba20c1f21cfd91f1ac99a0543aca9f455d4cb
4
- data.tar.gz: a0b680d4a6ee949e57b9b79e29054c81884d171cfb0923025ffa36bb646f1a30
3
+ metadata.gz: 63d33439743b4634d9758d3d466832179f0314a0e45679f414f11e13b04d27e7
4
+ data.tar.gz: 3b76e1ffe31724de5c1ce32df26fad3e4f6ad9fdd9df482b9ee019aa009ce5c2
5
5
  SHA512:
6
- metadata.gz: 51f899741e534e393bdef3424e4760c7a7779a8868ef62d00b9f4b90b89bd4d670d985031a2acd14202ecc0245057fb569248687f677bf255a2eefb77f234321
7
- data.tar.gz: 92fb84bf319058a0f6b3fa77283e54289e20438b1684b82374a31312de5b944ce1751cd356f09552ae9f1575f337af100c836ef675af8749ef59986ea6c24c6a
6
+ metadata.gz: 036e9f9683e6235763e47f9761dbc0b97e40b853cfa3059cc63acc6c7d994d3dbee2afeec415d0fb660cf97edd3911d3fb1acbfff3ab2a1c01a76296b64145dd
7
+ data.tar.gz: 2de454e9296dad57c9701ae361172442f2ed10e86eb5120e8b7ab4f06f75ba5f0b356ad708c9385bf701988bef3a8c6df5ad77a37293fc94361bdf53df5b657c
data/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  # ![Jektex](https://blackblog.cz/assets/img/projects/jektex.svg)
4
4
  A Jekyll plugin for blazing-fast server-side cached LaTeX rendering, with support for macros.
5
5
  Enjoy the comfort of LaTeX and Markdown without cluttering your site with bloated JavaScript.
6
+ This project is [endorsed by KaTeX.org](https://katex.org/docs/libs#jekyll).
6
7
 
7
8
  ## Features
8
9
  - Renders LaTeX formulas during Jekyll rendering
@@ -14,6 +15,8 @@ Enjoy the comfort of LaTeX and Markdown without cluttering your site with bloate
14
15
  - Is very easy to setup
15
16
  - Doesn't interfere with Jekyll workflow and project structure
16
17
  - Marks invalid expressions in document, printing its location during rendering
18
+ - Leaves LaTeX inside code blocks and inline code untouched, so you can write about LaTeX
19
+ - Renders formulas inside raw HTML blocks, which kramdown skips
17
20
  - Is highly configurable with sensible defaults
18
21
  - Makes sure that cache does not contain expression rendered with outdated configuration
19
22
  - Supports two major LaTeX notations
@@ -44,7 +47,25 @@ ea commodo consequat.
44
47
  _Why Jektex does not use conventional single `$` for inline formulas and double `$$` for
45
48
  display mode?
46
49
  This is how [kramdown](https://kramdown.gettalong.org/) (Jekyll's markdown parser) works
47
- so I decided to respect this convention. It makes this plugin more consistent and universal._
50
+ so I decided to respect this convention. It makes this plugin more consistent and universal.
51
+ See [this issue](https://github.com/gettalong/kramdown/issues/762) for more context._
52
+
53
+ **Formulas inside raw HTML blocks**
54
+ Kramdown does not process markdown inside block-level HTML tags, so it leaves formulas
55
+ like `<div>$$\beta$$</div>` unconverted. Jektex finds these leftover `$$` formulas after
56
+ the conversion and renders them itself. A formula standing alone on its line renders
57
+ in display mode, a formula inside text flow renders inline:
58
+ ```html
59
+ <div>The inline formula $$e^{i\theta}$$ sits in text flow.
60
+
61
+ $$ \left[ \frac{-\hbar^2}{2\mu}\nabla^2 + V(\mathbf{r},t)\right] \Psi(\mathbf{r},t) $$
62
+ </div>
63
+ ```
64
+ This applies only to markdown source files and never inside `pre`, `code`, `script`
65
+ and similar tags. You can prevent rendering of a specific formula by escaping it
66
+ as `\$$` or by putting it in a code span. If you prefer kramdown to process the
67
+ content of an HTML block itself, give the tag the
68
+ [`markdown="1"` attribute](https://kramdown.gettalong.org/syntax.html#html-blocks).
48
69
 
49
70
 
50
71
  ### LaTex math mode notation
@@ -73,11 +94,17 @@ ea commodo consequat.
73
94
  There is a build in macro for jektex logo. You can use it as `\jektex`.
74
95
 
75
96
  ### Config
76
- Jektex si highly configurable via your `_config.yml` file.
97
+ Jektex is highly configurable via your `_config.yml` file.
98
+ Unknown options and invalid values are reported during the build and fall back to their defaults.
77
99
 
78
100
  **Disabling cache**
79
- You can disable caching with `disable_disk_cache = true` in `_config.yml`.
101
+ You can disable caching with the `disable_disk_cache` option.
80
102
  Caching is enabled by default.
103
+ This is Jekyll's own option, so unlike the options below it belongs
104
+ at the top level of `_config.yml`, not under the `jektex` key:
105
+ ```yaml
106
+ disable_disk_cache: true
107
+ ```
81
108
  You can find more information on [Jekyll's official website](https://jekyllrb.com/docs/configuration/options/).
82
109
 
83
110
  **Setting cache location**
@@ -124,6 +151,15 @@ jektex:
124
151
  And yes, you have to escape the backlash (`\`) with another backlash.
125
152
  This is due to the [yaml specification](https://yaml.org/).
126
153
 
154
+ You can define macros with parameters:
155
+ ```yaml
156
+ jektex:
157
+ macros:
158
+ - ["\\vec", "\\mathbf{#1}"]
159
+ - ["\\addBar", "\\bar{#1}"]
160
+ ```
161
+ This simulates behaviour of LaTeX `\newcommand`.
162
+
127
163
  **Silencing Jektex output**
128
164
  Jektex periodically informs the user about rendered/cached equations.
129
165
  If this is not desired, you can set the `silent` option (`false` by default).
@@ -132,6 +168,33 @@ jektex:
132
168
  silent: true
133
169
  ```
134
170
 
171
+ **KaTeX options**
172
+ You can pass any [KaTeX rendering option](https://katex.org/docs/options) to the renderer
173
+ through the `katex_options` key. Write the keys exactly as the KaTeX documentation spells them:
174
+ ```yaml
175
+ jektex:
176
+ katex_options:
177
+ trust: false
178
+ output: htmlAndMathml
179
+ strict: warn
180
+ ```
181
+
182
+ The most useful options are:
183
+
184
+ - `trust` toggles features KaTeX deems potentially unsafe (`false` by default), namely
185
+ `\url`, `\href`, `\includegraphics`, `\htmlClass`, `\htmlId`, `\htmlStyle` and `\htmlData`.
186
+ - `output: html` halves the size of every rendered formula by omitting the invisible MathML
187
+ copy. Be aware that the MathML is what screen readers use.
188
+ - `maxExpand` limits macro expansion and protects your build from runaway recursive macros.
189
+
190
+ Jektex controls `displayMode`, `macros`, `throwOnError` and `globalGroup` itself,
191
+ so these keys are ignored (define your macros with the `macros` option above).
192
+ Values of the documented KaTeX options are checked: an invalid value is reported
193
+ and KaTeX's own default is used instead. Option names jektex does not know
194
+ (for example ones added by newer KaTeX versions) are passed through unchecked.
195
+ Changing any KaTeX option invalidates the cache and the next build re-renders everything once.
196
+
197
+
135
198
  **Complete examples**
136
199
  Recommended config:
137
200
  ```yaml
@@ -139,6 +202,8 @@ jektex:
139
202
  cache_dir: ".jektex-cache"
140
203
  ignore: ["*.xml"]
141
204
  silent: false
205
+ katex_options:
206
+ trust: false
142
207
  macros:
143
208
  - ["\\Q", "\\mathbb{Q}"]
144
209
  - ["\\C", "\\mathbb{C}"]
@@ -150,6 +215,7 @@ jektex:
150
215
  cache_dir: ".jekyll-cache"
151
216
  ignore: []
152
217
  silent: false
218
+ katex_options: {}
153
219
  macros: []
154
220
  ```
155
221
 
@@ -178,9 +244,9 @@ plugins:
178
244
 
179
245
  and don't forget to add `katex.min.css` to you HTML head:
180
246
  ```html
181
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" integrity="sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ" crossorigin="anonymous">
247
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.css" integrity="sha384-vlBdW0r3AcZO/HboRPznQNowvexd3fY8qHOWkBi5q7KGgqJ+F48+DceybYmrVbmB" crossorigin="anonymous">
182
248
  ```
183
- It is much better practice to download the [**css** file](https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css) and load it as an asset from your server directly.
249
+ It is much better practice to download the [**css** file](https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.css) and load it as an asset from your server directly.
184
250
  You can find more information on [KaTeX's website](https://katex.org/docs/browser.html).
185
251
 
186
252
  ## Contributions and bug reports
@@ -0,0 +1,107 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+
4
+ module Jektex
5
+ # Disk cache of rendered expressions. The cache file additionally stores
6
+ # the macro table it was rendered with, so expressions using an edited
7
+ # macro can be invalidated individually, and a fingerprint of everything
8
+ # else that affects KaTeX output, so a KaTeX or configuration change
9
+ # discards the whole cache instead of serving stale HTML.
10
+ class Cache
11
+ FORMAT = 2
12
+
13
+ attr_reader :updated_global_macros
14
+
15
+ # names of macros whose definition differs between the two tables
16
+ def self.updated_macros_between(current_macros, cached_macros)
17
+ current = current_macros || Hash.new
18
+ cached = cached_macros || Hash.new
19
+ (current.keys | cached.keys).reject { |name| current[name] == cached[name] }
20
+ end
21
+
22
+ def initialize(config, reporter: nil)
23
+ @config = config
24
+ @reporter = reporter
25
+ @entries = Hash.new
26
+ @updated_global_macros = Array.new
27
+ end
28
+
29
+ def load
30
+ payload = read_payload
31
+ if payload
32
+ @entries = payload["entries"]
33
+ @updated_global_macros =
34
+ self.class.updated_macros_between(@config.global_macros, payload["macros"])
35
+ end
36
+ return self
37
+ end
38
+
39
+ def fetch(expression, display_mode)
40
+ return nil if @updated_global_macros.any? { |macro| expression.include?(macro) }
41
+ return @entries[key_for(expression, display_mode)]
42
+ end
43
+
44
+ def store(expression, display_mode, html)
45
+ @entries[key_for(expression, display_mode)] = html
46
+ end
47
+
48
+ def size
49
+ return @entries.size
50
+ end
51
+
52
+ def save
53
+ return if @config.disable_disk_cache
54
+ path = @config.path_to_cache_file
55
+ FileUtils.mkdir_p(File.dirname(path))
56
+ payload = { "format" => FORMAT,
57
+ "fingerprint" => fingerprint,
58
+ "macros" => @config.global_macros,
59
+ "entries" => @entries }
60
+ # write to a temporary file first so an interrupted write
61
+ # can never leave a truncated cache behind
62
+ temporary_path = path + ".tmp"
63
+ File.open(temporary_path, "wb") { |file| Marshal.dump(payload, file) }
64
+ File.rename(temporary_path, path)
65
+ end
66
+
67
+ private
68
+
69
+ def read_payload
70
+ path = @config.path_to_cache_file
71
+ return nil unless File.exist?(path)
72
+ payload = File.open(path, "rb") { |file| Marshal.load(file) }
73
+ unless valid?(payload)
74
+ @reporter&.info("cache file is invalid and will be rebuilt")
75
+ return nil
76
+ end
77
+ unless payload["fingerprint"] == fingerprint
78
+ @reporter&.info("cache reset (configuration or KaTeX changed)")
79
+ return nil
80
+ end
81
+ return payload
82
+ rescue StandardError
83
+ @reporter&.info("cache file is invalid and will be rebuilt")
84
+ return nil
85
+ end
86
+
87
+ def valid?(payload)
88
+ payload.is_a?(Hash) &&
89
+ payload["format"] == FORMAT &&
90
+ payload["entries"].is_a?(Hash) &&
91
+ payload["macros"].is_a?(Hash)
92
+ end
93
+
94
+ def key_for(expression, display_mode)
95
+ Digest::SHA2.hexdigest(expression) + (display_mode ? ":display" : ":inline")
96
+ end
97
+
98
+ # everything that changes KaTeX output (except macros, which are diffed
99
+ # individually) must be part of this fingerprint; katex_options covers
100
+ # all pass-through options, jektex-side additions must be appended
101
+ def fingerprint
102
+ @fingerprint ||= [FORMAT,
103
+ Digest::SHA2.file(@config.path_to_katex_js).hexdigest[0, 16],
104
+ @config.katex_options.sort.inspect].join("|")
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,167 @@
1
+ module Jektex
2
+ class Config
3
+ # KaTeX options jektex needs to control itself: displayMode is decided
4
+ # per expression by its delimiters, macros have their own config key,
5
+ # throwOnError drives the error reporting pipeline, and globalGroup
6
+ # would make rendering order-dependent, which breaks caching
7
+ RESERVED_KATEX_OPTIONS = ["displayMode", "macros", "throwOnError", "globalGroup"].freeze
8
+
9
+ KNOWN_OPTIONS = ["cache_dir", "ignore", "silent", "macros", "katex_options"].freeze
10
+
11
+ # value checks for the KaTeX options documented at katex.org/docs/options;
12
+ # keys not listed here are passed to KaTeX unchecked, so options added
13
+ # by future KaTeX versions keep working without a jektex update
14
+ KATEX_OPTION_VALIDATIONS = {
15
+ "output" => ['"html", "mathml" or "htmlAndMathml"', '"htmlAndMathml"',
16
+ ->(value) { ["html", "mathml", "htmlAndMathml"].include?(value) }],
17
+ "leqno" => ["true or false", "false", ->(value) { [true, false].include?(value) }],
18
+ "fleqn" => ["true or false", "false", ->(value) { [true, false].include?(value) }],
19
+ "errorColor" => ["a color string", '"#cc0000"', ->(value) { value.is_a?(String) }],
20
+ "minRuleThickness" => ["a number", "the KaTeX default", ->(value) { value.is_a?(Numeric) }],
21
+ "colorIsTextColor" => ["true or false", "false", ->(value) { [true, false].include?(value) }],
22
+ "maxSize" => ["a number", "unlimited", ->(value) { value.is_a?(Numeric) }],
23
+ "maxExpand" => ["a number", "1000", ->(value) { value.is_a?(Numeric) }],
24
+ "strict" => ['true, false, "ignore", "warn" or "error"', '"warn"',
25
+ ->(value) { [true, false, "ignore", "warn", "error"].include?(value) }],
26
+ "trust" => ["true or false", "false", ->(value) { [true, false].include?(value) }]
27
+ }.freeze
28
+
29
+ attr_reader :path_to_katex_js
30
+ attr_reader :disable_disk_cache
31
+ attr_reader :ignore
32
+ attr_reader :console_indent
33
+ attr_reader :silent
34
+ attr_reader :front_matter_tag
35
+ attr_reader :katex_options
36
+ attr_reader :global_macros
37
+ attr_reader :markdown_extensions
38
+ # problems found while reading the configuration; the plugin prints
39
+ # them at startup, so this class can stay free of console output
40
+ attr_reader :warnings
41
+
42
+ def initialize(config)
43
+ @path_to_katex_js = File.join(__dir__, "/katex.min.js")
44
+
45
+ @cache_dir = ".jekyll-cache"
46
+ @cache_file = "jektex-cache.marshal"
47
+ @disable_disk_cache = false
48
+
49
+ @ignore = Array.new
50
+ @console_indent = " " * 13
51
+ @silent = false
52
+
53
+ @front_matter_tag = "jektex"
54
+ @katex_options = Hash.new
55
+
56
+ @markdown_ext = "markdown,mkdown,mkdn,mkd,md"
57
+
58
+ @global_macros = Hash.new
59
+ @warnings = Array.new
60
+
61
+ if config.is_a?(Hash)
62
+ update_from_jekyll_config(config)
63
+ end
64
+ add_jektex_logo_macro
65
+ @ignore.append("#{@cache_dir}/*")
66
+ @markdown_extensions = @markdown_ext.to_s.split(",").map { |ext| ".#{ext.strip.downcase}" }
67
+ end
68
+
69
+ def path_to_cache_file
70
+ return File.join(@cache_dir, @cache_file)
71
+ end
72
+
73
+ def number_of_global_macros
74
+ return @global_macros.length - 1
75
+ end
76
+
77
+ private
78
+ def update_from_jekyll_config(config)
79
+ if config.key?("jektex")
80
+ jektex_config = config["jektex"]
81
+ if jektex_config.is_a?(Hash)
82
+ warn_about_unknown_options(jektex_config)
83
+ @cache_dir = checked_option(jektex_config, "cache_dir", @cache_dir, "a string") do |value|
84
+ value.is_a?(String)
85
+ end
86
+ @ignore = checked_option(jektex_config, "ignore", @ignore, "a list of file patterns") do |value|
87
+ value.is_a?(Array) && value.all? { |pattern| pattern.is_a?(String) }
88
+ end
89
+ @silent = checked_option(jektex_config, "silent", @silent, "true or false") do |value|
90
+ value == true || value == false
91
+ end
92
+ read_katex_options(jektex_config)
93
+ read_macros(jektex_config)
94
+ else
95
+ @warnings.append('the "jektex" configuration must be a mapping of options, ignoring it')
96
+ end
97
+ end
98
+
99
+ @disable_disk_cache = config["disable_disk_cache"] if config.has_key?("disable_disk_cache")
100
+ @markdown_ext = config["markdown_ext"] if config.has_key?("markdown_ext")
101
+ end
102
+
103
+ def warn_about_unknown_options(jektex_config)
104
+ jektex_config.each_key do |key|
105
+ next if KNOWN_OPTIONS.include?(key)
106
+ @warnings.append("unknown option \"#{key}\" " \
107
+ "(known options: #{KNOWN_OPTIONS.join(", ")})")
108
+ end
109
+ end
110
+
111
+ # returns the configured value when the validation block accepts it,
112
+ # otherwise warns, names the default and returns it
113
+ def checked_option(jektex_config, name, default, expectation)
114
+ return default unless jektex_config.key?(name)
115
+ value = jektex_config[name]
116
+ return value if yield(value)
117
+ @warnings.append("option \"#{name}\" must be #{expectation}, " \
118
+ "falling back to default: #{default.inspect}")
119
+ return default
120
+ end
121
+
122
+ def read_katex_options(jektex_config)
123
+ return unless jektex_config.key?("katex_options")
124
+ options = jektex_config["katex_options"]
125
+ unless options.is_a?(Hash)
126
+ @warnings.append('option "katex_options" must be a mapping of KaTeX options, ' \
127
+ "falling back to default: {}")
128
+ return
129
+ end
130
+ options.each_key do |key|
131
+ if RESERVED_KATEX_OPTIONS.include?(key.to_s)
132
+ @warnings.append("KaTeX option \"#{key}\" is controlled by jektex and was ignored")
133
+ end
134
+ end
135
+ @katex_options = options.reject { |key, _value| RESERVED_KATEX_OPTIONS.include?(key.to_s) }
136
+ KATEX_OPTION_VALIDATIONS.each do |name, (expectation, default_description, valid)|
137
+ next unless @katex_options.key?(name)
138
+ next if valid.call(@katex_options[name])
139
+ @warnings.append("KaTeX option \"#{name}\" must be #{expectation}, " \
140
+ "falling back to KaTeX default: #{default_description}")
141
+ @katex_options.delete(name)
142
+ end
143
+ end
144
+
145
+ def read_macros(jektex_config)
146
+ return unless jektex_config.has_key?("macros")
147
+ macro_list = jektex_config["macros"]
148
+ valid = macro_list.is_a?(Array) && macro_list.all? do |pair|
149
+ pair.is_a?(Array) && pair.size == 2 && pair.all? { |part| part.is_a?(String) }
150
+ end
151
+ unless valid
152
+ @warnings.append('option "macros" must be a list of [name, definition] pairs of strings, ' \
153
+ "falling back to default: no macros")
154
+ return
155
+ end
156
+ for macro_definition in macro_list
157
+ @global_macros[macro_definition[0]] = macro_definition[1]
158
+ end
159
+ end
160
+
161
+ def add_jektex_logo_macro
162
+ @global_macros['\jektex'] =
163
+ '\text{\raisebox{-0.55ex}{J}\kern{-0.3ex}E\kern{-0.25ex}\raisebox{-0.5ex}{K}\kern{-0.7ex}}\TeX'
164
+ end
165
+
166
+ end
167
+ end
@@ -0,0 +1,51 @@
1
+ # Jekyll integration. Everything else in this gem is plain Ruby;
2
+ # this file is the only one that touches Jekyll::Hooks.
3
+
4
+ module Jektex
5
+ class << self
6
+ attr_accessor :config, :cache, :renderer, :page_processor, :reporter
7
+ end
8
+ end
9
+
10
+ # Fires once per process (for jekyll build and jekyll serve alike),
11
+ # at the end of Site#initialize.
12
+ Jekyll::Hooks.register :site, :after_init do |site|
13
+ Jektex.config = Jektex::Config.new(site.config || Hash.new)
14
+ Jektex.reporter = Jektex::Reporter.new(Jektex.config)
15
+ Jektex.config.warnings.each { |warning| Jektex.reporter.warn(warning) }
16
+ Jektex.cache = Jektex::Cache.new(Jektex.config, reporter: Jektex.reporter).load
17
+ Jektex.renderer = Jektex::Renderer.new(Jektex.config)
18
+ Jektex.page_processor = Jektex::PageProcessor.new(config: Jektex.config,
19
+ cache: Jektex.cache,
20
+ renderer: Jektex.renderer,
21
+ reporter: Jektex.reporter)
22
+ Jektex.reporter.macro_summary(Jektex.config.number_of_global_macros,
23
+ Jektex.cache.updated_global_macros.size)
24
+ end
25
+
26
+ # LaTeX notation (\( \) and \[ \]) in raw content, before Liquid/kramdown:
27
+ # expressions are protected behind inert tokens so markdown cannot touch them.
28
+ Jekyll::Hooks.register [:pages, :documents], :pre_render do |page|
29
+ page.content = Jektex.page_processor.process_content(page)
30
+ end
31
+
32
+ # After conversion the HTML structure is known: tokens inside code markup
33
+ # are restored to their source text, everything else is rendered — including
34
+ # the \(..\)/\[..\] delimiters kramdown produces from its $$..$$ notation
35
+ # and the $$..$$ kramdown leaves untouched inside raw HTML blocks.
36
+ Jekyll::Hooks.register [:pages, :documents], :post_render do |page|
37
+ page.output = Jektex.page_processor.process_output(page)
38
+ end
39
+
40
+ # Fires once per rebuild in watch mode — and also once during
41
+ # Site#initialize BEFORE after_init has run, hence the safe navigation.
42
+ Jekyll::Hooks.register :site, :after_reset do |_site|
43
+ Jektex.page_processor&.reset_counters
44
+ end
45
+
46
+ Jekyll::Hooks.register :site, :post_write do |_site|
47
+ # print stats once more so the error log cannot overwrite them
48
+ Jektex.reporter.finish(Jektex.page_processor.rendered_count,
49
+ Jektex.page_processor.cache_hit_count)
50
+ Jektex.cache.save
51
+ end