Class: Inkmark::Options

Inherits:
Object
  • Object
show all
Defined in:
lib/inkmark/options.rb

Overview

Typed hash of Inkmark rendering options with a known key set. Unknown keys raise ArgumentError at every write path.

Nested policy hashes—:headings, :images, :links—group related options together and deep-merge over defaults when set, so users can tweak one sub-key without clobbering the others.

The meta :preset option (accepted in new and Options.native_hash_from) selects a named bundle from PRESETS applied before the rest of the overrides. :gfm is the default preset; see PRESETS for the full list.

Examples:

Preset + per-app overrides

Inkmark::Options.new(
  preset: :recommended,
  links:  { allowed_hosts: ["*.example.com"] }
)

Constant Summary collapse

HEADINGS_SCHEMA =

Per-element-policy schemas. Each entry is { default:, types: }; the validators use types for type checking and default to seed fresh nested hashes. Keep in sync with NESTED_TO_FLAT.

{
  attributes: {default: false, types: [TrueClass, FalseClass]},
  ids: {default: false, types: [TrueClass, FalseClass]}
}.freeze
IMAGES_SCHEMA =
{
  lazy: {default: false, types: [TrueClass, FalseClass]},
  allowed_hosts: {default: nil, types: [NilClass, Array]},
  allowed_schemes: {default: nil, types: [NilClass, Array]}
}.freeze
{
  autolink: {default: false, types: [TrueClass, FalseClass]},
  nofollow: {default: false, types: [TrueClass, FalseClass]},
  allowed_hosts: {default: nil, types: [NilClass, Array]},
  allowed_schemes: {default: nil, types: [NilClass, Array]}
}.freeze
NESTED_SCHEMAS =

Registry of nested hash options => their schemas. Iterated by the validator and native-hash flattener to keep the three element-policy groupings uniform.

{
  headings: HEADINGS_SCHEMA,
  images: IMAGES_SCHEMA,
  links: LINKS_SCHEMA
}.freeze
NESTED_TO_FLAT =

Map from (parent, child) user-facing keys to the flat key name the Rust side reads. Used by #to_native_hash / #to_native_hash_frozen to serialize the user-shaped hash into the FFI wire format.

{
  [:headings, :attributes] => :heading_attributes,
  [:headings, :ids] => :heading_ids,
  [:images, :lazy] => :lazy_images,
  [:images, :allowed_hosts] => :allowed_image_hosts,
  [:images, :allowed_schemes] => :allowed_image_schemes,
  [:links, :autolink] => :autolink,
  [:links, :nofollow] => :nofollow_external_links,
  [:links, :allowed_hosts] => :allowed_link_hosts,
  [:links, :allowed_schemes] => :allowed_link_schemes
}.freeze
DEFAULTS =

Default values for every option. Top-level keys are user-facing; nested element-policy groups (headings, images, links) hold their own default hashes built from NESTED_SCHEMAS.

