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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +68 -0
- data/MIT-LICENSE +20 -0
- data/README.md +264 -0
- data/lib/tasks/typst_rails/tasks.rake +6 -0
- data/lib/typst_rails/backends/base.rb +30 -0
- data/lib/typst_rails/backends/cli.rb +90 -0
- data/lib/typst_rails/backends/gem.rb +43 -0
- data/lib/typst_rails/backends/registry.rb +82 -0
- data/lib/typst_rails/backends.rb +15 -0
- data/lib/typst_rails/framework_detection.rb +26 -0
- data/lib/typst_rails/handler.rb +75 -0
- data/lib/typst_rails/helpers.rb +354 -0
- data/lib/typst_rails/rage_integration.rb +18 -0
- data/lib/typst_rails/railtie.rb +18 -0
- data/lib/typst_rails/renderer.rb +386 -0
- data/lib/typst_rails/sinatra_integration.rb +33 -0
- data/lib/typst_rails/version.rb +5 -0
- data/lib/typst_rails.rb +146 -0
- metadata +225 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "json"
|
|
5
|
+
require "typst_rails/helpers"
|
|
6
|
+
require "typst_rails/backends"
|
|
7
|
+
|
|
8
|
+
# Only require ActiveSupport extensions if available
|
|
9
|
+
begin
|
|
10
|
+
require "active_support/core_ext/hash/keys" # for symbolize_keys
|
|
11
|
+
require "active_support/json" # for ActiveSupport::JSON, used by to_json below
|
|
12
|
+
require "active_support/core_ext/object/json" # for as_json
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# ActiveSupport not available, define minimal compatibility shims
|
|
15
|
+
class Hash
|
|
16
|
+
unless method_defined?(:symbolize_keys)
|
|
17
|
+
def symbolize_keys
|
|
18
|
+
transform_keys(&:to_sym)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module TypstRails
|
|
25
|
+
# The Renderer class is responsible for taking Typst source code,
|
|
26
|
+
# compiling it using the Typst CLI, and returning the resulting PDF data.
|
|
27
|
+
# It can optionally use data passed from a view context (instance variables and locals)
|
|
28
|
+
# to make them available to the Typst template, typically via a temporary JSON file.
|
|
29
|
+
# This class works independently of any web framework.
|
|
30
|
+
#
|
|
31
|
+
# @example Basic standalone usage
|
|
32
|
+
# typst_source = "#let data = json(\"typst_data.json\")\n= #data.title"
|
|
33
|
+
# renderer = TypstRails::Renderer.new(typst_source)
|
|
34
|
+
# pdf_data = renderer.render(nil, { title: "My Document" })
|
|
35
|
+
# File.binwrite("output.pdf", pdf_data)
|
|
36
|
+
#
|
|
37
|
+
# @example With view context (Rails)
|
|
38
|
+
# # In a Rails controller:
|
|
39
|
+
# # @title = "Monthly Report"
|
|
40
|
+
# # render template: "reports/monthly" # Uses .typ template file
|
|
41
|
+
#
|
|
42
|
+
# Key features:
|
|
43
|
+
# - Automatic data serialization to JSON for Typst templates
|
|
44
|
+
# - Secure temporary file handling with proper cleanup
|
|
45
|
+
# - Integration with view contexts for framework support
|
|
46
|
+
# - Comprehensive error handling and logging
|
|
47
|
+
# - Input validation and size limits for security
|
|
48
|
+
class Renderer
|
|
49
|
+
include Helpers
|
|
50
|
+
|
|
51
|
+
# MARK: - Constants
|
|
52
|
+
|
|
53
|
+
# Maximum source size (10MB) to prevent memory issues
|
|
54
|
+
MAX_SOURCE_SIZE = 10 * 1024 * 1024
|
|
55
|
+
|
|
56
|
+
# Holds the filesystem paths used during a single compilation run.
|
|
57
|
+
TempPaths = Struct.new(:typ_file, :dir, :data_file_path)
|
|
58
|
+
|
|
59
|
+
# MARK: - Attributes
|
|
60
|
+
|
|
61
|
+
# @return [String] The Typst template source code
|
|
62
|
+
attr_reader :source
|
|
63
|
+
|
|
64
|
+
# @return [Object, nil] The view context providing access to helpers and instance variables
|
|
65
|
+
attr_reader :view_context
|
|
66
|
+
|
|
67
|
+
# MARK: - Initialization
|
|
68
|
+
|
|
69
|
+
# Initializes the renderer with the Typst template source.
|
|
70
|
+
#
|
|
71
|
+
# @param source [String] The Typst template source code
|
|
72
|
+
# @return [Renderer] A new renderer instance
|
|
73
|
+
# @raise [ArgumentError] if source is nil
|
|
74
|
+
# @raise [ArgumentError] if source is too large (exceeds MAX_SOURCE_SIZE)
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# renderer = Renderer.new("= Hello, Typst!")
|
|
78
|
+
def initialize(source)
|
|
79
|
+
raise ArgumentError, "Source cannot be nil" if source.nil?
|
|
80
|
+
|
|
81
|
+
@source = source.to_s
|
|
82
|
+
|
|
83
|
+
return unless @source.bytesize > MAX_SOURCE_SIZE
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "Source is too large (#{@source.bytesize} bytes, max #{MAX_SOURCE_SIZE} bytes)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# MARK: - Public Methods
|
|
89
|
+
|
|
90
|
+
# Renders the Typst template to PDF.
|
|
91
|
+
#
|
|
92
|
+
# This method compiles the Typst source code to PDF, optionally injecting data
|
|
93
|
+
# from a view context and local variables. The data is serialized to JSON and
|
|
94
|
+
# made available to the Typst template via a temporary JSON file.
|
|
95
|
+
#
|
|
96
|
+
# @param view_context [Object, nil] Optional view context providing access to helpers and instance variables
|
|
97
|
+
# @param local_assigns [Hash] A hash of local variables available in the template
|
|
98
|
+
# @return [String] Binary string containing the PDF data
|
|
99
|
+
# @raise [ArgumentError] if local_assigns is not a Hash
|
|
100
|
+
# @raise [ArgumentError] if source is empty
|
|
101
|
+
# @raise [TypstRails::Error] if Typst compilation fails
|
|
102
|
+
#
|
|
103
|
+
# @example Render with plain data
|
|
104
|
+
# renderer = Renderer.new("= #data.title")
|
|
105
|
+
# pdf = renderer.render(nil, { title: "Report" })
|
|
106
|
+
#
|
|
107
|
+
# @example Render with view context (Rails)
|
|
108
|
+
# renderer = Renderer.new(typst_source)
|
|
109
|
+
# pdf = renderer.render(view_context, { extra_data: "value" })
|
|
110
|
+
def render(view_context = nil, local_assigns = {})
|
|
111
|
+
raise ArgumentError, "local_assigns must be a Hash" unless local_assigns.is_a?(Hash)
|
|
112
|
+
raise ArgumentError, "Source is empty" if @source.empty?
|
|
113
|
+
|
|
114
|
+
@view_context = view_context
|
|
115
|
+
|
|
116
|
+
data_for_typst = if view_context
|
|
117
|
+
collect_data_for_typst(view_context, local_assigns)
|
|
118
|
+
else
|
|
119
|
+
local_assigns
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
compile_typst_source(@source, data_for_typst)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# MARK: - Private Methods
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Compiles the Typst source code to PDF, optionally injecting data.
|
|
130
|
+
#
|
|
131
|
+
# This method handles the complete compilation workflow:
|
|
132
|
+
# 1. Creates temporary files for Typst source and data
|
|
133
|
+
# 2. Writes data to JSON file for Typst template access
|
|
134
|
+
# 3. Executes the Typst compiler
|
|
135
|
+
# 4. Reads and returns the generated PDF
|
|
136
|
+
# 5. Cleans up all temporary files
|
|
137
|
+
#
|
|
138
|
+
# @param typst_source [String] The Typst template source
|
|
139
|
+
# @param data [Hash] Data to be made available to the Typst template via JSON
|
|
140
|
+
# @return [String] Binary PDF data
|
|
141
|
+
# @raise [ArgumentError] if typst_source is empty or data is not a Hash
|
|
142
|
+
# @raise [TypstRails::Error] if compilation fails or produces no output
|
|
143
|
+
#
|
|
144
|
+
# @note This method ensures all temporary files are cleaned up even if errors occur
|
|
145
|
+
def compile_typst_source(typst_source, data = {})
|
|
146
|
+
raise ArgumentError, "typst_source cannot be empty" if typst_source.nil? || typst_source.empty?
|
|
147
|
+
raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
paths = TempPaths.new
|
|
150
|
+
begin
|
|
151
|
+
write_typst_source_file(paths, typst_source)
|
|
152
|
+
write_typst_data_file(paths, data)
|
|
153
|
+
run_typst_compiler(paths)
|
|
154
|
+
rescue Error
|
|
155
|
+
raise
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
error_message = "Unexpected error during Typst compilation: #{e.class} - #{e.message}"
|
|
158
|
+
log_error(error_message)
|
|
159
|
+
raise Error, error_message
|
|
160
|
+
ensure
|
|
161
|
+
cleanup_temp_paths(paths)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write_typst_source_file(paths, typst_source)
|
|
166
|
+
# Create temp file in a directory that Typst can access.
|
|
167
|
+
# Ensure it's in binary mode for writing source if it contains non-ASCII.
|
|
168
|
+
paths.typ_file = Tempfile.new(["durable_typst_template_", ".typ"], binmode: true)
|
|
169
|
+
paths.dir = File.dirname(paths.typ_file.path)
|
|
170
|
+
|
|
171
|
+
paths.typ_file.write(typst_source)
|
|
172
|
+
paths.typ_file.flush # Ensure content is written before Typst reads it.
|
|
173
|
+
paths.typ_file.close # Close it so Typst can open it, especially important on Windows.
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def write_typst_data_file(paths, data)
|
|
177
|
+
return if data.nil? || data.empty?
|
|
178
|
+
|
|
179
|
+
# Create a temporary JSON data file in the same directory as the Typst source.
|
|
180
|
+
# The Typst template would then use `json("typst_data.json")` to load this data.
|
|
181
|
+
paths.data_file_path = File.join(paths.dir, "typst_data.json")
|
|
182
|
+
File.write(paths.data_file_path, data.to_json)
|
|
183
|
+
rescue JSON::GeneratorError => e
|
|
184
|
+
raise Error, "Failed to serialize data to JSON: #{e.message}"
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
raise Error, "Failed to write data file: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def run_typst_compiler(paths)
|
|
190
|
+
backend = Backends::Registry.resolve(configured_backend)
|
|
191
|
+
backend.compile(paths.typ_file.path, paths.dir)
|
|
192
|
+
rescue Error => e
|
|
193
|
+
log_error(e.message)
|
|
194
|
+
raise
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @return [Symbol, Backends::Base, nil] the backend preference from
|
|
198
|
+
# TypstRails.configuration, or nil if the top-level TypstRails module
|
|
199
|
+
# (and its Configuration) hasn't been loaded (e.g. when only
|
|
200
|
+
# `typst_rails/renderer` is required directly).
|
|
201
|
+
def configured_backend
|
|
202
|
+
return nil unless TypstRails.respond_to?(:configuration)
|
|
203
|
+
|
|
204
|
+
TypstRails.configuration&.backend
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def cleanup_temp_paths(paths)
|
|
208
|
+
# Clean up all temporary files, suppressing any errors during cleanup
|
|
209
|
+
rescue_cleanup_errors("temp Typst file") do
|
|
210
|
+
paths.typ_file.unlink if paths.typ_file&.path && File.exist?(paths.typ_file.path)
|
|
211
|
+
end
|
|
212
|
+
rescue_cleanup_errors("temp data file") { safe_unlink(paths.data_file_path) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def safe_unlink(path)
|
|
216
|
+
File.unlink(path) if path && File.exist?(path)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def rescue_cleanup_errors(description)
|
|
220
|
+
yield
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
warn "Failed to clean up #{description}: #{e.message}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Collects data from view_context (instance variables) and local_assigns.
|
|
226
|
+
#
|
|
227
|
+
# This method extracts instance variables from the view context (if available via
|
|
228
|
+
# `assigns`) and merges them with local variables. The result is a hash suitable
|
|
229
|
+
# for JSON serialization and injection into Typst templates.
|
|
230
|
+
#
|
|
231
|
+
# @param view_context [Object] The view context (typically from Rails/Sinatra)
|
|
232
|
+
# @param local_assigns [Hash] Local variables passed to the template
|
|
233
|
+
# @return [Hash] A hash of data suitable for JSON serialization, with symbolized keys
|
|
234
|
+
# @raise [ArgumentError] if local_assigns is not a Hash
|
|
235
|
+
# @raise [TypstRails::Error] if data collection fails
|
|
236
|
+
#
|
|
237
|
+
# @note Instance variables from view_context take precedence over local_assigns
|
|
238
|
+
# @note All values are transformed for JSON serialization (dates to ISO8601, etc.)
|
|
239
|
+
def collect_data_for_typst(view_context, local_assigns)
|
|
240
|
+
raise ArgumentError, "local_assigns must be a Hash" unless local_assigns.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
data = {}
|
|
243
|
+
|
|
244
|
+
# Instance variables from the controller are available in view_context.assigns.
|
|
245
|
+
if view_context.respond_to?(:assigns)
|
|
246
|
+
assigns = view_context.assigns
|
|
247
|
+
if assigns.is_a?(Hash)
|
|
248
|
+
assigns.each do |key, value|
|
|
249
|
+
data[key.to_sym] = value # Convert keys to symbols for consistency.
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Local assigns (e.g., from `render locals: {...}`) merge in, taking precedence.
|
|
255
|
+
data.merge!(local_assigns.symbolize_keys)
|
|
256
|
+
|
|
257
|
+
# Transform values to ensure they are JSON-serializable.
|
|
258
|
+
data.transform_values do |value|
|
|
259
|
+
transform_value_for_json_serialization(value)
|
|
260
|
+
end
|
|
261
|
+
rescue ArgumentError
|
|
262
|
+
raise
|
|
263
|
+
rescue StandardError => e
|
|
264
|
+
raise Error, "Failed to collect data for Typst template: #{e.message}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Transforms various Ruby objects into JSON-friendly representations.
|
|
268
|
+
#
|
|
269
|
+
# This method recursively transforms Ruby objects to ensure they can be
|
|
270
|
+
# serialized to JSON for Typst template consumption. It handles:
|
|
271
|
+
# - ActiveRecord objects (via as_json)
|
|
272
|
+
# - Date/Time objects (to ISO8601 strings)
|
|
273
|
+
# - Arrays (recursively transform elements)
|
|
274
|
+
# - Hashes (recursively transform values)
|
|
275
|
+
#
|
|
276
|
+
# @param value [Object] The value to transform
|
|
277
|
+
# @return [Object] A JSON-friendly representation of the value
|
|
278
|
+
#
|
|
279
|
+
# @example Transforming a date
|
|
280
|
+
# transform_value_for_json_serialization(Date.today)
|
|
281
|
+
# # => "2024-01-15"
|
|
282
|
+
#
|
|
283
|
+
# @example Transforming an array
|
|
284
|
+
# transform_value_for_json_serialization([Date.today, "text"])
|
|
285
|
+
# # => ["2024-01-15", "text"]
|
|
286
|
+
def transform_value_for_json_serialization(value)
|
|
287
|
+
if active_record_value?(value)
|
|
288
|
+
value.as_json # Use Rails' built-in JSON serialization for ActiveRecord objects.
|
|
289
|
+
elsif value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
|
|
290
|
+
value.iso8601 # Convert date/time objects to ISO8601 strings.
|
|
291
|
+
elsif value.is_a?(Array)
|
|
292
|
+
value.map { |v| transform_value_for_json_serialization(v) } # Recursively transform array elements.
|
|
293
|
+
elsif value.is_a?(Hash)
|
|
294
|
+
# Recursively transform hash values.
|
|
295
|
+
value.transform_values do |v_hash|
|
|
296
|
+
transform_value_for_json_serialization(v_hash)
|
|
297
|
+
end
|
|
298
|
+
else
|
|
299
|
+
value # Return other types as-is, assuming they are JSON-serializable.
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @return [Boolean] whether +value+ is an ActiveRecord model or relation
|
|
304
|
+
def active_record_value?(value)
|
|
305
|
+
return false unless defined?(::ActiveRecord::Base)
|
|
306
|
+
|
|
307
|
+
value.is_a?(::ActiveRecord::Base) ||
|
|
308
|
+
(defined?(::ActiveRecord::Relation) && value.is_a?(::ActiveRecord::Relation))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Logs an error message to the appropriate logger.
|
|
312
|
+
#
|
|
313
|
+
# Attempts to use Rails.logger if available, otherwise falls back to warn.
|
|
314
|
+
# Handles logging failures gracefully.
|
|
315
|
+
#
|
|
316
|
+
# @param message [String] The error message to log
|
|
317
|
+
# @return [void]
|
|
318
|
+
#
|
|
319
|
+
# @note Prefixes all messages with "TypstRails:" for easy identification
|
|
320
|
+
# @note Never raises exceptions, even if logging fails
|
|
321
|
+
def log_error(message)
|
|
322
|
+
return if message.nil? || message.empty?
|
|
323
|
+
|
|
324
|
+
formatted_message = "TypstRails: #{message}"
|
|
325
|
+
|
|
326
|
+
begin
|
|
327
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
328
|
+
::Rails.logger.error formatted_message
|
|
329
|
+
else
|
|
330
|
+
warn formatted_message
|
|
331
|
+
end
|
|
332
|
+
rescue StandardError
|
|
333
|
+
# Fallback to warn if logging fails
|
|
334
|
+
warn formatted_message
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Delegates missing methods to the view_context.
|
|
339
|
+
#
|
|
340
|
+
# This allows the renderer to access framework helpers from the view context
|
|
341
|
+
# (e.g., Rails helpers like `link_to`, `image_tag`, etc.) when preparing data.
|
|
342
|
+
#
|
|
343
|
+
# @param method_name [Symbol] The name of the missing method
|
|
344
|
+
# @param args [Array] Arguments to pass to the method
|
|
345
|
+
# @param block [Proc] Block to pass to the method
|
|
346
|
+
# @return [Object] The result of calling the method on view_context
|
|
347
|
+
# @raise [NoMethodError] if neither the renderer nor view_context respond to the method
|
|
348
|
+
#
|
|
349
|
+
# @see #respond_to_missing?
|
|
350
|
+
def method_missing(method_name, *args, &block)
|
|
351
|
+
if @view_context.respond_to?(method_name)
|
|
352
|
+
@view_context.public_send(method_name, *args, &block)
|
|
353
|
+
else
|
|
354
|
+
super
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Complements method_missing for `respond_to?` checks.
|
|
359
|
+
#
|
|
360
|
+
# Ensures that `respond_to?` correctly reports whether the renderer can respond
|
|
361
|
+
# to a method, either directly or via delegation to the view_context.
|
|
362
|
+
#
|
|
363
|
+
# @param method_name [Symbol] The name of the method to check
|
|
364
|
+
# @param include_private [Boolean] Whether to include private methods in the check
|
|
365
|
+
# @return [Boolean] true if the method can be responded to, false otherwise
|
|
366
|
+
#
|
|
367
|
+
# @see #method_missing
|
|
368
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
369
|
+
@view_context.respond_to?(method_name, include_private) || super
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Error class for Typst-related errors.
|
|
374
|
+
#
|
|
375
|
+
# Raised when Typst compilation fails, when files cannot be created,
|
|
376
|
+
# or when other Typst-specific errors occur during rendering.
|
|
377
|
+
#
|
|
378
|
+
# @example Handling Typst errors
|
|
379
|
+
# begin
|
|
380
|
+
# renderer.render(nil, data)
|
|
381
|
+
# rescue TypstRails::Error => e
|
|
382
|
+
# Rails.logger.error "Typst compilation failed: #{e.message}"
|
|
383
|
+
# render plain: "PDF generation failed", status: 500
|
|
384
|
+
# end
|
|
385
|
+
class Error < StandardError; end
|
|
386
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypstRails
|
|
4
|
+
# Integration for Sinatra framework
|
|
5
|
+
module SinatraIntegration
|
|
6
|
+
# Simple view context wrapper providing the `assigns` reader that
|
|
7
|
+
# Renderer expects, mirroring Rails' view context interface.
|
|
8
|
+
ViewContext = Struct.new(:assigns)
|
|
9
|
+
|
|
10
|
+
def self.setup
|
|
11
|
+
return unless defined?(::Sinatra)
|
|
12
|
+
|
|
13
|
+
require "typst_rails/renderer"
|
|
14
|
+
|
|
15
|
+
# Extend Sinatra with Typst rendering capabilities
|
|
16
|
+
::Sinatra::Base.class_eval do
|
|
17
|
+
# Helper method to render Typst templates in Sinatra
|
|
18
|
+
def typst(template, locals = {}, _options = {})
|
|
19
|
+
typst_source = File.read(template)
|
|
20
|
+
renderer = ::TypstRails::Renderer.new(typst_source)
|
|
21
|
+
|
|
22
|
+
# Sinatra doesn't have the same view context as Rails,
|
|
23
|
+
# so we create a simple wrapper
|
|
24
|
+
view_context = ::TypstRails::SinatraIntegration::ViewContext.new(locals)
|
|
25
|
+
pdf_data = renderer.render(view_context, locals)
|
|
26
|
+
|
|
27
|
+
content_type "application/pdf"
|
|
28
|
+
pdf_data
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/typst_rails.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "typst_rails/version"
|
|
4
|
+
require "typst_rails/backends"
|
|
5
|
+
require "typst_rails/renderer"
|
|
6
|
+
require "typst_rails/framework_detection"
|
|
7
|
+
|
|
8
|
+
# Conditionally load framework integrations
|
|
9
|
+
require "typst_rails/railtie" if TypstRails::FrameworkDetection.rails?
|
|
10
|
+
|
|
11
|
+
if TypstRails::FrameworkDetection.rage?
|
|
12
|
+
require "typst_rails/rage_integration"
|
|
13
|
+
TypstRails::RageIntegration.setup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if TypstRails::FrameworkDetection.sinatra?
|
|
17
|
+
require "typst_rails/sinatra_integration"
|
|
18
|
+
TypstRails::SinatraIntegration.setup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Rails integration module for Typst document generation
|
|
22
|
+
#
|
|
23
|
+
# This module provides seamless integration of the Typst typesetting system
|
|
24
|
+
# with Ruby web applications. It supports Rails, Rage, Sinatra, and standalone
|
|
25
|
+
# Ruby usage through automatic framework detection.
|
|
26
|
+
#
|
|
27
|
+
# @example Basic configuration
|
|
28
|
+
# TypstRails.configure do |config|
|
|
29
|
+
# config.typst_executable_path = "/usr/local/bin/typst"
|
|
30
|
+
# config.default_root_path = Rails.root.join("app", "assets", "typst")
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example Standalone usage
|
|
34
|
+
# renderer = TypstRails::Renderer.new("#let data = json(\"typst_data.json\")\n= #data.title")
|
|
35
|
+
# pdf = renderer.render(nil, { title: "My Document" })
|
|
36
|
+
# File.binwrite("output.pdf", pdf)
|
|
37
|
+
module TypstRails
|
|
38
|
+
class << self
|
|
39
|
+
# @return [Configuration] The current configuration instance
|
|
40
|
+
attr_accessor :configuration
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Configures the Typst integration.
|
|
44
|
+
#
|
|
45
|
+
# This method creates a new Configuration with default values and yields
|
|
46
|
+
# it for modification. Each call replaces any previous configuration.
|
|
47
|
+
#
|
|
48
|
+
# @yield [configuration] Yields the configuration object for modification
|
|
49
|
+
# @yieldparam configuration [Configuration] The configuration instance to modify
|
|
50
|
+
# @return [Configuration] The configured instance
|
|
51
|
+
# @raise [ArgumentError] if no block is given
|
|
52
|
+
#
|
|
53
|
+
# @example Configure Typst executable path
|
|
54
|
+
# TypstRails.configure do |config|
|
|
55
|
+
# config.typst_executable_path = "/opt/typst/bin/typst"
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# @example Configure default root path
|
|
59
|
+
# TypstRails.configure do |config|
|
|
60
|
+
# config.default_root_path = Rails.root.join("app", "typst")
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# @example Force a specific compilation backend
|
|
64
|
+
# TypstRails.configure do |config|
|
|
65
|
+
# config.backend = :gem # or :cli
|
|
66
|
+
# end
|
|
67
|
+
def self.configure
|
|
68
|
+
raise ArgumentError, "Block required for configuration" unless block_given?
|
|
69
|
+
|
|
70
|
+
self.configuration = Configuration.new
|
|
71
|
+
yield(configuration)
|
|
72
|
+
configuration.validate!
|
|
73
|
+
configuration
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Configuration class for Typst rendering options.
|
|
77
|
+
#
|
|
78
|
+
# This class holds configuration values that control how Typst documents
|
|
79
|
+
# are compiled and rendered. It provides validation to ensure configuration
|
|
80
|
+
# values are valid before use.
|
|
81
|
+
#
|
|
82
|
+
# @example Create and configure
|
|
83
|
+
# config = TypstRails::Configuration.new
|
|
84
|
+
# config.typst_executable_path = "/usr/local/bin/typst"
|
|
85
|
+
# config.validate! # Ensures configuration is valid
|
|
86
|
+
class Configuration
|
|
87
|
+
# @return [String] Path to the Typst executable (defaults to "typst" in PATH)
|
|
88
|
+
attr_accessor :typst_executable_path
|
|
89
|
+
|
|
90
|
+
# @return [String, nil] Default root path for Typst template resolution
|
|
91
|
+
attr_accessor :default_root_path
|
|
92
|
+
|
|
93
|
+
# @return [Symbol, TypstRails::Backends::Base] Which compilation backend to use.
|
|
94
|
+
# `:auto` (the default) prefers the `typst` gem when installed, otherwise falls
|
|
95
|
+
# back to shelling out to the `typst` CLI. Set to `:gem` or `:cli` to force a
|
|
96
|
+
# specific built-in backend, or assign a custom backend instance registered
|
|
97
|
+
# via {TypstRails::Backends::Registry.register}.
|
|
98
|
+
attr_accessor :backend
|
|
99
|
+
|
|
100
|
+
# Initializes a new Configuration with default values.
|
|
101
|
+
#
|
|
102
|
+
# Default values:
|
|
103
|
+
# - `typst_executable_path`: "typst" (assumes typst is in PATH)
|
|
104
|
+
# - `default_root_path`: nil (uses temporary directory)
|
|
105
|
+
# - `backend`: `:auto` (prefer the `typst` gem, fall back to the CLI)
|
|
106
|
+
#
|
|
107
|
+
# @return [Configuration] A new configuration instance
|
|
108
|
+
#
|
|
109
|
+
# @example Create configuration with defaults
|
|
110
|
+
# config = TypstRails::Configuration.new
|
|
111
|
+
# config.typst_executable_path #=> "typst"
|
|
112
|
+
# config.default_root_path #=> nil
|
|
113
|
+
# config.backend #=> :auto
|
|
114
|
+
def initialize
|
|
115
|
+
@typst_executable_path = "typst"
|
|
116
|
+
@default_root_path = nil
|
|
117
|
+
@backend = :auto
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validates the configuration settings.
|
|
121
|
+
#
|
|
122
|
+
# Ensures that:
|
|
123
|
+
# - typst_executable_path is a non-empty string
|
|
124
|
+
# - default_root_path (if set) is a valid directory path
|
|
125
|
+
#
|
|
126
|
+
# @return [true] if configuration is valid
|
|
127
|
+
# @raise [ArgumentError] if typst_executable_path is invalid
|
|
128
|
+
# @raise [ArgumentError] if default_root_path is invalid
|
|
129
|
+
#
|
|
130
|
+
# @example Validate configuration
|
|
131
|
+
# config = Configuration.new
|
|
132
|
+
# config.typst_executable_path = ""
|
|
133
|
+
# config.validate! # Raises ArgumentError
|
|
134
|
+
def validate! # rubocop:disable Naming/PredicateMethod -- bang method raises, doesn't predicate
|
|
135
|
+
raise ArgumentError, "typst_executable_path must be a String" unless typst_executable_path.is_a?(String)
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "typst_executable_path cannot be nil or empty" if typst_executable_path.empty?
|
|
138
|
+
|
|
139
|
+
if default_root_path && !default_root_path.is_a?(String)
|
|
140
|
+
raise ArgumentError, "default_root_path must be a String or nil"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|