inkmark 0.1.0-arm64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,698 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Inkmark
4
+ # Typed hash of Inkmark rendering options with a known key set.
5
+ # Unknown keys raise ArgumentError at every write path.
6
+ #
7
+ # Nested policy hashes—+:headings+, +:images+, +:links+—group related
8
+ # options together and deep-merge over defaults when set, so users
9
+ # can tweak one sub-key without clobbering the others.
10
+ #
11
+ # The meta +:preset+ option (accepted in {.new} and {.native_hash_from})
12
+ # selects a named bundle from {PRESETS} applied before the rest of
13
+ # the overrides. +:gfm+ is the default preset; see {PRESETS} for the
14
+ # full list.
15
+ #
16
+ # @example Preset + per-app overrides
17
+ # Inkmark::Options.new(
18
+ # preset: :recommended,
19
+ # links: { allowed_hosts: ["*.example.com"] }
20
+ # )
21
+ class Options
22
+ # Per-element-policy schemas. Each entry is +{ default:, types: }+; the
23
+ # validators use +types+ for type checking and +default+ to seed fresh
24
+ # nested hashes. Keep in sync with {NESTED_TO_FLAT}.
25
+ HEADINGS_SCHEMA = {
26
+ attributes: {default: false, types: [TrueClass, FalseClass]},
27
+ ids: {default: false, types: [TrueClass, FalseClass]}
28
+ }.freeze
29
+
30
+ 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]}
34
+ }.freeze
35
+
36
+ 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]}
41
+ }.freeze
42
+
43
+ # Registry of nested hash options => their schemas. Iterated by the
44
+ # validator and native-hash flattener to keep the three element-policy
45
+ # groupings uniform.
46
+ NESTED_SCHEMAS = {
47
+ headings: HEADINGS_SCHEMA,
48
+ images: IMAGES_SCHEMA,
49
+ links: LINKS_SCHEMA
50
+ }.freeze
51
+
52
+ # Map from +(parent, child)+ user-facing keys to the flat key name the
53
+ # Rust side reads. Used by {#to_native_hash} / {#to_native_hash_frozen}
54
+ # to serialize the user-shaped hash into the FFI wire format.
55
+ NESTED_TO_FLAT = {
56
+ [:headings, :attributes] => :heading_attributes,
57
+ [:headings, :ids] => :heading_ids,
58
+ [:images, :lazy] => :lazy_images,
59
+ [:images, :allowed_hosts] => :allowed_image_hosts,
60
+ [:images, :allowed_schemes] => :allowed_image_schemes,
61
+ [:links, :autolink] => :autolink,
62
+ [:links, :nofollow] => :nofollow_external_links,
63
+ [:links, :allowed_hosts] => :allowed_link_hosts,
64
+ [:links, :allowed_schemes] => :allowed_link_schemes
65
+ }.freeze
66
+
67
+ # Build a frozen defaults hash for a nested schema from its +default+
68
+ # entries.
69
+ def self.schema_defaults(schema)
70
+ schema.each_with_object({}) { |(k, v), h| h[k] = v[:default] }.freeze
71
+ end
72
+
73
+ # Default values for every option. Top-level keys are user-facing; nested
74
+ # element-policy groups (+headings+, +images+, +links+) hold their own
75
+ # default hashes built from {NESTED_SCHEMAS}.
76
+ DEFAULTS = {
77
+ # GFM conformance bundle. Enables pulldown-cmark's ENABLE_GFM and the
78
+ # four core GFM extensions. Individual extensions can still be toggled
79
+ # off after setting gfm: true.
80
+ gfm: true,
81
+
82
+ # GFM "Disallowed Raw HTML" extension. When +gfm+ and +raw_html+ are
83
+ # both true, escapes the leading +<+ of nine unsafe tag names
84
+ # (title, textarea, style, xmp, iframe, noembed, noframes, script,
85
+ # plaintext). Required for GFM conformance; no effect when
86
+ # +raw_html+ is false.
87
+ gfm_tag_filter: true,
88
+
89
+ # GFM pipe tables with optional column-alignment markers.
90
+ tables: true,
91
+
92
+ # GFM strikethrough: +~~text~~+ → +<del>+.
93
+ strikethrough: true,
94
+
95
+ # GFM task lists: +- [ ]+ and +- [x]+ → disabled checkboxes.
96
+ tasklists: true,
97
+
98
+ # Footnote references and definitions.
99
+ footnotes: true,
100
+
101
+ # Pass raw HTML tags through unescaped. Off by default for
102
+ # untrusted-input safety. When true, the caller is fully responsible
103
+ # for sanitizing output—Inkmark does not sanitize beyond the narrow
104
+ # GFM tagfilter. Always run the output through a dedicated sanitizer
105
+ # (Sanitize, Loofah, rails-html-sanitizer) for untrusted content.
106
+ raw_html: false,
107
+
108
+ # Smart punctuation: ASCII quotes/dashes/ellipses → typographic forms.
109
+ smart_punctuation: false,
110
+
111
+ # Heading-related options. +:attributes+ enables +# Title {#id .klass}+
112
+ # inline attribute syntax; +:ids+ auto-generates an +id+ on every
113
+ # heading from its text (slug). User-supplied ids from +attributes+
114
+ # are preserved when +:ids+ fills the rest in.
115
+ headings: schema_defaults(HEADINGS_SCHEMA),
116
+
117
+ # Image-related options. +:lazy+ adds +loading="lazy" decoding="async"+
118
+ # to every +<img>+. +:allowed_hosts+ is a glob allowlist for
119
+ # +<img src>+ hostnames (http/https); non-matching images drop to alt
120
+ # text. +:allowed_schemes+ is a URL-scheme allowlist for image URLs.
121
+ # Both allowlists default to +nil+ (no filtering); set +[]+ to
122
+ # deny-all-external.
123
+ images: schema_defaults(IMAGES_SCHEMA),
124
+
125
+ # Link-related options. +:autolink+ turns bare URLs and emails into
126
+ # clickable links. +:nofollow+ adds +rel="nofollow noopener"+ to
127
+ # external +<a>+ tags. +:allowed_hosts+ / +:allowed_schemes+ are
128
+ # glob / scheme allowlists for +<a href>+ (same semantics as the
129
+ # image versions). Relative/anchor/mailto URLs are never filtered.
130
+ links: schema_defaults(LINKS_SCHEMA),
131
+
132
+ # Replace gemoji-style +:shortcode:+ sequences with the emoji
133
+ # character. Unknown codes and codes inside code blocks are preserved.
134
+ emoji_shortcodes: false,
135
+
136
+ # Server-side syntax highlighting for fenced code blocks with a
137
+ # language tag. Uses syntect with CSS class output—pair with
138
+ # {Inkmark.highlight_css} for the theme stylesheet.
139
+ syntax_highlight: false,
140
+
141
+ # Treat every single newline in a paragraph as a hard line break
142
+ # (+<br />+). Default is soft-break (single +\n+ → space).
143
+ hard_wrap: false,
144
+
145
+ # Collect a table of contents from headings. When set, {Inkmark#toc}
146
+ # returns a {Inkmark::Toc} value object (+#to_markdown+ / +#to_html+ /
147
+ # +#to_s+). Implicitly enables +headings[:ids]+ in the rendered HTML
148
+ # so TOC anchor hrefs have matching targets. Also populates
149
+ # {Inkmark#statistics} with +heading_count+.
150
+ #
151
+ # Accepts +true+ / +false+ for simple enable/disable, or a Hash with a
152
+ # +:depth+ key to limit which heading levels appear in the rendered
153
+ # TOC. +toc: { depth: 3 }+ renders h1–h3 only; +toc: {}+ or
154
+ # +toc: true+ renders all levels. Depth filtering affects only the
155
+ # rendered TOC; +heading_count+, +extracts[:headings]+, and
156
+ # +chunks_by_heading+ still see every heading.
157
+ toc: false,
158
+
159
+ # Full document statistics: language detection, character/word counts,
160
+ # and +*_count+ fields for headings, code blocks, images, links, and
161
+ # footnote definitions. For structured arrays of records, use
162
+ # {extract}. Implies +toc+ and +headings[:ids]+.
163
+ statistics: false,
164
+
165
+ # Opt into structured extraction of specific element kinds. Pass a
166
+ # Hash with any of +:images+, +:links+, +:code_blocks+, +:headings+,
167
+ # +:footnote_definitions+ set to +true+. {Inkmark#extracts} then
168
+ # returns a Hash keyed by the requested kinds, each carrying an Array
169
+ # of record Hashes with a +:byte_range+. +nil+ (default) disables
170
+ # extraction. +extract: { headings: true }+ and +toc: true+ trigger
171
+ # each other—one heading walk powers both surfaces.
172
+ extract: nil,
173
+
174
+ # Math: +$inline$+ and +$$display$$+ blocks → +<code class="language-math">+.
175
+ math: false,
176
+
177
+ # Definition lists: +term\n: definition+ → +<dl>+.
178
+ definition_list: false,
179
+
180
+ # Superscript: +^text^+ → +<sup>+.
181
+ superscript: false,
182
+
183
+ # Subscript: +~text~+ → +<sub>+ (conflicts with strikethrough—enable
184
+ # only one).
185
+ subscript: false,
186
+
187
+ # Wiki-style links: +[[Page]]+ and +[[Page|label]]+ → +<a>+.
188
+ wikilinks: false,
189
+
190
+ # Frontmatter: YAML metadata at the start of the document. Recognized
191
+ # +---\nkey: value\n---+ block is stripped from rendered output and
192
+ # exposed as a Hash via {Inkmark#frontmatter}.
193
+ frontmatter: false
194
+ }.freeze
195
+
196
+ # Per-key class allowlist. Keys absent from this hash inherit their
197
+ # allowed classes from {DEFAULTS}: a boolean default allows
198
+ # +TrueClass+/+FalseClass+, a +nil+ default allows +NilClass+, and so
199
+ # on. Entries here declare options whose default doesn't fully describe
200
+ # the accepted type set (nil-default-but-Array-when-set, polymorphic
201
+ # +toc+, nested-hash element-policy groups).
202
+ TYPES = {
203
+ extract: [NilClass, Hash],
204
+ toc: [TrueClass, FalseClass, Hash],
205
+ headings: [Hash],
206
+ images: [Hash],
207
+ links: [Hash]
208
+ }.freeze
209
+
210
+ # Element kinds accepted inside +extract: { ... }+. Mirrors the match
211
+ # arms in Rust +stats::to_extracts_hash+—changing one means changing
212
+ # the other.
213
+ EXTRACT_KINDS = %i[
214
+ images
215
+ links
216
+ code_blocks
217
+ headings
218
+ footnote_definitions
219
+ ].freeze
220
+
221
+ # Named bundles of option settings. Pass +preset: :name+ in the
222
+ # options hash and the preset's values are applied first; any other
223
+ # keys override (deep-merging for nested element-policy hashes).
224
+ #
225
+ # - +:gfm+ (the default applied by {#initialize}) — CommonMark +
226
+ # core GFM extensions (tables, strikethrough, tasklists, footnotes,
227
+ # tagfilter). Conservative, matches the render profile of every
228
+ # other major GFM engine.
229
+ # - +:commonmark+ — strict CommonMark, no GFM extensions.
230
+ # - +:recommended+ — opinionated bundle for modern web content.
231
+ # Enables smart punctuation, auto heading IDs, lazy-loading images,
232
+ # autolinks, +rel="nofollow noopener"+ on external links, URL
233
+ # scheme allowlists for links and images, emoji shortcodes, syntax
234
+ # highlighting, hard wraps, and frontmatter. Recommended starting
235
+ # point for apps; override individual options to tune.
236
+ # - +:trusted+ — +:recommended+ with raw HTML pass-through enabled.
237
+ # The GFM tagfilter stays on. **Dangerous.** Use only for content
238
+ # the caller fully trusts (internal team-authored docs). The
239
+ # caller is fully responsible for sanitizing output.
240
+ PRESETS = {
241
+ commonmark: {
242
+ gfm: false,
243
+ gfm_tag_filter: false,
244
+ tables: false,
245
+ strikethrough: false,
246
+ tasklists: false,
247
+ footnotes: false
248
+ }.freeze,
249
+
250
+ gfm: {
251
+ gfm: true,
252
+ gfm_tag_filter: true,
253
+ tables: true,
254
+ strikethrough: true,
255
+ tasklists: true,
256
+ footnotes: true
257
+ }.freeze,
258
+
259
+ recommended: {
260
+ gfm: true,
261
+ gfm_tag_filter: true,
262
+ tables: true,
263
+ strikethrough: true,
264
+ tasklists: true,
265
+ footnotes: true,
266
+ raw_html: false,
267
+ smart_punctuation: true,
268
+ headings: {attributes: false, ids: true},
269
+ images: {lazy: true, allowed_schemes: ["http", "https"]},
270
+ links: {autolink: true, nofollow: true, allowed_schemes: ["http", "https", "mailto"]},
271
+ emoji_shortcodes: true,
272
+ syntax_highlight: true,
273
+ hard_wrap: true,
274
+ frontmatter: true
275
+ }.freeze,
276
+
277
+ trusted: {
278
+ gfm: true,
279
+ gfm_tag_filter: true,
280
+ tables: true,
281
+ strikethrough: true,
282
+ tasklists: true,
283
+ footnotes: true,
284
+ raw_html: true,
285
+ smart_punctuation: true,
286
+ headings: {attributes: false, ids: true},
287
+ images: {lazy: true, allowed_schemes: ["http", "https"]},
288
+ links: {autolink: true, nofollow: true, allowed_schemes: ["http", "https", "mailto"]},
289
+ emoji_shortcodes: true,
290
+ syntax_highlight: true,
291
+ hard_wrap: true,
292
+ frontmatter: true
293
+ }.freeze
294
+ }.freeze
295
+
296
+ # Preset applied by {#initialize} when the caller doesn't pass
297
+ # +preset:+. +:gfm+ matches {DEFAULTS}, so the default constructor
298
+ # is equivalent to "CommonMark + core GFM, nothing else".
299
+ DEFAULT_PRESET = :gfm
300
+
301
+ # Build a new Options instance with defaults from {DEFAULTS} plus any
302
+ # overrides applied on top. Nested hash values (for +:headings+,
303
+ # +:images+, +:links+) are deep-merged over the defaults—users only
304
+ # need to pass the sub-keys they care about.
305
+ #
306
+ # @param overrides [Hash{Symbol => Object}] option keys and values to
307
+ # override against the defaults. Every key must be present in
308
+ # {DEFAULTS} or +ArgumentError+ is raised.
309
+ # @option overrides [Boolean] :gfm (true) GFM conformance mode +
310
+ # bundle-enable tables, strikethrough, tasklists, and footnotes.
311
+ # @option overrides [Boolean] :tables (true) GFM pipe tables.
312
+ # @option overrides [Boolean] :strikethrough (true) +~~text~~+.
313
+ # @option overrides [Boolean] :tasklists (true) +- [ ]+ / +- [x]+.
314
+ # @option overrides [Boolean] :footnotes (true) +[^1]+ / +[^1]: body+.
315
+ # @option overrides [Boolean] :raw_html (false) Pass raw HTML through.
316
+ # @option overrides [Boolean] :smart_punctuation (false) Typographic
317
+ # quotes/dashes/ellipses.
318
+ # @option overrides [Hash] :headings ({attributes: false, ids: false})
319
+ # Heading-related policy. Sub-keys: +:attributes+ (inline
320
+ # +{#id .klass}+ syntax), +:ids+ (auto-generate slug ids).
321
+ # @option overrides [Hash] :images ({lazy: false, allowed_hosts: nil,
322
+ # allowed_schemes: nil}) Image-related policy. Sub-keys: +:lazy+
323
+ # (+loading="lazy"+), +:allowed_hosts+ (glob allowlist), +:allowed_schemes+.
324
+ # @option overrides [Hash] :links ({autolink: false, nofollow: false,
325
+ # allowed_hosts: nil, allowed_schemes: nil}) Link-related policy.
326
+ # Sub-keys: +:autolink+, +:nofollow+ (external +rel+), +:allowed_hosts+,
327
+ # +:allowed_schemes+.
328
+ # @option overrides [Boolean] :emoji_shortcodes (false) +:rocket:+ → 🚀.
329
+ # @option overrides [Boolean] :syntax_highlight (false) Server-side
330
+ # syntect highlighting for fenced code blocks.
331
+ # @option overrides [Boolean] :hard_wrap (false) Every +\n+ → +<br />+.
332
+ # @option overrides [Boolean, Hash] :toc (false) Collect TOC.
333
+ # +true+ / +{}+ includes all heading levels; +{ depth: N }+ limits to
334
+ # h1..hN (1..6).
335
+ # @option overrides [Boolean] :statistics (false) Full document stats.
336
+ # @option overrides [Hash, nil] :extract (nil) Structured element
337
+ # extraction. Keys: +:images+, +:links+, +:code_blocks+, +:headings+,
338
+ # +:footnote_definitions+.
339
+ # @option overrides [Boolean] :math (false)
340
+ # @option overrides [Boolean] :definition_list (false)
341
+ # @option overrides [Boolean] :superscript (false)
342
+ # @option overrides [Boolean] :subscript (false)
343
+ # @option overrides [Boolean] :wikilinks (false)
344
+ # @option overrides [Boolean] :frontmatter (false) Parse YAML
345
+ # frontmatter and expose via {Inkmark#frontmatter}.
346
+ # @option overrides [Symbol] :preset (:gfm) Named bundle of option
347
+ # settings applied before the rest of +overrides+. See {PRESETS}
348
+ # for the available names. Every other key in +overrides+ takes
349
+ # precedence over the preset (nested hashes deep-merge).
350
+ # @raise [ArgumentError] if any key in +overrides+ is unknown, any
351
+ # nested sub-key is unknown, any value has the wrong type, or
352
+ # +preset:+ is not a known preset name.
353
+ # @example With defaults
354
+ # Inkmark::Options.new[:tables] #=> true
355
+ # @example Deep-merge over nested defaults
356
+ # opts = Inkmark::Options.new(images: { lazy: true })
357
+ # opts[:images] #=> { lazy: true, allowed_hosts: nil, allowed_schemes: nil }
358
+ # @example Preset + override
359
+ # opts = Inkmark::Options.new(preset: :recommended, smart_punctuation: false)
360
+ # opts[:smart_punctuation] #=> false (override wins)
361
+ # opts[:syntax_highlight] #=> true (kept from :recommended)
362
+ def initialize(overrides = {})
363
+ @values = dup_with_nested(DEFAULTS)
364
+ @toc_depth = nil
365
+ @frozen_native_hash = nil
366
+ apply_overrides!(overrides, default_preset: DEFAULT_PRESET)
367
+ end
368
+
369
+ # Read an option by key. Nested element-policy keys return the nested
370
+ # hash as a live reference—mutating it directly bypasses cache
371
+ # invalidation; prefer the setter.
372
+ #
373
+ # @param key [Symbol] a key from {DEFAULTS}
374
+ # @return [Object] the current value for that key
375
+ # @raise [KeyError] if +key+ is not present in {DEFAULTS}
376
+ def [](key)
377
+ @values.fetch(key)
378
+ end
379
+
380
+ # Write an option by key. For nested element-policy keys (+:headings+,
381
+ # +:images+, +:links+) the hash is deep-merged over the current value,
382
+ # so callers may pass only the sub-keys they want to change.
383
+ #
384
+ # @param key [Symbol] a key from {DEFAULTS}
385
+ # @param value [Object] the new value
386
+ # @return [Object] the value that was written (post-merge for nested
387
+ # hashes; the input value as-is otherwise)
388
+ # @raise [ArgumentError] if +key+ is unknown, or the value (or any
389
+ # nested sub-value) has the wrong type
390
+ def []=(key, value)
391
+ validate_key!(key)
392
+ # Deep-merge partial nested-hash overrides (+:headings+,
393
+ # +:images+, +:links+) so callers pass only the sub-keys they
394
+ # care about; non-Hash values fall through to validate_value!
395
+ # and raise there.
396
+ value = @values[key].merge(value) if NESTED_SCHEMAS.key?(key) && value.is_a?(Hash)
397
+ validate_value!(key, value)
398
+ @values[key] = value
399
+ # Sugar: +toc: { depth: N }+ normalizes to +toc: true+ plus the
400
+ # depth stashed in +@toc_depth+ (not a user-facing option).
401
+ if key == :toc && value.is_a?(Hash)
402
+ @values[:toc] = true
403
+ @toc_depth = value[:depth]
404
+ end
405
+ @frozen_native_hash = nil
406
+ end
407
+
408
+ # Return a plain user-shaped Hash copy of the current option values.
409
+ # Nested element-policy groups are returned as nested Hashes,
410
+ # mirroring the input shape accepted by {#initialize}.
411
+ #
412
+ # @return [Hash{Symbol => Object}]
413
+ def to_h
414
+ dup_with_nested(@values)
415
+ end
416
+
417
+ # Return a Rust-facing flat Hash: nested element-policy hashes are
418
+ # expanded into their flat Rust keys via {NESTED_TO_FLAT}, and the
419
+ # internal +@toc_depth+ is injected when set. Used by the FFI layer.
420
+ #
421
+ # @return [Hash{Symbol => Object}] fresh mutable hash; callers that
422
+ # add per-call params (truncate, window, etc.) mutate this hash
423
+ # @api private
424
+ def to_native_hash
425
+ build_native_hash
426
+ end
427
+
428
+ # Memoized frozen variant of {#to_native_hash} used by the hot-path
429
+ # FFI calls that don't need to add per-call params. The cache is
430
+ # invalidated in {#[]=} and {#initialize_copy}.
431
+ #
432
+ # @return [Hash{Symbol => Object}] frozen, shared across calls until
433
+ # a mutation invalidates it
434
+ # @api private
435
+ def to_native_hash_frozen
436
+ @frozen_native_hash ||= build_native_hash.freeze
437
+ end
438
+
439
+ # Return a new Options instance with +other+'s values applied on top.
440
+ # Nested element-policy hashes deep-merge; top-level values replace.
441
+ # Accepts +preset:+ on +other+ to re-apply a named preset before the
442
+ # other overrides (unlike {#initialize}, no default preset is applied
443
+ # when +other+ omits +preset:+—the receiver's state is preserved).
444
+ #
445
+ # @param other [Inkmark::Options, Hash] source of overriding values
446
+ # @return [Inkmark::Options] merged result; neither receiver nor +other+ is mutated
447
+ def merge(other)
448
+ other_hash = other.is_a?(Inkmark::Options) ? other.to_h : other
449
+ merged = dup
450
+ merged.send(:apply_overrides!, other_hash, default_preset: nil)
451
+ merged
452
+ end
453
+
454
+ # Compare by value equality (user-shaped view).
455
+ def ==(other)
456
+ other.class == self.class && to_h == other.to_h
457
+ end
458
+ alias_method :eql?, :==
459
+
460
+ # Duplicate this instance, deep-copying the internal values hash so the
461
+ # clone is fully independent from the original.
462
+ def initialize_copy(orig)
463
+ super
464
+ @values = dup_with_nested(orig.instance_variable_get(:@values))
465
+ @toc_depth = orig.instance_variable_get(:@toc_depth)
466
+ @frozen_native_hash = nil
467
+ end
468
+
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.
474
+ DEFAULTS.each_key do |key|
475
+ define_method(key) { @values[key] }
476
+ define_method("#{key}=") { |value| self[key] = value }
477
+ end
478
+
479
+ private
480
+
481
+ # Apply a hash of override values, handling the pseudo-option
482
+ # +:preset+ by expanding it into its PRESETS entry first. Called
483
+ # from {#initialize} (which passes +default_preset: :gfm+ so bare
484
+ # +Options.new+ gets the GFM preset) and {#merge} (which passes
485
+ # +default_preset: nil+ so the receiver's state is preserved when
486
+ # the caller doesn't specify a preset).
487
+ def apply_overrides!(overrides, default_preset:)
488
+ overrides = overrides.to_h
489
+ preset_name = overrides.fetch(:preset, default_preset)
490
+ overrides = overrides.except(:preset)
491
+
492
+ if preset_name
493
+ preset = PRESETS.fetch(preset_name) do
494
+ raise ArgumentError,
495
+ "unknown preset: #{preset_name.inspect}; expected one of #{PRESETS.keys.inspect}"
496
+ end
497
+ preset.each { |k, v| self[k] = v }
498
+ end
499
+
500
+ overrides.each { |k, v| self[k] = v }
501
+ end
502
+
503
+ # Shallow-dup a hash but deep-dup any one-level nested hashes, so
504
+ # the caller can mutate nested entries without aliasing back into
505
+ # +source+. Used to seed +@values+ from DEFAULTS, to snapshot
506
+ # +@values+ for {#to_h}, and to fork +@values+ in
507
+ # {#initialize_copy}.
508
+ def dup_with_nested(source)
509
+ source.each_with_object({}) do |(k, v), h|
510
+ h[k] = v.is_a?(Hash) ? v.dup : v
511
+ end
512
+ end
513
+
514
+ # Delegate to the class-method validators so both +[]=+ (instance)
515
+ # and {.native_hash_from} (class-level fast path) share one source
516
+ # of truth for validation rules and error messages.
517
+ def validate_key!(key) = self.class.send(:validate_key!, key)
518
+ def validate_value!(key, value) = self.class.send(:validate_value!, key, value)
519
+
520
+ # Build the Rust-facing flat hash: nested element-policy hashes expand
521
+ # into their flat keys via {NESTED_TO_FLAT}; the internal +@toc_depth+
522
+ # is injected when set.
523
+ def build_native_hash
524
+ h = {}
525
+ @values.each do |key, value|
526
+ if NESTED_SCHEMAS.key?(key)
527
+ value.each do |sub_key, sub_value|
528
+ h[NESTED_TO_FLAT.fetch([key, sub_key])] = sub_value
529
+ end
530
+ else
531
+ h[key] = value
532
+ end
533
+ end
534
+ h[:toc_depth] = @toc_depth unless @toc_depth.nil?
535
+ h
536
+ end
537
+
538
+ # Pure functions of class-level constants (DEFAULTS, TYPES,
539
+ # NESTED_SCHEMAS, EXTRACT_KINDS). Called from both +#[]=+ (via
540
+ # instance delegates above) and {.native_hash_from} (the
541
+ # class-method fast path that bypasses Options allocation for
542
+ # one-shot callers).
543
+
544
+ class << self
545
+ private
546
+
547
+ def validate_key!(key)
548
+ return if DEFAULTS.key?(key)
549
+ raise ArgumentError, "unknown Inkmark option: #{key.inspect}"
550
+ end
551
+
552
+ def validate_value!(key, value)
553
+ allowed = TYPES[key] || default_types_for(key)
554
+ unless allowed.any? { |klass| value.is_a?(klass) }
555
+ raise ArgumentError,
556
+ "invalid value for #{key}: got #{value.class} (#{value.inspect}), " \
557
+ "expected one of #{allowed.inspect}"
558
+ end
559
+ validate_extract_hash!(value) if key == :extract && value.is_a?(Hash)
560
+ validate_toc_hash!(value) if key == :toc && value.is_a?(Hash)
561
+ validate_nested_hash!(key, value) if NESTED_SCHEMAS.key?(key)
562
+ end
563
+
564
+ def validate_extract_hash!(hash)
565
+ hash.each do |kind, enabled|
566
+ unless EXTRACT_KINDS.include?(kind)
567
+ raise ArgumentError,
568
+ "unknown extract kind: #{kind.inspect}; " \
569
+ "expected one of #{EXTRACT_KINDS.inspect}"
570
+ end
571
+ unless enabled == true || enabled == false
572
+ raise ArgumentError,
573
+ "invalid value for extract[#{kind.inspect}]: got #{enabled.class} (#{enabled.inspect}), " \
574
+ "expected true or false"
575
+ end
576
+ end
577
+ end
578
+
579
+ def validate_toc_hash!(hash)
580
+ unknown = hash.keys - [:depth]
581
+ unless unknown.empty?
582
+ raise ArgumentError,
583
+ "unknown toc key(s): #{unknown.inspect}; expected :depth"
584
+ end
585
+ depth = hash[:depth]
586
+ return if depth.nil?
587
+ unless depth.is_a?(Integer) && (1..6).cover?(depth)
588
+ raise ArgumentError,
589
+ "invalid value for toc depth: got #{depth.inspect}, expected nil or Integer 1..6"
590
+ end
591
+ end
592
+
593
+ def validate_nested_hash!(key, hash)
594
+ schema = NESTED_SCHEMAS[key]
595
+ unknown = hash.keys - schema.keys
596
+ unless unknown.empty?
597
+ raise ArgumentError,
598
+ "unknown #{key} key(s): #{unknown.inspect}; " \
599
+ "expected one of #{schema.keys.inspect}"
600
+ end
601
+ hash.each do |sub_key, sub_value|
602
+ types = schema[sub_key][:types]
603
+ unless types.any? { |klass| sub_value.is_a?(klass) }
604
+ raise ArgumentError,
605
+ "invalid value for #{key}[#{sub_key.inspect}]: got #{sub_value.class} " \
606
+ "(#{sub_value.inspect}), expected one of #{types.inspect}"
607
+ end
608
+ end
609
+ end
610
+
611
+ def default_types_for(key)
612
+ case DEFAULTS[key]
613
+ when true, false then [TrueClass, FalseClass]
614
+ when nil then [NilClass]
615
+ else [DEFAULTS[key].class]
616
+ end
617
+ end
618
+ end
619
+
620
+ # Precomputed flat Rust-facing hash per preset. Built once at load
621
+ # time by running each preset through +Options.new(preset: name)+
622
+ # and memoizing the resulting +to_native_hash_frozen+. Used by the
623
+ # class-method fast paths in {Inkmark} to short-circuit the
624
+ # +options: { preset: :name }+ call pattern, which would otherwise
625
+ # build a fresh +Options+ instance (seed defaults, 6–14 +[]=+
626
+ # 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
631
+
632
+ # Build a Rust-facing flat hash from +overrides+ without allocating
633
+ # an +Options+ instance or walking +build_native_hash+. Starts from
634
+ # the cached preset native hash and applies user overrides directly
635
+ # to the flat form; nested element-policy hashes (+:headings+,
636
+ # +:images+, +:links+) are flattened via {NESTED_TO_FLAT}; +toc:
637
+ # Hash+ is expanded to +toc: true, toc_depth: N+.
638
+ #
639
+ # Semantically equivalent to
640
+ # +Options.new(overrides).to_native_hash_frozen+ but bypasses the
641
+ # Options object. Validation matches +[]=+ exactly—same
642
+ # ArgumentErrors for unknown keys, wrong types, unknown sub-keys,
643
+ # out-of-range toc depth, and unknown extract kinds.
644
+ #
645
+ # @api private
646
+ class << self
647
+ def native_hash_from(overrides)
648
+ overrides = overrides.to_h
649
+ preset_name = overrides[:preset] || DEFAULT_PRESET
650
+ cached = PRESETS_NATIVE_HASH[preset_name]
651
+ unless cached
652
+ raise ArgumentError,
653
+ "unknown preset: #{preset_name.inspect}; expected one of #{PRESETS.keys.inspect}"
654
+ end
655
+
656
+ # Fast-fast path: only :preset (or nothing) → return cached frozen hash.
657
+ non_preset_keys = overrides.size - (overrides.key?(:preset) ? 1 : 0)
658
+ return cached if non_preset_keys.zero?
659
+
660
+ h = cached.dup
661
+ toc_depth = h[:toc_depth]
662
+
663
+ overrides.each do |key, value|
664
+ next if key == :preset
665
+ validate_key!(key)
666
+ validate_value!(key, value)
667
+
668
+ case key
669
+ when :headings, :images, :links
670
+ # value is a Hash (validated); flatten each sub-key via
671
+ # NESTED_TO_FLAT. The flat-hash representation of the final
672
+ # state is equivalent to the deep-merged nested representation,
673
+ # since the preset-cached base already has all sub-keys present.
674
+ value.each do |sub_key, sub_value|
675
+ h[NESTED_TO_FLAT.fetch([key, sub_key])] = sub_value
676
+ end
677
+ when :toc
678
+ if value.is_a?(Hash)
679
+ h[:toc] = true
680
+ toc_depth = value[:depth]
681
+ else
682
+ h[:toc] = value
683
+ end
684
+ else
685
+ h[key] = value
686
+ end
687
+ end
688
+
689
+ if toc_depth.nil?
690
+ h.delete(:toc_depth)
691
+ else
692
+ h[:toc_depth] = toc_depth
693
+ end
694
+ h.freeze
695
+ end
696
+ end
697
+ end
698
+ end