inkmark 0.1.4 → 0.2.1
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 +142 -123
- data/README.md +25 -3
- data/ext/inkmark/Cargo.toml +5 -5
- data/ext/inkmark/src/chunks_by_heading.rs +30 -30
- data/ext/inkmark/src/document.rs +1 -1
- data/ext/inkmark/src/emoji.rs +7 -18
- data/ext/inkmark/src/handler.rs +1 -5
- data/ext/inkmark/src/lib.rs +28 -3
- data/ext/inkmark/src/stats.rs +4 -6
- data/ext/inkmark/src/toc.rs +2 -2
- 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.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Yaroslav Markin <yaroslav@markin.net>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -10,12 +10,12 @@ publish = false
|
|
|
10
10
|
crate-type = ["cdylib"]
|
|
11
11
|
|
|
12
12
|
[dependencies]
|
|
13
|
-
magnus = { version = "0.
|
|
14
|
-
rb-sys = { version = "0.9.
|
|
13
|
+
magnus = { version = "0.9.0", default-features = false }
|
|
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" }
|
|
@@ -54,17 +54,15 @@ pub fn native_chunks_by_heading(
|
|
|
54
54
|
// when there are no headings at all. Emitted as an entry with
|
|
55
55
|
// `heading: nil, level: 0, id: nil`. Skipped entirely when there
|
|
56
56
|
// is no non-empty content before the first heading.
|
|
57
|
-
let
|
|
57
|
+
let content_opts = ContentOptions {
|
|
58
|
+
with_counts: flags.statistics,
|
|
59
|
+
truncate_params: truncate_params.as_ref(),
|
|
60
|
+
};
|
|
58
61
|
let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len());
|
|
59
62
|
if preamble_end > 0 {
|
|
60
63
|
let preamble_events = &events[0..preamble_end];
|
|
61
64
|
if !is_empty_content(preamble_events) {
|
|
62
|
-
result.push(build_preamble_hash(
|
|
63
|
-
ruby,
|
|
64
|
-
preamble_events,
|
|
65
|
-
with_counts,
|
|
66
|
-
truncate_params.as_ref(),
|
|
67
|
-
)?)?;
|
|
65
|
+
result.push(build_preamble_hash(ruby, preamble_events, &content_opts)?)?;
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
|
|
@@ -87,16 +85,17 @@ pub fn native_chunks_by_heading(
|
|
|
87
85
|
}
|
|
88
86
|
let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
|
|
89
87
|
let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
|
|
88
|
+
// Content = events after End(Heading) up to the next section or
|
|
89
|
+
// end of document. Re-serialized through cmark_write.
|
|
90
|
+
let content_events = &events[(boundary.end + 1)..section_end];
|
|
90
91
|
let hash = build_section_hash(
|
|
91
92
|
ruby,
|
|
92
|
-
|
|
93
|
-
boundary,
|
|
94
|
-
section_end,
|
|
93
|
+
boundary.level,
|
|
95
94
|
&heading_text,
|
|
96
95
|
&breadcrumb,
|
|
96
|
+
content_events,
|
|
97
97
|
&mut dedup,
|
|
98
|
-
|
|
99
|
-
truncate_params.as_ref(),
|
|
98
|
+
&content_opts,
|
|
100
99
|
)?;
|
|
101
100
|
result.push(hash)?;
|
|
102
101
|
ancestors.push((level, heading_text));
|
|
@@ -113,6 +112,13 @@ struct HeadingBoundary {
|
|
|
113
112
|
level: HeadingLevel,
|
|
114
113
|
}
|
|
115
114
|
|
|
115
|
+
/// How each entry's `content` is rendered: optionally truncated, and
|
|
116
|
+
/// optionally accompanied by `character_count` / `word_count`.
|
|
117
|
+
struct ContentOptions<'a> {
|
|
118
|
+
with_counts: bool,
|
|
119
|
+
truncate_params: Option<&'a TruncateParams>,
|
|
120
|
+
}
|
|
121
|
+
|
|
116
122
|
fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
|
|
117
123
|
let mut boundaries = Vec::new();
|
|
118
124
|
let mut i = 0;
|
|
@@ -165,8 +171,7 @@ fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize)
|
|
|
165
171
|
fn build_preamble_hash(
|
|
166
172
|
ruby: &Ruby,
|
|
167
173
|
events: &[Event<'_>],
|
|
168
|
-
|
|
169
|
-
truncate_params: Option<&TruncateParams>,
|
|
174
|
+
content_opts: &ContentOptions<'_>,
|
|
170
175
|
) -> Result<RHash, Error> {
|
|
171
176
|
let hash = ruby.hash_new();
|
|
172
177
|
hash.aset(ruby.to_symbol("heading"), ())?;
|
|
@@ -176,12 +181,12 @@ fn build_preamble_hash(
|
|
|
176
181
|
// with proper sections so callers can treat every entry alike.
|
|
177
182
|
hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
|
|
178
183
|
|
|
179
|
-
let content = match truncate_params {
|
|
184
|
+
let content = match content_opts.truncate_params {
|
|
180
185
|
Some(params) => truncate::truncate_events(events, params),
|
|
181
186
|
None => render_markdown(events),
|
|
182
187
|
};
|
|
183
|
-
if with_counts {
|
|
184
|
-
let (chars, words) = count_post_truncate(events, truncate_params, &content);
|
|
188
|
+
if content_opts.with_counts {
|
|
189
|
+
let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content);
|
|
185
190
|
hash.aset(ruby.to_symbol("character_count"), chars)?;
|
|
186
191
|
hash.aset(ruby.to_symbol("word_count"), words)?;
|
|
187
192
|
}
|
|
@@ -191,14 +196,12 @@ fn build_preamble_hash(
|
|
|
191
196
|
|
|
192
197
|
fn build_section_hash(
|
|
193
198
|
ruby: &Ruby,
|
|
194
|
-
|
|
195
|
-
boundary: &HeadingBoundary,
|
|
196
|
-
section_end: usize,
|
|
199
|
+
level: HeadingLevel,
|
|
197
200
|
heading_text: &str,
|
|
198
201
|
breadcrumb: &[&str],
|
|
202
|
+
content_events: &[Event<'_>],
|
|
199
203
|
dedup: &mut SlugDeduplicator,
|
|
200
|
-
|
|
201
|
-
truncate_params: Option<&TruncateParams>,
|
|
204
|
+
content_opts: &ContentOptions<'_>,
|
|
202
205
|
) -> Result<RHash, Error> {
|
|
203
206
|
// Slug is the deduplicated slugify of the (filter-applied) heading
|
|
204
207
|
// text, matching the ids `heading_ids` / `toc` would emit for the
|
|
@@ -210,13 +213,9 @@ fn build_section_hash(
|
|
|
210
213
|
dedup.deduplicate(base)
|
|
211
214
|
};
|
|
212
215
|
|
|
213
|
-
// Content = events after End(Heading) up to the next section or
|
|
214
|
-
// end of document. Re-serialized through cmark_write.
|
|
215
|
-
let content_events = &events[(boundary.end + 1)..section_end];
|
|
216
|
-
|
|
217
216
|
let hash = ruby.hash_new();
|
|
218
217
|
hash.aset(ruby.to_symbol("heading"), heading_text)?;
|
|
219
|
-
hash.aset(ruby.to_symbol("level"), toc::level_to_u8(
|
|
218
|
+
hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?;
|
|
220
219
|
if id.is_empty() {
|
|
221
220
|
hash.aset(ruby.to_symbol("id"), ())?;
|
|
222
221
|
} else {
|
|
@@ -228,12 +227,13 @@ fn build_section_hash(
|
|
|
228
227
|
}
|
|
229
228
|
hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
|
|
230
229
|
|
|
231
|
-
let content = match truncate_params {
|
|
230
|
+
let content = match content_opts.truncate_params {
|
|
232
231
|
Some(params) => truncate::truncate_events(content_events, params),
|
|
233
232
|
None => render_markdown(content_events),
|
|
234
233
|
};
|
|
235
|
-
if with_counts {
|
|
236
|
-
let (chars, words) =
|
|
234
|
+
if content_opts.with_counts {
|
|
235
|
+
let (chars, words) =
|
|
236
|
+
count_post_truncate(content_events, content_opts.truncate_params, &content);
|
|
237
237
|
hash.aset(ruby.to_symbol("character_count"), chars)?;
|
|
238
238
|
hash.aset(ruby.to_symbol("word_count"), words)?;
|
|
239
239
|
}
|
data/ext/inkmark/src/document.rs
CHANGED
|
@@ -283,7 +283,7 @@ fn render_to_plain_text(source: &str, cm_opts: pulldown_cmark::Options, flags: F
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
let events = apply_filters(parser.collect(), &flags);
|
|
286
|
-
plain_text::write_plain_text(events
|
|
286
|
+
plain_text::write_plain_text(events, &mut buf);
|
|
287
287
|
buf
|
|
288
288
|
}
|
|
289
289
|
|
data/ext/inkmark/src/emoji.rs
CHANGED
|
@@ -16,34 +16,23 @@ use pulldown_cmark::{CowStr, Event, Tag, TagEnd};
|
|
|
16
16
|
/// Tracks code-block nesting depth so shortcodes inside fenced code blocks
|
|
17
17
|
/// are preserved. Inline code (`Event::Code`) is passed through untouched
|
|
18
18
|
/// because we only scan `Event::Text` events.
|
|
19
|
-
pub fn replace(events: &mut
|
|
19
|
+
pub fn replace(events: &mut [Event<'_>]) {
|
|
20
20
|
let mut code_depth: usize = 0;
|
|
21
21
|
|
|
22
|
-
for
|
|
23
|
-
match
|
|
22
|
+
for event in events.iter_mut() {
|
|
23
|
+
match event {
|
|
24
24
|
Event::Start(Tag::CodeBlock(_)) => {
|
|
25
25
|
code_depth += 1;
|
|
26
|
-
continue;
|
|
27
26
|
}
|
|
28
27
|
Event::End(TagEnd::CodeBlock) => {
|
|
29
28
|
code_depth = code_depth.saturating_sub(1);
|
|
30
|
-
continue;
|
|
31
29
|
}
|
|
32
|
-
Event::Text(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Take ownership of the text so we can feed it to `replace_shortcodes`
|
|
37
|
-
// and emit a new Text event with the result.
|
|
38
|
-
if let Event::Text(text) = std::mem::replace(&mut events[i], Event::SoftBreak) {
|
|
39
|
-
match replace_shortcodes(&text) {
|
|
40
|
-
Some(replaced) => {
|
|
41
|
-
events[i] = Event::Text(CowStr::Boxed(replaced.into_boxed_str()));
|
|
42
|
-
}
|
|
43
|
-
None => {
|
|
44
|
-
events[i] = Event::Text(text);
|
|
30
|
+
Event::Text(text) if code_depth == 0 => {
|
|
31
|
+
if let Some(replaced) = replace_shortcodes(text) {
|
|
32
|
+
*text = CowStr::Boxed(replaced.into_boxed_str());
|
|
45
33
|
}
|
|
46
34
|
}
|
|
35
|
+
_ => {}
|
|
47
36
|
}
|
|
48
37
|
}
|
|
49
38
|
}
|
data/ext/inkmark/src/handler.rs
CHANGED
|
@@ -696,11 +696,7 @@ fn apply_mutations(node: &mut Node, event_obj: Value, _ruby: &Ruby) -> Result<()
|
|
|
696
696
|
Ok(())
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
-
pub fn dispatch_handlers(
|
|
700
|
-
nodes: &mut Vec<Node>,
|
|
701
|
-
handlers: &RHash,
|
|
702
|
-
ruby: &Ruby,
|
|
703
|
-
) -> Result<(), Error> {
|
|
699
|
+
pub fn dispatch_handlers(nodes: &mut [Node], handlers: &RHash, ruby: &Ruby) -> Result<(), Error> {
|
|
704
700
|
for node in nodes.iter_mut() {
|
|
705
701
|
dispatch_handlers(&mut node.children, handlers, ruby)?;
|
|
706
702
|
|
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/stats.rs
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
//! The collector is the single source of truth for the full-render path.
|
|
17
17
|
//! Two independent Ruby-side knobs consume its output:
|
|
18
18
|
//! - `statistics: true` => scalar counts and language
|
|
19
|
-
//!
|
|
19
|
+
//! detection (`to_statistics_hash`)
|
|
20
20
|
//! - `extract: {...}` => filtered arrays of structured
|
|
21
|
-
//!
|
|
21
|
+
//! records (`to_extracts_hash`)
|
|
22
22
|
|
|
23
23
|
use std::ops::Range;
|
|
24
24
|
|
|
@@ -293,10 +293,8 @@ pub fn collect(events: &[(Event<'_>, Range<usize>)]) -> Stats {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
-
Event::SoftBreak | Event::HardBreak => {
|
|
297
|
-
|
|
298
|
-
text_buf.push(' ');
|
|
299
|
-
}
|
|
296
|
+
Event::SoftBreak | Event::HardBreak if !in_code_block => {
|
|
297
|
+
text_buf.push(' ');
|
|
300
298
|
}
|
|
301
299
|
_ => {}
|
|
302
300
|
}
|
data/ext/inkmark/src/toc.rs
CHANGED
|
@@ -22,7 +22,7 @@ pub struct TocEntry {
|
|
|
22
22
|
pub fn toc_to_markdown(entries: &[TocEntry], max_depth: Option<u8>) -> String {
|
|
23
23
|
let filtered: Vec<&TocEntry> = entries
|
|
24
24
|
.iter()
|
|
25
|
-
.filter(|e| max_depth.
|
|
25
|
+
.filter(|e| max_depth.is_none_or(|max| level_to_u8(e.level) <= max))
|
|
26
26
|
.collect();
|
|
27
27
|
if filtered.is_empty() {
|
|
28
28
|
return String::new();
|
|
@@ -61,7 +61,7 @@ pub fn toc_to_markdown(entries: &[TocEntry], max_depth: Option<u8>) -> String {
|
|
|
61
61
|
pub fn toc_to_html(entries: &[TocEntry], max_depth: Option<u8>) -> String {
|
|
62
62
|
let filtered: Vec<&TocEntry> = entries
|
|
63
63
|
.iter()
|
|
64
|
-
.filter(|e| max_depth.
|
|
64
|
+
.filter(|e| max_depth.is_none_or(|max| level_to_u8(e.level) <= max))
|
|
65
65
|
.collect();
|
|
66
66
|
if filtered.is_empty() {
|
|
67
67
|
return String::new();
|
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