{
  # GFM conformance bundle. Enables pulldown-cmark's ENABLE_GFM and the
  # four core GFM extensions. Individual extensions can still be toggled
  # off after setting gfm: true.
  gfm: true,

  # GFM "Disallowed Raw HTML" extension. When +gfm+ and +raw_html+ are
  # both true, escapes the leading +<+ of nine unsafe tag names
  # (title, textarea, style, xmp, iframe, noembed, noframes, script,
  # plaintext). Required for GFM conformance; no effect when
  # +raw_html+ is false.
  gfm_tag_filter: true,

  # GFM pipe tables with optional column-alignment markers.
  tables: true,

  # GFM strikethrough: +~~text~~+ → +<del>+.
  strikethrough: true,

  # GFM task lists: +- [ ]+ and +- [x]+ → disabled checkboxes.
  tasklists: true,

  # Footnote references and definitions.
  footnotes: true,

  # Pass raw HTML tags through unescaped. Off by default for
  # untrusted-input safety. When true, the caller is fully responsible
  # for sanitizing output—Inkmark does not sanitize beyond the narrow
  # GFM tagfilter. Always run the output through a dedicated sanitizer
  # (Sanitize, Loofah, rails-html-sanitizer) for untrusted content.
  raw_html: false,

  # Smart punctuation: ASCII quotes/dashes/ellipses → typographic forms.
  smart_punctuation: false,

  # Heading-related options. +:attributes+ enables +# Title {#id .klass}+
  # inline attribute syntax; +:ids+ auto-generates an +id+ on every
  # heading from its text (slug). User-supplied ids from +attributes+
  # are preserved when +:ids+ fills the rest in.
  headings: schema_defaults(HEADINGS_SCHEMA),

  # Image-related options. +:lazy+ adds +loading="lazy" decoding="async"+
  # to every +<img>+. +:allowed_hosts+ is a glob allowlist for
  # +<img src>+ hostnames (http/https); non-matching images drop to alt
  # text. +:allowed_schemes+ is a URL-scheme allowlist for image URLs.
  # Both allowlists default to +nil+ (no filtering); set +[]+ to
  # deny-all-external.
  images: schema_defaults(IMAGES_SCHEMA),

  # Link-related options. +:autolink+ turns bare URLs and emails into
  # clickable links. +:nofollow+ adds +rel="nofollow noopener"+ to
  # external +<a>+ tags. +:allowed_hosts+ / +:allowed_schemes+ are
  # glob / scheme allowlists for +<a href>+ (same semantics as the
  # image versions). Relative/anchor/mailto URLs are never filtered.
  links: schema_defaults(LINKS_SCHEMA),

  # Replace gemoji-style +:shortcode:+ sequences with the emoji
  # character. Unknown codes and codes inside code blocks are preserved.
  emoji_shortcodes: false,

  # Server-side syntax highlighting for fenced code blocks with a
  # language tag. Uses syntect with CSS class output—pair with
  # {Inkmark.highlight_css} for the theme stylesheet.
  syntax_highlight: false,

  # Treat every single newline in a paragraph as a hard line break
  # (+<br />+). Default is soft-break (single +\n+ → space).
  hard_wrap: false,

  # Collect a table of contents from headings. When set, {Inkmark#toc}
  # returns a {Inkmark::Toc} value object (+#to_markdown+ / +#to_html+ /
  # +#to_s+). Implicitly enables +headings[:ids]+ in the rendered HTML
  # so TOC anchor hrefs have matching targets. Also populates
  # {Inkmark#statistics} with +heading_count+.
  #
  # Accepts +true+ / +false+ for simple enable/disable, or a Hash with a
  # +:depth+ key to limit which heading levels appear in the rendered
  # TOC. +toc: { depth: 3 }+ renders h1–h3 only; +toc: {}+ or
  # +toc: true+ renders all levels. Depth filtering affects only the
  # rendered TOC; +heading_count+, +extracts[:headings]+, and
  # +chunks_by_heading+ still see every heading.
  toc: false,

  # Full document statistics: language detection, character/word counts,
  # and +*_count+ fields for headings, code blocks, images, links, and
  # footnote definitions. For structured arrays of records, use
  # {extract}. Implies +toc+ and +headings[:ids]+.
  statistics: false,

  # Opt into structured extraction of specific element kinds. Pass a
  # Hash with any of +:images+, +:links+, +:code_blocks+, +:headings+,
  # +:footnote_definitions+ set to +true+. {Inkmark#extracts} then
  # returns a Hash keyed by the requested kinds, each carrying an Array
  # of record Hashes with a +:byte_range+. +nil+ (default) disables
  # extraction. +extract: { headings: true }+ and +toc: true+ trigger
  # each other—one heading walk powers both surfaces.
  extract: nil,

  # Math: +$inline$+ and +$$display$$+ blocks → +<code class="language-math">+.
  math: false,

  # Definition lists: +term\n: definition+ → +<dl>+.
  definition_list: false,

  # Superscript: +^text^+ → +<sup>+.
  superscript: false,

  # Subscript: +~text~+ → +<sub>+ (conflicts with strikethrough—enable
  # only one).
  subscript: false,

  # Wiki-style links: +[[Page]]+ and +[[Page|label]]+ → +<a>+.
  wikilinks: false,

  # Frontmatter: YAML metadata at the start of the document. Recognized
  # +---\nkey: value\n---+ block is stripped from rendered output and
  # exposed as a Hash via {Inkmark#frontmatter}.
  frontmatter: false
}.freeze
TYPES =

Per-key class allowlist. Keys absent from this hash inherit their allowed classes from DEFAULTS: a boolean default allows TrueClass/FalseClass, a nil default allows NilClass, and so on. Entries here declare options whose default doesn’t fully describe the accepted type set (nil-default-but-Array-when-set, polymorphic toc, nested-hash element-policy groups).

{
  extract: [NilClass, Hash],
  toc: [TrueClass, FalseClass, Hash],
  headings: [Hash],
  images: [Hash],
  links: [Hash]
}.freeze
EXTRACT_KINDS =

