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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/Cargo.lock +126 -107
- data/README.md +25 -3
- data/ext/inkmark/Cargo.toml +4 -4
- data/ext/inkmark/src/chunks_by_heading.rs +3 -2
- data/ext/inkmark/src/chunks_by_size.rs +3 -3
- data/ext/inkmark/src/document.rs +46 -4
- data/ext/inkmark/src/lib.rs +28 -3
- data/ext/inkmark/src/truncate.rs +3 -3
- data/lib/inkmark/options.rb +97 -36
- data/lib/inkmark/version.rb +1 -1
- data/lib/inkmark.rb +70 -24
- data/sig/inkmark.rbs +1 -0
- metadata +6 -6
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-
|
|
181
|
-
Inkmark.
|
|
182
|
-
|
|
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`,
|
data/ext/inkmark/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "inkmark"
|
|
3
|
-
version = "0.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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::
|
|
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> =
|
|
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 {
|
data/ext/inkmark/src/document.rs
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|
data/ext/inkmark/src/lib.rs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
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(
|
|
29
|
-
|
|
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),
|
data/ext/inkmark/src/truncate.rs
CHANGED
|
@@ -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::
|
|
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> =
|
|
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
|
}
|
data/lib/inkmark/options.rb
CHANGED
|
@@ -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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
294
|
-
}
|
|
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
|
-
#
|
|
433
|
-
#
|
|
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
|
|
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
|
-
#
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
#
|
|
473
|
-
#
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
data/lib/inkmark/version.rb
CHANGED