inkmark 0.1.3 → 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.
data/README.md CHANGED
@@ -38,6 +38,7 @@ A very fast, feature-packed, AI-first Markdown gem for Ruby.
38
38
  - [Plain-text extraction](#plain-text-extraction)
39
39
  - [Markdown-to-Markdown pipeline](#markdown-to-markdown-pipeline)
40
40
  - [Event handlers](#event-handlers)
41
+ - [Ractors](#ractors)
41
42
  - [Benchmarks](#benchmarks)
42
43
  - [Contributing](#contributing)
43
44
  - [Acknowledgements](#acknowledgements)
@@ -177,9 +178,15 @@ g.options.math = true
177
178
  g.options.tables = false
178
179
  g.to_html
179
180
 
180
- # Process-level defaults, to set in your application initializer
181
- Inkmark.default_options.math = true
182
- Inkmark.new(md).to_html # picks up the default
181
+ # Process-wide defaults, set once in your application initializer
182
+ Inkmark.configure do |options|
183
+ options.math = true
184
+ options.links = { nofollow: true }
185
+ end
186
+ Inkmark.new(md).to_html # picks up the defaults
187
+
188
+ # Or replace them wholesale
189
+ Inkmark.default_options = { preset: :recommended, math: true }
183
190
  ```
184
191
 
185
192
  Unknown option keys raise `ArgumentError` immediately, including via the
@@ -1029,6 +1036,21 @@ Post-render filters (`syntax_highlight`, allowlists, `images: { lazy: true }`,
1029
1036
  highlighter
1030
1037
  - Handler-set `dest=` values pass through host and scheme allowlists
1031
1038
 
1039
+ ## Ractors
1040
+
1041
+ Inkmark is Ractor-safe: every public API works inside non-main Ractors,
1042
+ so documents can be rendered in parallel without leaving the native fast
1043
+ path.
1044
+
1045
+ ```ruby
1046
+ workers = sources.each_slice(250).map do |slice|
1047
+ Ractor.new(slice) do |batch|
1048
+ batch.map { |src| Inkmark.to_html(src, options: { preset: :recommended }) }
1049
+ end
1050
+ end
1051
+ html = workers.flat_map(&:value) # Ruby 4.0; use #take on 3.3 and 3.4
1052
+ ```
1053
+
1032
1054
  ## Benchmarks
1033
1055
 
1034
1056
  Inkmark ships a benchmark harness comparing it against `kramdown`,
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "inkmark"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -11,11 +11,11 @@ crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = { version = "0.8.2" }
14
- rb-sys = { version = "0.9.126", features = ["stable-api-compiled-fallback"] }
14
+ rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
15
15
  pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
16
16
  pulldown-cmark-escape = "0.11"
17
17
  deunicode = "1.6"
18
- emojis = "0.8"
18
+ emojis = "0.9"
19
19
  whatlang = "0.18"
20
20
  unicode-segmentation = "1.12"
21
21
  syntect = { version = "5.2", default-features = false, features = ["parsing", "html", "default-themes", "default-syntaxes", "regex-fancy"] }
@@ -28,4 +28,4 @@ pulldown-cmark-to-cmark = "22"
28
28
  rb-sys-env = "0.2.2"
29
29
 
30
30
  [dev-dependencies]
31
- rb-sys-test-helpers = { version = "0.2.2" }
31
+ rb-sys-test-helpers = { version = "0.3.0" }
@@ -19,7 +19,7 @@ use magnus::{Error, RArray, RHash, Ruby};
19
19
  use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
20
20
  use unicode_segmentation::UnicodeSegmentation;
21
21
 
22
- use crate::document::apply_filters;
22
+ use crate::document::{apply_filters, content_events};
23
23
  use crate::heading::{self, SlugDeduplicator};
24
24
  use crate::options::build_options;
25
25
  use crate::toc;
@@ -43,7 +43,8 @@ pub fn native_chunks_by_heading(
43
43
  let (cm_opts, flags) = build_options(ruby, opts_hash)?;
44
44
 
45
45
  // Parse + run the full filter pipeline, same as `to_markdown`.
46
- let events: Vec<Event> = Parser::new_ext(&source, cm_opts).collect();
46
+ // `content_events` drops frontmatter so it never becomes a section.
47
+ let events: Vec<Event> = content_events(&source, cm_opts).collect();
47
48
  let events = apply_filters(events, &flags);
48
49
 
49
50
  let boundaries = find_heading_boundaries(&events);
@@ -18,10 +18,10 @@
18
18
  //! adjacent chunks share context.
19
19
 
20
20
  use magnus::{Error, RArray, RHash, Ruby};
21
- use pulldown_cmark::{Event, Parser};
21
+ use pulldown_cmark::Event;
22
22
  use unicode_segmentation::UnicodeSegmentation;
23
23
 
24
- use crate::document::apply_filters;
24
+ use crate::document::{apply_filters, content_events};
25
25
  use crate::options::build_options;
26
26
 
27
27
  #[derive(Clone, Copy, PartialEq, Eq)]
@@ -45,7 +45,7 @@ pub fn native_chunks_by_size(
45
45
  let params = parse_params(ruby, &opts_hash)?;
46
46
  let (cm_opts, flags) = build_options(ruby, opts_hash)?;
47
47
 
48
- let events: Vec<Event> = Parser::new_ext(&source, cm_opts).collect();
48
+ let events: Vec<Event> = content_events(&source, cm_opts).collect();
49
49
  let events = apply_filters(events, &flags);
50
50
 
51
51
  let windows = match params.at {
@@ -1,5 +1,5 @@
1
1
  use magnus::{Error, RHash, Ruby};
2
- use pulldown_cmark::{html, Event, Options, Parser};
2
+ use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
3
3
 
4
4
  use crate::autolink;
5
5
  use crate::emoji;
@@ -115,6 +115,44 @@ fn hard_wrap(event: Event) -> Event {
115
115
  }
116
116
  }
117
117
 
118
+ /// Parse `source` into the content event stream that renderers and chunkers
119
+ /// consume.
120
+ ///
121
+ /// YAML frontmatter is removed at this boundary: it is document *metadata*
122
+ /// (surfaced via {Inkmark#frontmatter}), never content. pulldown-cmark's HTML
123
+ /// renderer ignores metadata blocks and our plain-text writer discards them,
124
+ /// but the Markdown serializer (`pulldown-cmark-to-cmark`) faithfully
125
+ /// re-emits them as `---\n...\n---`. Rather than re-stripping after the fact
126
+ /// in every Markdown/chunk path, we never hand those consumers the events in
127
+ /// the first place—so `to_markdown`, `chunks_by_heading`, and
128
+ /// `chunks_by_size` are frontmatter-free by construction, with no separate
129
+ /// pass and no special-casing of the streaming fast path.
130
+ ///
131
+ /// Frontmatter extraction walks the *raw* parser (see `stats::collect`), so
132
+ /// dropping the events here does not affect the `frontmatter` accessor.
133
+ pub fn content_events(source: &str, cm_opts: Options) -> impl Iterator<Item = Event<'_>> {
134
+ drop_metadata(Parser::new_ext(source, cm_opts))
135
+ }
136
+
137
+ /// Iterator adapter that filters out `Start(MetadataBlock) … End(MetadataBlock)`
138
+ /// runs, including the raw YAML `Text` between the markers. Stateful but
139
+ /// composes with both the streaming fast path and the buffered `.collect()`
140
+ /// path, so a single definition serves every content consumer.
141
+ fn drop_metadata<'a>(events: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
142
+ let mut in_metadata = false;
143
+ events.filter(move |event| match event {
144
+ Event::Start(Tag::MetadataBlock(_)) => {
145
+ in_metadata = true;
146
+ false
147
+ }
148
+ Event::End(TagEnd::MetadataBlock(_)) => {
149
+ in_metadata = false;
150
+ false
151
+ }
152
+ _ => !in_metadata,
153
+ })
154
+ }
155
+
118
156
  /// Full render: parse once, collect stats + TOC from original events,
119
157
  /// apply filters, render HTML. Returns a Ruby Hash:
120
158
  ///
@@ -219,14 +257,18 @@ fn render(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> Strin
219
257
 
220
258
  fn render_to_markdown(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> String {
221
259
  let mut buf = String::with_capacity(source.len());
222
- let parser = Parser::new_ext(source, cm_opts);
223
260
 
261
+ // `content_events` strips frontmatter, so the cmark serializer never sees
262
+ // a metadata block to re-emit—on either the streaming or buffered path.
224
263
  if !needs_buffer(&flags) {
225
- cmark_write(parser.map(stream_filter(&flags)), &mut buf);
264
+ cmark_write(
265
+ content_events(source, cm_opts).map(stream_filter(&flags)),
266
+ &mut buf,
267
+ );
226
268
  return buf;
227
269
  }
228
270
 
229
- let events = apply_filters(parser.collect(), &flags);
271
+ let events = apply_filters(content_events(source, cm_opts).collect(), &flags);
230
272
  cmark_write(events.into_iter(), &mut buf);
231
273
  buf
232
274
  }
@@ -1,4 +1,6 @@
1
- #![forbid(unsafe_code)]
1
+ // `deny` rather than `forbid`: the single `unsafe` block in `init` is
2
+ // allowed explicitly; everything else stays unsafe-free.
3
+ #![deny(unsafe_code)]
2
4
 
3
5
  use magnus::{function, prelude::*, Error, Ruby};
4
6
 
@@ -22,11 +24,34 @@ mod truncate;
22
24
  mod url_match;
23
25
 
24
26
  #[magnus::init]
27
+ #[allow(unsafe_code)]
25
28
  fn init(ruby: &Ruby) -> Result<(), Error> {
29
+ // Declare every method defined below Ractor-safe. Ruby marks the methods an
30
+ // extension defines while `Init_*` runs as unsafe unless this flag is set,
31
+ // and calling such a method from a non-main Ractor raises
32
+ // `Ractor::UnsafeError`. The extension keeps no Ruby `VALUE`s in
33
+ // process-global state: the `OnceLock` caches in `highlight` hold plain
34
+ // syntect data (`Sync`, enforced by the compiler) and the `sym_id!`
35
+ // statics in `options` hold interned symbol ids, both safe to share across
36
+ // Ractors. This must run before `define_class` so the flag covers every
37
+ // method registered below.
38
+ //
39
+ // SAFETY: `rb_ext_ractor_safe` takes no pointers and only flips a flag on
40
+ // the VM's extension-loading state. Its one precondition is being called
41
+ // while `Init_*` runs, which is exactly where `#[magnus::init]` invokes
42
+ // this function.
43
+ unsafe { rb_sys::rb_ext_ractor_safe(true) };
44
+
26
45
  let inkmark = ruby.define_class("Inkmark", ruby.class_object())?;
27
46
  inkmark.define_singleton_method("_native_to_html", function!(document::native_to_html, 2))?;
28
- inkmark.define_singleton_method("_native_to_markdown", function!(document::native_to_markdown, 2))?;
29
- inkmark.define_singleton_method("_native_to_plain_text", function!(document::native_to_plain_text, 2))?;
47
+ inkmark.define_singleton_method(
48
+ "_native_to_markdown",
49
+ function!(document::native_to_markdown, 2),
50
+ )?;
51
+ inkmark.define_singleton_method(
52
+ "_native_to_plain_text",
53
+ function!(document::native_to_plain_text, 2),
54
+ )?;
30
55
  inkmark.define_singleton_method(
31
56
  "_native_chunks_by_heading",
32
57
  function!(chunks_by_heading::native_chunks_by_heading, 2),
@@ -7,10 +7,10 @@
7
7
  //! input, context-window budgeting, and chunk normalization.
8
8
 
9
9
  use magnus::{Error, RHash, Ruby};
10
- use pulldown_cmark::{Event, Parser};
10
+ use pulldown_cmark::Event;
11
11
  use unicode_segmentation::UnicodeSegmentation;
12
12
 
13
- use crate::document::apply_filters;
13
+ use crate::document::{apply_filters, content_events};
14
14
  use crate::options::{build_options, Flags};
15
15
 
16
16
  /// What kind of boundary to cut at.
@@ -44,7 +44,7 @@ pub fn truncate_source(
44
44
  flags: &Flags,
45
45
  params: &TruncateParams,
46
46
  ) -> String {
47
- let events: Vec<Event> = Parser::new_ext(source, cm_opts).collect();
47
+ let events: Vec<Event> = content_events(source, cm_opts).collect();
48
48
  let events = apply_filters(events, flags);
49
49
  truncate_events(&events, params)
50
50
  }
@@ -22,22 +22,28 @@ class Inkmark
22
22
  # Per-element-policy schemas. Each entry is +{ default:, types: }+; the
23
23
  # validators use +types+ for type checking and +default+ to seed fresh
24
24
  # nested hashes. Keep in sync with {NESTED_TO_FLAT}.
25
+ #
26
+ # Every constant in this class is frozen all the way down, not just
27
+ # at the top level: a non-main Ractor may only read constants whose
28
+ # whole object graph is frozen (+Ractor.shareable?+), so the inner
29
+ # Hashes and Arrays are frozen explicitly and the larger nested
30
+ # tables go through +Ractor.make_shareable+.
25
31
  HEADINGS_SCHEMA = {
26
- attributes: {default: false, types: [TrueClass, FalseClass]},
27
- ids: {default: false, types: [TrueClass, FalseClass]}
32
+ attributes: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
33
+ ids: {default: false, types: [TrueClass, FalseClass].freeze}.freeze
28
34
  }.freeze
29
35
 
30
36
  IMAGES_SCHEMA = {
31
- lazy: {default: false, types: [TrueClass, FalseClass]},
32
- allowed_hosts: {default: nil, types: [NilClass, Array]},
33
- allowed_schemes: {default: nil, types: [NilClass, Array]}
37
+ lazy: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
38
+ allowed_hosts: {default: nil, types: [NilClass, Array].freeze}.freeze,
39
+ allowed_schemes: {default: nil, types: [NilClass, Array].freeze}.freeze
34
40
  }.freeze
35
41
 
36
42
  LINKS_SCHEMA = {
37
- autolink: {default: false, types: [TrueClass, FalseClass]},
38
- nofollow: {default: false, types: [TrueClass, FalseClass]},
39
- allowed_hosts: {default: nil, types: [NilClass, Array]},
40
- allowed_schemes: {default: nil, types: [NilClass, Array]}
43
+ autolink: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
44
+ nofollow: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
45
+ allowed_hosts: {default: nil, types: [NilClass, Array].freeze}.freeze,
46
+ allowed_schemes: {default: nil, types: [NilClass, Array].freeze}.freeze
41
47
  }.freeze
42
48
 
43
49
  # Registry of nested hash options => their schemas. Iterated by the
@@ -52,7 +58,7 @@ class Inkmark
52
58
  # Map from +(parent, child)+ user-facing keys to the flat key name the
53
59
  # Rust side reads. Used by {#to_native_hash} / {#to_native_hash_frozen}
54
60
  # to serialize the user-shaped hash into the FFI wire format.
55
- NESTED_TO_FLAT = {
61
+ NESTED_TO_FLAT = Ractor.make_shareable({
56
62
  [:headings, :attributes] => :heading_attributes,
57
63
  [:headings, :ids] => :heading_ids,
58
64
  [:images, :lazy] => :lazy_images,
@@ -62,7 +68,7 @@ class Inkmark
62
68
  [:links, :nofollow] => :nofollow_external_links,
63
69
  [:links, :allowed_hosts] => :allowed_link_hosts,
64
70
  [:links, :allowed_schemes] => :allowed_link_schemes
65
- }.freeze
71
+ })
66
72
 
67
73
  # Build a frozen defaults hash for a nested schema from its +default+
68
74
  # entries.
@@ -200,11 +206,11 @@ class Inkmark
200
206
  # the accepted type set (nil-default-but-Array-when-set, polymorphic
201
207
  # +toc+, nested-hash element-policy groups).
202
208
  TYPES = {
203
- extract: [NilClass, Hash],
204
- toc: [TrueClass, FalseClass, Hash],
205
- headings: [Hash],
206
- images: [Hash],
207
- links: [Hash]
209
+ extract: [NilClass, Hash].freeze,
210
+ toc: [TrueClass, FalseClass, Hash].freeze,
211
+ headings: [Hash].freeze,
212
+ images: [Hash].freeze,
213
+ links: [Hash].freeze
208
214
  }.freeze
209
215
 
210
216
  # Element kinds accepted inside +extract: { ... }+. Mirrors the match
@@ -237,7 +243,7 @@ class Inkmark
237
243
  # The GFM tagfilter stays on. **Dangerous.** Use only for content
238
244
  # the caller fully trusts (internal team-authored docs). The
239
245
  # caller is fully responsible for sanitizing output.
240
- PRESETS = {
246
+ PRESETS = Ractor.make_shareable({
241
247
  commonmark: {
242
248
  gfm: false,
243
249
  gfm_tag_filter: false,
@@ -245,7 +251,7 @@ class Inkmark
245
251
  strikethrough: false,
246
252
  tasklists: false,
247
253
  footnotes: false
248
- }.freeze,
254
+ },
249
255
 
250
256
  gfm: {
251
257
  gfm: true,
@@ -254,7 +260,7 @@ class Inkmark
254
260
  strikethrough: true,
255
261
  tasklists: true,
256
262
  footnotes: true
257
- }.freeze,
263
+ },
258
264
 
259
265
  recommended: {
260
266
  gfm: true,
@@ -272,7 +278,7 @@ class Inkmark
272
278
  syntax_highlight: true,
273
279
  hard_wrap: true,
274
280
  frontmatter: true
275
- }.freeze,
281
+ },
276
282
 
277
283
  trusted: {
278
284
  gfm: true,
@@ -290,8 +296,8 @@ class Inkmark
290
296
  syntax_highlight: true,
291
297
  hard_wrap: true,
292
298
  frontmatter: true
293
- }.freeze
294
- }.freeze
299
+ }
300
+ })
295
301
 
296
302
  # Preset applied by {#initialize} when the caller doesn't pass
297
303
  # +preset:+. +:gfm+ matches {DEFAULTS}, so the default constructor
@@ -387,7 +393,16 @@ class Inkmark
387
393
  # hashes; the input value as-is otherwise)
388
394
  # @raise [ArgumentError] if +key+ is unknown, or the value (or any
389
395
  # nested sub-value) has the wrong type
396
+ # @raise [FrozenError] if this instance is frozen (as
397
+ # {Inkmark.default_options} always is)
390
398
  def []=(key, value)
399
+ if frozen?
400
+ raise FrozenError.new(
401
+ "can't modify frozen #{self.class}: use Inkmark.configure to change " \
402
+ "the process-wide defaults, or dup for a mutable copy",
403
+ receiver: self
404
+ )
405
+ end
391
406
  validate_key!(key)
392
407
  # Deep-merge partial nested-hash overrides (+:headings+,
393
408
  # +:images+, +:links+) so callers pass only the sub-keys they
@@ -429,11 +444,17 @@ class Inkmark
429
444
  # FFI calls that don't need to add per-call params. The cache is
430
445
  # invalidated in {#[]=} and {#initialize_copy}.
431
446
  #
432
- # @return [Hash{Symbol => Object}] frozen, shared across calls until
433
- # a mutation invalidates it
447
+ # The hash is frozen all the way down, as a copy: nested Arrays and
448
+ # Hashes are duplicated before freezing so caller-supplied values
449
+ # (an +allowed_hosts+ Array, say) stay mutable in the caller's hands.
450
+ # A deeply frozen memo is what lets a frozen +Options+ be shared
451
+ # across Ractors.
452
+ #
453
+ # @return [Hash{Symbol => Object}] deeply frozen, shared across calls
454
+ # until a mutation invalidates it
434
455
  # @api private
435
456
  def to_native_hash_frozen
436
- @frozen_native_hash ||= build_native_hash.freeze
457
+ @frozen_native_hash ||= deep_frozen_copy(build_native_hash)
437
458
  end
438
459
 
439
460
  # Return a new Options instance with +other+'s values applied on top.
@@ -457,6 +478,18 @@ class Inkmark
457
478
  end
458
479
  alias_method :eql?, :==
459
480
 
481
+ # Freeze this instance. The memoized FFI hash is computed first, while
482
+ # the instance is still mutable, so {#to_native_hash_frozen} never has
483
+ # to write to a frozen object. +Ractor.make_shareable+ calls +freeze+
484
+ # on every object it visits, which is what makes a shared
485
+ # {Inkmark.default_options} renderable from any Ractor.
486
+ #
487
+ # @return [self]
488
+ def freeze
489
+ to_native_hash_frozen
490
+ super
491
+ end
492
+
460
493
  # Duplicate this instance, deep-copying the internal values hash so the
461
494
  # clone is fully independent from the original.
462
495
  def initialize_copy(orig)
@@ -466,14 +499,26 @@ class Inkmark
466
499
  @frozen_native_hash = nil
467
500
  end
468
501
 
469
- # @!macro [attach] inkmark_options_accessor
470
- # @!attribute [rw] $1
471
- # Reader and writer for the +$1+ option. The writer routes through
472
- # {#[]=} so key validation and (for nested groups) deep-merge apply
473
- # uniformly.
502
+ # Reader and writer for every option key. The writer routes through
503
+ # {#[]=} so key validation and (for nested groups) deep-merge apply
504
+ # uniformly.
505
+ #
506
+ # Generated from source strings rather than +define_method+ blocks:
507
+ # a block-defined method carries its Proc, and Ruby refuses to call
508
+ # such a method from a non-main Ractor ("defined with an un-shareable
509
+ # Proc in a different Ractor"). Plain +def+ bodies have no such
510
+ # baggage. +key+ is always a Symbol from {DEFAULTS}, so interpolating
511
+ # it is safe.
474
512
  DEFAULTS.each_key do |key|
475
- define_method(key) { @values[key] }
476
- define_method("#{key}=") { |value| self[key] = value }
513
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
514
+ def #{key} # def tables
515
+ @values[:#{key}] # @values[:tables]
516
+ end # end
517
+
518
+ def #{key}=(value) # def tables=(value)
519
+ self[:#{key}] = value # self[:tables] = value
520
+ end # end
521
+ RUBY
477
522
  end
478
523
 
479
524
  private
@@ -517,6 +562,19 @@ class Inkmark
517
562
  def validate_key!(key) = self.class.send(:validate_key!, key)
518
563
  def validate_value!(key, value) = self.class.send(:validate_value!, key, value)
519
564
 
565
+ # Return a frozen copy of +value+ with every nested Hash, Array, and
566
+ # String frozen too. Scalars (booleans, nil, Integers, Symbols) are
567
+ # returned as is. Only the container shapes {#build_native_hash} can
568
+ # produce are handled.
569
+ def deep_frozen_copy(value)
570
+ case value
571
+ when Hash then value.transform_values { |v| deep_frozen_copy(v) }.freeze
572
+ when Array then value.map { |v| deep_frozen_copy(v) }.freeze
573
+ when String then -value
574
+ else value
575
+ end
576
+ end
577
+
520
578
  # Build the Rust-facing flat hash: nested element-policy hashes expand
521
579
  # into their flat keys via {NESTED_TO_FLAT}; the internal +@toc_depth+
522
580
  # is injected when set.
@@ -624,10 +682,13 @@ class Inkmark
624
682
  # +options: { preset: :name }+ call pattern, which would otherwise
625
683
  # build a fresh +Options+ instance (seed defaults, 6–14 +[]=+
626
684
  # with validation, +build_native_hash+) on every call. The cached
627
- # hashes are frozen and safe to share across threads.
628
- PRESETS_NATIVE_HASH = PRESETS.keys.each_with_object({}) do |name, h|
629
- h[name] = new(preset: name).to_native_hash_frozen
630
- end.freeze
685
+ # hashes are deeply frozen and safe to share across threads and
686
+ # Ractors.
687
+ PRESETS_NATIVE_HASH = Ractor.make_shareable(
688
+ PRESETS.keys.each_with_object({}) do |name, h|
689
+ h[name] = new(preset: name).to_native_hash_frozen
690
+ end
691
+ )
631
692
 
632
693
  # Build a Rust-facing flat hash from +overrides+ without allocating
633
694
  # an +Options+ instance or walking +build_native_hash+. Starts from
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Inkmark
4
4
  # Current gem version.
5
- VERSION = "0.1.3"
5
+ VERSION = "0.2.0"
6
6
  end