Element kinds accepted inside extract: { … }. Mirrors the match arms in Rust stats::to_extracts_hash—changing one means changing the other.

%i[
  images
  links
  code_blocks
  headings
  footnote_definitions
].freeze
PRESETS =

Named bundles of option settings. Pass preset: :name in the options hash and the preset’s values are applied first; any other keys override (deep-merging for nested element-policy hashes).

  • :gfm (the default applied by #initialize) — CommonMark + core GFM extensions (tables, strikethrough, tasklists, footnotes, tagfilter). Conservative, matches the render profile of every other major GFM engine.

  • :commonmark — strict CommonMark, no GFM extensions.

  • :recommended — opinionated bundle for modern web content. Enables smart punctuation, auto heading IDs, lazy-loading images, autolinks, rel=“nofollow noopener” on external links, URL scheme allowlists for links and images, emoji shortcodes, syntax highlighting, hard wraps, and frontmatter. Recommended starting point for apps; override individual options to tune.

  • :trusted:recommended with raw HTML pass-through enabled. The GFM tagfilter stays on. Dangerous. Use only for content the caller fully trusts (internal team-authored docs). The caller is fully responsible for sanitizing output.

{
  commonmark: {
    gfm: false,
    gfm_tag_filter: false,
    tables: false,
    strikethrough: false,
    tasklists: false,
    footnotes: false
  }.freeze,

  gfm: {
    gfm: true,
    gfm_tag_filter: true,
    tables: true,
    strikethrough: true,
    tasklists: true,
    footnotes: true
  }.freeze,

  recommended: {
    gfm: true,
    gfm_tag_filter: true,
    tables: true,
    strikethrough: true,
    tasklists: true,
    footnotes: true,
    raw_html: false,
    smart_punctuation: true,
    headings: {attributes: false, ids: true},
    images: {lazy: true, allowed_schemes: ["http", "https"]},
    links: {autolink: true, nofollow: true, allowed_schemes: ["http", "https", "mailto"]},
    emoji_shortcodes: true,
    syntax_highlight: true,
    hard_wrap: true,
    frontmatter: true
  }.freeze,

  trusted: {
    gfm: true,
    gfm_tag_filter: true,
    tables: true,
    strikethrough: true,
    tasklists: true,
    footnotes: true,
    raw_html: true,
    smart_punctuation: true,
    headings: {attributes: false, ids: true},
    images: {lazy: true, allowed_schemes: ["http", "https"]},
    links: {autolink: true, nofollow: true, allowed_schemes: ["http", "https", "mailto"]},
    emoji_shortcodes: true,
    syntax_highlight: true,
    hard_wrap: true,
    frontmatter: true
  }.freeze
}.freeze
DEFAULT_PRESET =

Preset applied by #initialize when the caller doesn’t pass preset:. :gfm matches DEFAULTS, so the default constructor is equivalent to “CommonMark + core GFM, nothing else”.

:gfm

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(overrides = {}) ⇒ Options

Build a new Options instance with defaults from DEFAULTS plus any overrides applied on top. Nested hash values (for :headings, :images, :links) are deep-merged over the defaults—users only need to pass the sub-keys they care about.

Examples:

With defaults

Inkmark::Options.new[:tables]  #=> true

Deep-merge over nested defaults

opts = Inkmark::Options.new(images: { lazy: true })
opts[:images]  #=> { lazy: true, allowed_hosts: nil, allowed_schemes: nil }

Preset + override

opts = Inkmark::Options.new(preset: :recommended, smart_punctuation: false)
opts[:smart_punctuation]  #=> false  (override wins)
opts[:syntax_highlight]   #=> true   (kept from :recommended)

Parameters:

  • overrides (Hash{Symbol => Object}) (defaults to: {})

    option keys and values to override against the defaults. Every key must be present in DEFAULTS or ArgumentError is raised.

Options Hash (overrides):

  • :gfm (Boolean) — default: true

    GFM conformance mode + bundle-enable tables, strikethrough, tasklists, and footnotes.

  • :tables (Boolean) — default: true

    GFM pipe tables.

  • :strikethrough (Boolean) — default: true

    ~~text~~.

  • :tasklists (Boolean) — default: true

    - [ ] / - [x].

  • :footnotes (Boolean) — default: true

    [^1] / [^1]: body.

  • :raw_html (Boolean) — default: false

    Pass raw HTML through.

  • :smart_punctuation (Boolean) — default: false

    Typographic quotes/dashes/ellipses.

  • :headings (Hash) — default: {attributes: false, ids: false}

    Heading-related policy. Sub-keys: :attributes (inline {#id .klass} syntax), :ids (auto-generate slug ids).

  • :images (Hash) — default: {lazy: false, allowed_hosts: nil, allowed_schemes: nil}

    Image-related policy. Sub-keys: :lazy (+loading=“lazy”+), :allowed_hosts (glob allowlist), :allowed_schemes.

  • :links (Hash) — default: {autolink: false, nofollow: false, allowed_hosts: nil, allowed_schemes: nil}

    Link-related policy. Sub-keys: :autolink, :nofollow (external rel), :allowed_hosts, :allowed_schemes.

  • :emoji_shortcodes (Boolean) — default: false

    :rocket: → 🚀.

  • :syntax_highlight (Boolean) — default: false

    Server-side syntect highlighting for fenced code blocks.

  • :hard_wrap (Boolean) — default: false

    Every \n<br />.

  • :toc (Boolean, Hash) — default: false

    Collect TOC. true / {} includes all heading levels; { depth: N } limits to h1..hN (1..6).

  • :statistics (Boolean) — default: false

    Full document stats.

  • :extract (Hash, nil) — default: nil

    Structured element extraction. Keys: :images, :links, :code_blocks, :headings, :footnote_definitions.

  • :math (Boolean) — default: false
  • :definition_list (Boolean) — default: false
  • :superscript (Boolean) — default: false
  • :subscript (Boolean) — default: false
  • :wikilinks (Boolean) — default: false
  • :frontmatter (Boolean) — default: false

    Parse YAML frontmatter and expose via Inkmark#frontmatter.

  • :preset (Symbol) — default: :gfm

    Named bundle of option settings applied before the rest of overrides. See PRESETS for the available names. Every other key in overrides takes precedence over the preset (nested hashes deep-merge).

Raises:

  • (ArgumentError)

    if any key in overrides is unknown, any nested sub-key is unknown, any value has the wrong type, or preset: is not a known preset name.



362
363
364
365
366
367
# File 'lib/inkmark/options.rb', line 362

def initialize(overrides = {})
  @values = dup_with_nested(DEFAULTS)
  @toc_depth = nil
  @frozen_native_hash = nil
  apply_overrides!(overrides, default_preset: DEFAULT_PRESET)
end

Instance Attribute Details

#keyObject

Reader and writer for the key option. The writer routes through #[]= so key validation and (for nested groups) deep-merge apply uniformly.



474
475
476
477
# File 'lib/inkmark/options.rb', line 474

DEFAULTS.each_key do |key|
  define_method(key) { @values[key] }
  define_method("#{key}=") { |value| self[key] = value }
end

Class Method Details

.native_hash_from(overrides) ⇒ Object



647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
# File 'lib/inkmark/options.rb', line 647

def native_hash_from(overrides)
  overrides = overrides.to_h
  preset_name = overrides[:preset] || DEFAULT_PRESET
  cached = PRESETS_NATIVE_HASH[preset_name]
  unless cached
    raise ArgumentError,
      "unknown preset: #{preset_name.inspect}; expected one of #{PRESETS.keys.inspect}"
  end

  # Fast-fast path: only :preset (or nothing) → return cached frozen hash.
  non_preset_keys = overrides.size - (overrides.key?(:preset) ? 1 : 0)
  return cached if non_preset_keys.zero?

  h = cached.dup
  toc_depth = h[:toc_depth]

  overrides.each do |key, value|
    next if key == :preset
    validate_key!(key)
    validate_value!(key, value)

    case key
    when :headings, :images, :links
      # value is a Hash (validated); flatten each sub-key via
      # NESTED_TO_FLAT. The flat-hash representation of the final
      # state is equivalent to the deep-merged nested representation,
      # since the preset-cached base already has all sub-keys present.
      value.each do |sub_key, sub_value|
        h[NESTED_TO_FLAT.fetch([key, sub_key])] = sub_value
      end
    when :toc
      if value.is_a?(Hash)
        h[:toc] = true
        toc_depth = value[:depth]
      else
        h[:toc] = value
      end
    else
      h[key] = value
    end
  end

  if toc_depth.nil?
    h.delete(:toc_depth)
  else
    h[:toc_depth] = toc_depth
  end
  h.freeze
end

.schema_defaults(schema) ⇒ Object

Build a frozen defaults hash for a nested schema from its default entries.



69
70
71
# File 'lib/inkmark/options.rb', line 69

def self.schema_defaults(schema)
  schema.each_with_object({}) { |(k, v), h| h[k] = v[:default] }.freeze
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?

Compare by value equality (user-shaped view).



455
456
457
# File 'lib/inkmark/options.rb', line 455

def ==(other)
  other.class == self.class && to_h == other.to_h
end

#[](key) ⇒ Object

Read an option by key. Nested element-policy keys return the nested hash as a live reference—mutating it directly bypasses cache invalidation; prefer the setter.

Parameters:

Returns:

  • (Object)

    the current value for that key

Raises:

  • (KeyError)

    if key is not present in DEFAULTS



376
377
378
# File 'lib/inkmark/options.rb', line 376

def [](key)
  @values.fetch(key)
end

#[]=(key, value) ⇒ Object

Write an option by key. For nested element-policy keys (:headings, :images, :links) the hash is deep-merged over the current value, so callers may pass only the sub-keys they want to change.

Parameters:

  • key (Symbol)

    a key from DEFAULTS

  • value (Object)

    the new value

Returns:

  • (Object)

    the value that was written (post-merge for nested hashes; the input value as-is otherwise)

Raises:

  • (ArgumentError)

    if key is unknown, or the value (or any nested sub-value) has the wrong type



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/inkmark/options.rb', line 390

def []=(key, value)
  validate_key!(key)
  # Deep-merge partial nested-hash overrides (+:headings+,
  # +:images+, +:links+) so callers pass only the sub-keys they
  # care about; non-Hash values fall through to validate_value!
  # and raise there.
  value = @values[key].merge(value) if NESTED_SCHEMAS.key?(key) && value.is_a?(Hash)
  validate_value!(key, value)
  @values[key] = value
  # Sugar: +toc: { depth: N }+ normalizes to +toc: true+ plus the
  # depth stashed in +@toc_depth+ (not a user-facing option).
  if key == :toc && value.is_a?(Hash)
    @values[:toc] = true
    @toc_depth = value[:depth]
  end
  @frozen_native_hash = nil
end

#initialize_copy(orig) ⇒ Object

Duplicate this instance, deep-copying the internal values hash so the clone is fully independent from the original.



462
463
464
465
466
467
# File 'lib/inkmark/options.rb', line 462

def initialize_copy(orig)
  super
  @values = dup_with_nested(orig.instance_variable_get(:@values))
  @toc_depth = orig.instance_variable_get(:@toc_depth)
  @frozen_native_hash = nil
end

#merge(other) ⇒ Inkmark::Options

Return a new Options instance with other‘s values applied on top. Nested element-policy hashes deep-merge; top-level values replace. Accepts preset: on other to re-apply a named preset before the other overrides (unlike #initialize, no default preset is applied when other omits preset:—the receiver’s state is preserved).

Parameters:

Returns:



447
448
449
450
451
452
# File 'lib/inkmark/options.rb', line 447

def merge(other)
  other_hash = other.is_a?(Inkmark::Options) ? other.to_h : other
  merged = dup
  merged.send(:apply_overrides!, other_hash, default_preset: nil)
  merged
end

#to_hHash{Symbol => Object}

Return a plain user-shaped Hash copy of the current option values. Nested element-policy groups are returned as nested Hashes, mirroring the input shape accepted by #initialize.

Returns:

  • (Hash{Symbol => Object})


413
414
415
# File 'lib/inkmark/options.rb', line 413

def to_h
  dup_with_nested(@values)
end

#to_native_hashHash{Symbol => Object}

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Return a Rust-facing flat Hash: nested element-policy hashes are expanded into their flat Rust keys via NESTED_TO_FLAT, and the internal @toc_depth is injected when set. Used by the FFI layer.

Returns:

  • (Hash{Symbol => Object})

    fresh mutable hash; callers that add per-call params (truncate, window, etc.) mutate this hash



424
425
426
# File 'lib/inkmark/options.rb', line 424

def to_native_hash
  build_native_hash
end

#to_native_hash_frozenHash{Symbol => Object}

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Memoized frozen variant of #to_native_hash used by the hot-path FFI calls that don’t need to add per-call params. The cache is invalidated in #[]= and #initialize_copy.

Returns:

  • (Hash{Symbol => Object})

    frozen, shared across calls until a mutation invalidates it



435
436
437
# File 'lib/inkmark/options.rb', line 435

def to_native_hash_frozen
  @frozen_native_hash ||= build_native_hash.freeze
end