Module: Y::RenderRules

Defined in:
lib/y/rendering.rb

Overview

Custom render rules for Y::Lexical and Y::ProseMirror.

Both renderers accept a nodes: hash mapping a node type (Lexical's __type, ProseMirror's node name) to a rule; Y::ProseMirror also accepts marks:. A rule is consulted before the built-in schema, so it can add a custom node or override a built-in one.

The usual way in is the block form — one rules.node call per type, keyword options for markup-as-data, a Ruby block for logic (see Builder below). contains: says what's inside the node: :inline (formatted text, the default), :blocks (child block nodes), or :none (a leaf). The nodes:/marks: keywords take the same rules as plain hashes, for shipping rule sets as data.

Two kinds of rule:

  • Declarative (keyword options / a Hash): markup as data, rendered natively at full speed. Y::ProseMirror.new(doc) do |rules| rules.node "callout", tag: "aside", attrs: { "class" => ["callout callout--", :kind] }, contains: :blocks end tag is the element; attrs values are templates — a String literal, a Symbol referencing one of the node's stored attributes, or an Array mixing both (an attribute that resolves empty is omitted); text is a template for literal text content; void: true emits no closing tag; contains declares what lives inside the node and renders there: :inline (default) for formatted text, :blocks for child block nodes (a container), :none for a leaf.

  • Callback (a block; in the hash form, a callable or render: plus contains):

    Y::Lexical.new(doc) do |rules|
    rules.node "video_embed" do |node|
      %(<video src="#{ERB::Util.html_escape(node.attrs["src"])}"></video>)
    end
    end
    

    The block runs after the document read has finished (never while the document is locked) and receives a RenderRules::Node with the node's type, stored attributes, children already rendered to HTML, and child_types (its element/block children by type). Its return value is spliced in verbatim — it is trusted HTML, so escape any attribute values you interpolate.

Mark rules (ProseMirror only) are declarative: tag plus attrs templates whose Symbol refs resolve against the mark's own attributes. A custom mark wraps outside every built-in mark; several custom marks nest alphabetically. A rule for a built-in mark's stored name replaces its wrap (the markup changes, the semantics don't — an overridden code mark still excludes the other formatting).

Defined Under Namespace

Classes: Builder, Node

Class Method Summary collapse

Class Method Details

.compile(nodes, marks) ⇒ Object

Compile the user-facing config into [rules_json, callbacks]. Structural validation happens in the native parser, which raises ArgumentError.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/y/rendering.rb', line 70

def compile(nodes, marks)
  callbacks = {}
  spec = {}
  unless nodes.empty?
    spec["nodes"] = nodes.to_h do |type, rule|
      [type.to_s, compile_node(type.to_s, rule, callbacks)]
    end
  end
  unless marks.empty?
    spec["marks"] = marks.to_h do |name, rule|
      [name.to_s, compile_mark(name.to_s, rule)]
    end
  end
  [JSON.generate(spec), callbacks]
end

.compile_attrs(attrs) ⇒ Object



121
122
123
# File 'lib/y/rendering.rb', line 121

def compile_attrs(attrs)
  attrs.map { |name, template| [name.to_s, compile_parts(template)] }
end

.compile_declarative_node(rule) ⇒ Object



102
103
104
105
106
107
108
109
110
# File 'lib/y/rendering.rb', line 102

def compile_declarative_node(rule)
  compiled = {}
  compiled["tag"] = rule[:tag].to_s if rule[:tag]
  compiled["void"] = true if rule[:void]
  compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
  compiled["text"] = compile_parts(rule[:text]) if rule[:text]
  compiled["content"] = rule[:contains].to_s if rule[:contains]
  compiled
end

.compile_mark(name, rule) ⇒ Object

Raises:

  • (ArgumentError)


112
113
114
115
116
117
118
119
# File 'lib/y/rendering.rb', line 112

def compile_mark(name, rule)
  raise ArgumentError, "mark rule for #{name.inspect} must be a Hash" unless rule.is_a?(Hash)

  compiled = {}
  compiled["tag"] = rule[:tag].to_s if rule[:tag]
  compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
  compiled
end

.compile_node(type, rule, callbacks) ⇒ Object

Raises:

  • (ArgumentError)


86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/y/rendering.rb', line 86

def compile_node(type, rule, callbacks)
  if rule.respond_to?(:call)
    callbacks[type] = rule
    return { "callback" => true }
  end
  raise ArgumentError, "rule for #{type.inspect} must be a Hash or a callable" unless rule.is_a?(Hash)

  if rule[:render]
    callbacks[type] = rule[:render]
    compiled = { "callback" => true }
    compiled["content"] = rule[:contains].to_s if rule[:contains]
    return compiled
  end
  compile_declarative_node(rule)
end

.compile_parts(template) ⇒ Object

A template: String literal, Symbol attribute reference, or an Array of both.



127
128
129
130
131
132
133
134
# File 'lib/y/rendering.rb', line 127

def compile_parts(template)
  Array(template).map do |part|
    case part
    when Symbol then { "ref" => part.to_s }
    else { "lit" => part.to_s }
    end
  end
end

.escape_attr(value) ⇒ Object



145
146
147
# File 'lib/y/rendering.rb', line 145

def escape_attr(value)
  escape_text(value).gsub('"', "&quot;")
end

.escape_text(value) ⇒ Object

The escaping the native renderers use, for blocks that build markup from stored values. Text content escapes &, <, > (quotes stay literal, matching the browser serializer); attribute values also escape ". Prefer these over ERB::Util.html_escape when byte parity with editor output matters — html_escape also rewrites apostrophes.



141
142
143
# File 'lib/y/rendering.rb', line 141

def escape_text(value)
  value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
end

.splice(segments, callbacks) ⇒ Object

Resolve callback segments depth-first, so a callback's node.content is finished HTML even when callback nodes nest.



197
198
199
200
201
202
203
204
205
206
207
# File 'lib/y/rendering.rb', line 197

def splice(segments, callbacks)
  segments.map do |segment|
    next segment if segment.is_a?(String)

    type, attrs_json, content, child_types = segment
    node = Node.new(type: type, attrs: JSON.parse(attrs_json),
                    content: splice(content, callbacks),
                    child_types: child_types)
    callbacks.fetch(type).call(node).to_s
  end.join
end