typst-rails 0.1.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "typst_rails/renderer" # Loads the Renderer class
5
+
6
+ module TypstRails
7
+ # Template handler for Typst (.typ) files.
8
+ # This handler allows ERB preprocessing of Typst templates, enabling dynamic content
9
+ # within Typst source files using standard Rails view instance variables and helpers.
10
+ # The ERB-processed Typst source is then passed to the `TypstRails::Renderer`
11
+ # for compilation into a PDF.
12
+ class Handler
13
+ # This method is called by ActionView to compile the template.
14
+ # It returns a string of Ruby code that, when evaluated, will render the template.
15
+ #
16
+ # @param template [ActionView::Template] The template object, providing access to
17
+ # metadata like `source` (the raw template content) and `identifier`.
18
+ # @param source [String, nil] The raw template source code. If nil, `template.source` is used.
19
+ # ActionView typically passes the source content as this second argument.
20
+ # @return [String] A Ruby code string. This string will be evaluated by ActionView
21
+ # in the context of an `ActionView::Base` instance (the view context).
22
+ def self.call(template, source = nil)
23
+ actual_source = source || template.source
24
+
25
+ # Configure ERB to behave like Rails' default ERB handler.
26
+ # This involves setting the output buffer variable to '@output_buffer'
27
+ # and enabling standard trim mode for ERB tags like '<%-' and '-%>'.
28
+ erb_engine = ::ERB.new(
29
+ actual_source,
30
+ trim_mode: "-", # Standard Rails trim mode (e.g., <% foo -%> removes trailing newline)
31
+ eoutvar: "@output_buffer" # Specifies the output variable ERB should append to.
32
+ # This makes it compatible with ActionView's rendering flow.
33
+ )
34
+
35
+ # `erb_engine.src` generates a string of Ruby code. This code, when executed,
36
+ # evaluates the ERB template and appends results to the `eoutvar` (i.e., `@output_buffer`).
37
+ erb_evaluation_code = erb_engine.src
38
+
39
+ # The string returned by this `call` method is the final piece of Ruby code
40
+ # that ActionView will `instance_eval`.
41
+ # This code performs two main steps:
42
+ # 1. Executes the `erb_evaluation_code` to process the Typst template through ERB,
43
+ # capturing the result. This allows dynamic Ruby evaluation within the .typ file.
44
+ # 2. Passes the ERB-processed Typst source to our `TypstRails::Renderer`
45
+ # which then handles the actual Typst compilation.
46
+ <<-RUBY_CODE
47
+ # Preserve the current @output_buffer if one exists (e.g., if rendering a partial).
48
+ # A new buffer is created for ERB processing of the Typst template to isolate its output.
49
+ _original_typst_handler_output_buffer = @output_buffer
50
+ @output_buffer = ActionView::OutputBuffer.new
51
+
52
+ #{erb_evaluation_code}
53
+
54
+ # After the above ERB code executes, @output_buffer contains the
55
+ # ERB-processed Typst template source as a string.
56
+ processed_typst_source = @output_buffer.to_s
57
+
58
+ # Restore the original output buffer that was in place before this handler ran.
59
+ @output_buffer = _original_typst_handler_output_buffer
60
+
61
+ typst_renderer = ::TypstRails::Renderer.new(processed_typst_source)
62
+ typst_renderer.render(self, local_assigns)
63
+ RUBY_CODE
64
+ end
65
+
66
+ # Indicates whether this template handler supports streaming.
67
+ # Typst compilation generates a complete PDF file; it's not inherently a streaming process
68
+ # where chunks of output can be sent progressively. Therefore, streaming is not supported.
69
+ #
70
+ # @return [Boolean] false, as Typst rendering is not streamable.
71
+ def self.supports_streaming?
72
+ false
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "nokogiri"
5
+ require "reverse_markdown"
6
+
7
+ module TypstRails
8
+ # Helper methods for working with Typst templates.
9
+ #
10
+ # This module provides utilities for:
11
+ # - Escaping special Typst characters
12
+ # - Converting HTML to Markdown and Typst
13
+ # - Converting Markdown to Typst syntax
14
+ # - Including external Markdown files
15
+ # - Sanitizing HTML for security
16
+ # - URL encoding
17
+ #
18
+ # These helpers are included in the Renderer and available in Rails ERB templates
19
+ # when using the Typst template handler.
20
+ #
21
+ # @example Using in Rails ERB template
22
+ # <%# app/views/reports/monthly.typ.erb %>
23
+ # <% data = { title: escape_typst(@title) } %>
24
+ # = #data.title
25
+ #
26
+ # @example Using standalone
27
+ # include TypstRails::Helpers
28
+ # safe_text = escape_typst("Price: $100")
29
+ # # => "Price: \\$100"
30
+ module Helpers
31
+ # MARK: - Text Escaping
32
+ # Escapes text for safe use in Typst documents.
33
+ #
34
+ # Escapes special Typst characters that have syntactic meaning:
35
+ # - `#` (code/scripting)
36
+ # - `$` (math mode)
37
+ # - `*` (emphasis)
38
+ # - `_` (emphasis)
39
+ # - `[`, `]` (content blocks)
40
+ # - `\` (escape character)
41
+ # - `<`, `>` (labels and references)
42
+ # - `{`, `}` (code blocks)
43
+ # - `@` (references)
44
+ #
45
+ # @param text [String, nil] The text to escape
46
+ # @return [String] The escaped text, or empty string if text is nil
47
+ # @raise [ArgumentError] if text is not a String or nil
48
+ #
49
+ # @example Escaping special characters
50
+ # escape_typst("Hello #world")
51
+ # # => "Hello \\#world"
52
+ #
53
+ # @example Escaping currency
54
+ # escape_typst("Price: $100")
55
+ # # => "Price: \\$100"
56
+ #
57
+ # @example Handling nil
58
+ # escape_typst(nil)
59
+ # # => ""
60
+ #
61
+ # @see https://typst.app/docs/reference/syntax/ Typst syntax reference
62
+ def escape_typst(text)
63
+ return "" if text.nil?
64
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
65
+
66
+ # Escape special Typst characters
67
+ # See: https://typst.app/docs/reference/syntax/
68
+ text.gsub(/([#\$*_\[\]\\<>{}@])/, '\\\\\1')
69
+ end
70
+
71
+ # MARK: - HTML Conversion
72
+
73
+ # Converts HTML to Markdown for use in Typst documents.
74
+ #
75
+ # Typst has excellent support for Markdown syntax, making this a convenient
76
+ # way to include HTML content in Typst documents. The conversion uses the
77
+ # ReverseMarkdown library.
78
+ #
79
+ # @param html [String, nil] The HTML to convert
80
+ # @param options [Hash] Options to pass to ReverseMarkdown
81
+ # @return [String] The converted Markdown, or empty string if html is nil
82
+ # @raise [ArgumentError] if html is not a String or nil
83
+ # @raise [ArgumentError] if options is not a Hash
84
+ # @raise [TypstRails::Error] if conversion fails
85
+ #
86
+ # @example Converting headings and text
87
+ # html_to_markdown("<h1>Title</h1><p>Content</p>")
88
+ # # => "# Title\n\nContent"
89
+ #
90
+ # @example Converting formatted text
91
+ # html_to_markdown("<strong>Bold</strong> text")
92
+ # # => "**Bold** text"
93
+ #
94
+ # @example With options
95
+ # html_to_markdown("<p>Text</p>", unknown_tags: :bypass)
96
+ #
97
+ # @see https://github.com/xijo/reverse_markdown ReverseMarkdown documentation
98
+ def html_to_markdown(html, options = {})
99
+ return "" if html.nil?
100
+ raise ArgumentError, "html must be a String" unless html.is_a?(String)
101
+ raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
102
+
103
+ begin
104
+ ReverseMarkdown.convert(html, options)
105
+ rescue StandardError => e
106
+ raise Error, "Failed to convert HTML to Markdown: #{e.message}"
107
+ end
108
+ end
109
+
110
+ # Converts HTML to Typst-compatible markup.
111
+ #
112
+ # This is a convenience method that combines html_to_markdown with
113
+ # markdown_to_typst to convert HTML directly to Typst syntax.
114
+ #
115
+ # @param html [String, nil] The HTML to convert
116
+ # @param options [Hash] Options to pass to the HTML converter
117
+ # @return [String] Typst-compatible markup
118
+ # @raise [ArgumentError] if html is not a String or nil
119
+ # @raise [ArgumentError] if options is not a Hash
120
+ # @raise [TypstRails::Error] if conversion fails
121
+ #
122
+ # @example Converting HTML headings to Typst
123
+ # html_to_typst("<h1>Title</h1>")
124
+ # # => "= Title\n\n"
125
+ #
126
+ # @example Converting formatted HTML
127
+ # html_to_typst("<strong>Bold</strong> and <em>italic</em>")
128
+ # # => "*Bold* and _italic_"
129
+ #
130
+ # @see #html_to_markdown
131
+ # @see #markdown_to_typst
132
+ def html_to_typst(html, options = {})
133
+ markdown = html_to_markdown(html, options)
134
+ markdown_to_typst(markdown)
135
+ end
136
+
137
+ # MARK: - Markdown Conversion
138
+
139
+ # Converts Markdown to Typst syntax.
140
+ #
141
+ # Handles common Markdown patterns and converts them to Typst equivalents:
142
+ # - Headings (`#` → `=`)
143
+ # - Bold (`**text**` → `*text*`)
144
+ # - Italic (`*text*` → `_text_`)
145
+ # - Links (`[text](url)` → `#link("url")[text]`)
146
+ # - Images (`![alt](url)` → `#image("url")`)
147
+ #
148
+ # @param markdown [String, nil] The Markdown text to convert
149
+ # @return [String] Typst-compatible markup, or empty string if markdown is nil
150
+ # @raise [ArgumentError] if markdown is not a String or nil
151
+ #
152
+ # @example Converting headings
153
+ # markdown_to_typst("# Title")
154
+ # # => "= Title"
155
+ #
156
+ # @example Converting subheadings
157
+ # markdown_to_typst("## Subtitle")
158
+ # # => "== Subtitle"
159
+ #
160
+ # @example Converting formatted text
161
+ # markdown_to_typst("**bold** and *italic*")
162
+ # # => "*bold* and _italic_"
163
+ #
164
+ # @example Converting links
165
+ # markdown_to_typst("[Typst](https://typst.app)")
166
+ # # => "#link(\"https://typst.app\")[Typst]"
167
+ #
168
+ # @see https://typst.app/docs/ Typst documentation
169
+ def markdown_to_typst(markdown)
170
+ return "" if markdown.nil?
171
+ raise ArgumentError, "markdown must be a String" unless markdown.is_a?(String)
172
+
173
+ result = markdown.dup
174
+
175
+ # Convert Markdown headers to Typst headers
176
+ # # Title -> = Title
177
+ # ## Subtitle -> == Subtitle
178
+ # etc.
179
+ result.gsub!(/^(#{Regexp.quote("#")}{1,6})\s+(.+)$/) do
180
+ "#{"=" * Regexp.last_match(1).length} #{Regexp.last_match(2)}"
181
+ end
182
+
183
+ # Convert Markdown bold to Typst bold, using placeholder bytes (\x01 text \x02)
184
+ # so the italic pass below doesn't re-convert the resulting *text* markers.
185
+ # **text** or __text__ -> *text*
186
+ result.gsub!(/(\*\*|__)(.+?)\1/, "\x01\\2\x02")
187
+
188
+ # Convert Markdown italic to Typst italic (underscore style)
189
+ # *text* or _text_ -> _text_
190
+ result.gsub!(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/, '_\1_')
191
+ result.gsub!(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/, '_\1_')
192
+
193
+ # Replace bold placeholders with Typst bold markers
194
+ result.gsub!("\x01", "*")
195
+ result.gsub!("\x02", "*")
196
+
197
+ # Convert Markdown code to Typst code
198
+ # `code` -> `code` (same in Typst)
199
+
200
+ # Convert Markdown images (must run before links, since ![alt](url)
201
+ # would otherwise be matched by the link pattern below)
202
+ # ![alt](url) -> #image("url")
203
+ result.gsub!(/!\[([^\]]*)\]\(([^)]+)\)/, '#image("\2")')
204
+
205
+ # Convert Markdown links
206
+ # [text](url) -> #link("url")[text]
207
+ result.gsub!(/\[([^\]]+)\]\(([^)]+)\)/, '#link("\2")[\1]')
208
+
209
+ result
210
+ end
211
+
212
+ # Reads and includes Markdown content, converting it to Typst syntax.
213
+ #
214
+ # This method is useful for including external Markdown files in Typst
215
+ # documents. The file is read and converted to Typst syntax.
216
+ #
217
+ # @param markdown_path [String] Path to the Markdown file (relative or absolute)
218
+ # @return [String] Typst-compatible content from the file
219
+ # @raise [ArgumentError] if markdown_path is not a String
220
+ # @raise [ArgumentError] if markdown_path is empty
221
+ # @raise [TypstRails::Error] if file not found
222
+ # @raise [TypstRails::Error] if permission denied
223
+ # @raise [TypstRails::Error] if file cannot be read
224
+ #
225
+ # @example Including a content file
226
+ # include_markdown("./content.md")
227
+ # # Returns the converted content of content.md
228
+ #
229
+ # @example Using in a Typst template
230
+ # # In your .typ.erb template:
231
+ # <%= include_markdown("sections/introduction.md") %>
232
+ #
233
+ # @note File path is relative to the current working directory
234
+ def include_markdown(markdown_path)
235
+ raise ArgumentError, "markdown_path must be a String" unless markdown_path.is_a?(String)
236
+ raise ArgumentError, "markdown_path cannot be empty" if markdown_path.empty?
237
+
238
+ begin
239
+ markdown_content = File.read(markdown_path)
240
+ markdown_to_typst(markdown_content)
241
+ rescue Errno::ENOENT
242
+ raise Error, "Markdown file not found: #{markdown_path}"
243
+ rescue Errno::EACCES
244
+ raise Error, "Permission denied reading Markdown file: #{markdown_path}"
245
+ rescue StandardError => e
246
+ raise Error, "Failed to read Markdown file #{markdown_path}: #{e.message}"
247
+ end
248
+ end
249
+
250
+ # MARK: - HTML Sanitization
251
+
252
+ DEFAULT_ALLOWED_TAGS = %w[
253
+ h1 h2 h3 h4 h5 h6 p br strong em u s del ins
254
+ ul ol li blockquote pre code a img table thead
255
+ tbody tr th td
256
+ ].freeze
257
+
258
+ DEFAULT_ALLOWED_ATTRIBUTES = %w[href src alt title].freeze
259
+
260
+ # Sanitizes HTML before conversion to prevent XSS attacks.
261
+ #
262
+ # This method removes potentially dangerous tags and attributes from HTML
263
+ # before conversion to Typst. It provides basic XSS protection by:
264
+ # - Removing `<script>` and `<style>` tags
265
+ # - Removing event handler attributes (onclick, onload, etc.)
266
+ # - Optionally filtering to allowed tags and attributes
267
+ #
268
+ # @param html [String, nil] The HTML to sanitize
269
+ # @param allowed_tags [Array<String>, nil] List of allowed HTML tags (uses defaults if nil)
270
+ # @param allowed_attributes [Array<String>, nil] List of allowed attributes (uses defaults if nil)
271
+ # @return [String] Sanitized HTML, or empty string if html is nil
272
+ # @raise [ArgumentError] if html is not a String or nil
273
+ #
274
+ # @example Basic sanitization
275
+ # sanitize_html("<script>alert('xss')</script><p>Safe content</p>")
276
+ # # => "<p>Safe content</p>"
277
+ #
278
+ # @example Removing event handlers
279
+ # sanitize_html('<div onclick="evil()">Click</div>')
280
+ # # => "<div>Click</div>"
281
+ #
282
+ # @example Custom allowed tags
283
+ # sanitize_html("<p>Text</p><img src='x'>", allowed_tags: %w[p])
284
+ # # => "<p>Text</p>"
285
+ #
286
+ # @note This is basic sanitization. For production use with untrusted HTML,
287
+ # consider using a dedicated sanitization library like Loofah or Sanitize
288
+ def sanitize_html(html, allowed_tags: nil, allowed_attributes: nil)
289
+ return "" if html.nil?
290
+ raise ArgumentError, "html must be a String" unless html.is_a?(String)
291
+
292
+ allowed_tags ||= DEFAULT_ALLOWED_TAGS
293
+ allowed_attributes ||= DEFAULT_ALLOWED_ATTRIBUTES
294
+
295
+ fragment = Nokogiri::HTML5.fragment(html)
296
+ strip_disallowed_nodes(fragment, allowed_tags, allowed_attributes)
297
+ fragment.to_html
298
+ end
299
+
300
+ private
301
+
302
+ # Recursively removes tags not in +allowed_tags+ (keeping their text content)
303
+ # and strips attributes not in +allowed_attributes+ from the tags that remain.
304
+ def strip_disallowed_nodes(node, allowed_tags, allowed_attributes)
305
+ node.children.each do |child|
306
+ next strip_disallowed_nodes(child, allowed_tags, allowed_attributes) unless child.element?
307
+
308
+ unless allowed_tags.include?(child.name.downcase)
309
+ child.replace(child.children)
310
+ next strip_disallowed_nodes(node, allowed_tags, allowed_attributes)
311
+ end
312
+
313
+ child.attribute_nodes.each do |attr|
314
+ attr.remove unless allowed_attributes.include?(attr.name.downcase)
315
+ end
316
+
317
+ strip_disallowed_nodes(child, allowed_tags, allowed_attributes)
318
+ end
319
+ end
320
+
321
+ public
322
+
323
+ # MARK: - URL Encoding
324
+
325
+ # URL-encodes text for safe use in links.
326
+ #
327
+ # Uses CGI.escape to encode special characters for URL safety.
328
+ # Useful when constructing URLs or query parameters in Typst templates.
329
+ #
330
+ # @param text [String, nil] The text to encode
331
+ # @return [String] URL-encoded text, or empty string if text is nil
332
+ # @raise [ArgumentError] if text is not a String or nil
333
+ #
334
+ # @example Encoding a search query
335
+ # url_encode("hello world")
336
+ # # => "hello+world"
337
+ #
338
+ # @example Encoding special characters
339
+ # url_encode("hello & goodbye")
340
+ # # => "hello+%26+goodbye"
341
+ #
342
+ # @example Building a URL
343
+ # query = url_encode(user_input)
344
+ # url = "https://example.com/search?q=#{query}"
345
+ #
346
+ # @see CGI.escape
347
+ def url_encode(text)
348
+ return "" if text.nil?
349
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
350
+
351
+ CGI.escape(text)
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypstRails
4
+ # Integration for Rage framework
5
+ module RageIntegration
6
+ def self.setup
7
+ return unless defined?(::Rage)
8
+
9
+ require "typst_rails/renderer"
10
+
11
+ # Register Typst handler for Rage
12
+ ::Rage.configure do |config|
13
+ # Rage uses a similar template system to Rails
14
+ # We can register our handler here when Rage's template system is available
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module TypstRails
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "typst_rails.register_template_handler" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ require "typst_rails/handler"
10
+ ActionView::Template.register_template_handler(:typ, TypstRails::Handler)
11
+ end
12
+ end
13
+
14
+ rake_tasks do
15
+ load File.expand_path("../tasks/typst_rails/tasks.rake", __dir__)
16
+ end
17
+ end
18
+ end