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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +16 -0
- data/README.md +1166 -0
- data/lib/inkmark/3.3/inkmark.bundle +0 -0
- data/lib/inkmark/3.4/inkmark.bundle +0 -0
- data/lib/inkmark/4.0/inkmark.bundle +0 -0
- data/lib/inkmark/event.rb +342 -0
- data/lib/inkmark/native.rb +8 -0
- data/lib/inkmark/options.rb +698 -0
- data/lib/inkmark/toc.rb +40 -0
- data/lib/inkmark/version.rb +6 -0
- data/lib/inkmark.rb +711 -0
- data/sig/inkmark.rbs +219 -0
- metadata +178 -0
|
@@ -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
|