Class: Markbridge::Normalizer

Inherits:
Object
  • Object
show all
Defined in:
lib/markbridge/normalizer.rb,
lib/markbridge/normalizer/report.rb,
lib/markbridge/normalizer/walker.rb,
lib/markbridge/normalizer/rule_set.rb,
lib/markbridge/normalizer/text_projection.rb

Overview

Rewrites an AST so the renderer only gets markup the target format can express. It runs once, between parse and render. The default rules are CommonMark legality: no link inside a link, no block element inside an inline container, and an inline-only code span. Each match resolves to a strategy (+:keep+, :hoist_after, :unwrap, :textify, :drop, or a callable) that the Walker applies.

Discourse-specific policy (for example, moving an image out of a link) is not built in. A consumer adds those with #rule.

Examples:

The default, reused across conversions

Markbridge::Normalizer.shared_default

A customized normalizer

n = Markbridge::Normalizer.default
n.rule(parent: AST::Url, child: AST::Image, strategy: :hoist_after)
Markbridge.convert(input, format: :bbcode, normalize: n)

List what would change, without changing it

Markbridge::Normalizer.default.violations(ast) # => [...]

Defined Under Namespace

Modules: TextProjection Classes: Report, RuleSet, Walker

Constant Summary collapse

STRATEGIES =

The strategy symbols a rule may resolve to.

%i[keep hoist_after unwrap textify drop].freeze
INLINE_CONTAINERS =

Containers that hold inline content only: a link's text (CommonMark ยง6.3), and emphasis and heading content.

[
  AST::Url,
  AST::Bold,
  AST::Italic,
  AST::Strikethrough,
  AST::Underline,
  AST::Superscript,
  AST::Subscript,
  AST::Heading,
].freeze
BLOCK_NODES =

AST nodes the Discourse renderer prints as block-level Markdown (their output has blank lines around it). One inside an inline container breaks that container, so it is moved out. Spoiler and single-line Code stay inline and are not listed (Code is handled by KEEP_INLINE_CODE).

[
  AST::Quote,
  AST::Heading,
  AST::List,
  AST::ListItem,
  AST::Table,
  AST::TableRow,
  AST::TableCell,
  AST::Details,
  AST::Paragraph,
  AST::HorizontalRule,
  AST::Align,
  AST::Poll,
  AST::Event,
].freeze
KEEP_INLINE_CODE =

A code span may stay inside an inline container while it is on one line. A fenced or multi-line block is moved out. This matches RenderingInterface#block_context?: Code prints as a fenced block when a Text child has a newline (the language alone does not make it a block).

lambda do |_boundary, node|
  block = node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
  block ? :hoist_after : :keep
end

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(rule_set) ⇒ Normalizer

Returns a new instance of Normalizer.

Parameters:



119
120
121
# File 'lib/markbridge/normalizer.rb', line 119

def initialize(rule_set)
  @rules = rule_set
end

Class Method Details

.defaultNormalizer

A fresh, customizable normalizer with the default rules. Add more with #rule.

Returns:



83
84
85
# File 'lib/markbridge/normalizer.rb', line 83

def default
  new(build_rules)
end

.shared_defaultNormalizer

The default normalizer, built once and frozen, reused across conversions. #normalize and #violations keep no state on the instance, so one frozen instance is safe to reuse, also across threads.

Returns:

  • (Normalizer)

    the same frozen instance on every call



91
92
93
# File 'lib/markbridge/normalizer.rb', line 91

def shared_default
  @shared_default ||= default.freeze
end

Instance Method Details

#freezeObject



157
158
159
160
# File 'lib/markbridge/normalizer.rb', line 157

def freeze
  @rules.freeze
  super
end

#normalize(ast) ⇒ Array<Hash>

Rewrite ast in place so it satisfies the rules.

Parameters:

Returns:

  • (Array<Hash>)

    a report of what changed (empty when nothing did), one {parent:, child:, strategy:, count:} row per distinct change.



141
142
143
144
145
# File 'lib/markbridge/normalizer.rb', line 141

def normalize(ast)
  report = Report.new
  Walker.new(@rules, report).call(ast)
  report.to_a
end

#rule(parent:, child:, strategy:) ⇒ self

Add or override a rule. Chainable. A rule for a (parent, child) pair that already exists is replaced. Raises on a frozen (shared_default) instance; build a fresh one with default.

Parameters:

  • parent (Class)

    ancestor AST class

  • child (Class)

    contained AST class

  • strategy (Symbol, #call)

    one of STRATEGIES or a callable

Returns:

  • (self)


131
132
133
134
# File 'lib/markbridge/normalizer.rb', line 131

def rule(parent:, child:, strategy:)
  @rules.add(parent:, child:, strategy:)
  self
end

#violations(ast) ⇒ Array<Hash>

List the violations in ast without changing it.

Parameters:

Returns:

  • (Array<Hash>)

    {parent:, child:, strategy:} per occurrence



151
152
153
154
155
# File 'lib/markbridge/normalizer.rb', line 151

def violations(ast)
  found = []
  collect_violations(ast, EMPTY_STACK, found)
  found
end