From 40a22c227828bebbdd6bfec8740ee289a418b88e Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:10:55 -0500 Subject: [PATCH 1/9] Introduce TipTap::Schema as the node type registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of a schema-centric redesign: Schema holds name→class registrations; Registry becomes a thin façade over TipTap.default_schema. Document/Node.from_json accept an optional schema: keyword while preserving existing behavior for built-ins. --- .gitignore | 1 + CHANGELOG.md | 11 ++++ docs/schema.md | 77 ++++++++++++++++++++++++ lib/tip_tap.rb | 28 ++++++--- lib/tip_tap/has_content.rb | 7 ++- lib/tip_tap/nodes/text.rb | 2 +- lib/tip_tap/registerable.rb | 2 + lib/tip_tap/registry.rb | 19 +++--- lib/tip_tap/schema.rb | 54 +++++++++++++++++ spec/tip_tap/schema_spec.rb | 115 ++++++++++++++++++++++++++++++++++++ 10 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 docs/schema.md create mode 100644 lib/tip_tap/schema.rb create mode 100644 spec/tip_tap/schema_spec.rb diff --git a/.gitignore b/.gitignore index b04a8c8..1351611 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ # rspec failure tracking .rspec_status +/vendor/bundle/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ced5ec9..96ec0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## [Unreleased] +### Added + +- `TipTap::Schema` — first-class node type registry (register, lookup, `use`/`dup`). +- Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). +- `TipTap.default_schema` accessor; `TipTap.node_for` reads from it. +- Docs: `docs/schema.md`. + +### Changed + +- `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API). + ## [0.10.1] - 2025-11-21 - Add support for Table of Contents diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..8985a12 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,77 @@ +# TipTap::Schema + +`TipTap::Schema` is the canonical registry of node type names → Ruby classes. +Parsing (`Document.from_json` / `Node.from_json`) resolves each JSON `type` through a schema. + +## Default schema + +Loading the gem builds registrations into `TipTap.default_schema` via each node's +`self.type_name = "..."` (see `TipTap::Registerable`). Built-ins (`doc`, `paragraph`, +`text`, lists, tables, etc.) live there. + +```ruby +TipTap.default_schema.node_for("paragraph") # => TipTap::Nodes::Paragraph +TipTap.node_for("paragraph") # same, via default schema +``` + +## Registry compatibility + +`TipTap::Registry` is a thin façade over `TipTap.default_schema`: + +| Registry API | Delegates to | +| --- | --- | +| `Registry.register(name, klass)` | `TipTap.default_schema.register` | +| `Registry.node_for(name)` | `TipTap.default_schema.node_for` | +| `Registry.clear` | `TipTap.default_schema.clear` | +| `Registry.registry` | `TipTap.default_schema.nodes` | + +`Registry::MissingNodeError` is an alias of `Schema::MissingNodeError`. + +Prefer `Schema` for new code; keep using `Registry` if you already depend on it. + +## Custom nodes + +### Register on the default schema (global) + +```ruby +class MyCallout < TipTap::Node + self.type_name = "callout" # auto-registers on TipTap.default_schema + self.html_tag = :aside +end + +# or explicitly: +TipTap.default_schema.register("callout", MyCallout) +# TipTap::Registry.register("callout", MyCallout) # equivalent +``` + +### Isolated schema (recommended for app-specific nodes) + +```ruby +schema = TipTap.default_schema.dup +schema.register("callout", MyCallout) + +document = TipTap::Document.from_json(json, schema: schema) +``` + +Isolated schemas do not affect other parses that use the default schema. + +## Composing schemas + +```ruby +schema = TipTap::Schema.new +schema.use(TipTap.default_schema) # copy built-ins +schema.register("callout", MyCallout) + +# or an extension object: +module CalloutExtension + def self.register(schema) + schema.register("callout", MyCallout) + end +end + +schema.use(CalloutExtension) +``` + +## Marks + +Mark handling is unchanged in this phase; schema registration for marks comes later. diff --git a/lib/tip_tap.rb b/lib/tip_tap.rb index bed1d02..1a44541 100644 --- a/lib/tip_tap.rb +++ b/lib/tip_tap.rb @@ -1,7 +1,27 @@ # frozen_string_literal: true require_relative "tip_tap/version" +require "tip_tap/schema" require "tip_tap/registry" + +module TipTap + class Error < StandardError; end + + def self.default_schema + @default_schema ||= Schema.new + end + + def self.default_schema=(schema) + @default_schema = schema + end + + def self.node_for(name) + default_schema.node_for(name) + end +end + +# Nodes register themselves onto TipTap.default_schema via type_name=, +# so default_schema must exist before these requires. require "tip_tap/document" require "tip_tap/nodes/bullet_list" require "tip_tap/nodes/hard_break" @@ -20,11 +40,3 @@ require "tip_tap/nodes/table_row" require "tip_tap/nodes/table_cell" require "tip_tap/nodes/table_header" - -module TipTap - class Error < StandardError; end - - def self.node_for(name) - Registry.node_for(name) - end -end diff --git a/lib/tip_tap/has_content.rb b/lib/tip_tap/has_content.rb index 0ad51fb..69bac7b 100644 --- a/lib/tip_tap/has_content.rb +++ b/lib/tip_tap/has_content.rb @@ -47,15 +47,16 @@ def blank? module ClassMethods # Create a new instance from a TipTap JSON object. # All nodes are recursively parsed and converted to Ruby objects - # All nodes must be registered in the registry. + # All nodes must be registered in the schema (defaults to TipTap.default_schema). # @param json [Hash] The JSON object to parse - def from_json(json) + # @param schema [TipTap::Schema] Optional schema used to resolve node types + def from_json(json, schema: TipTap.default_schema) return new if json.nil? json.deep_stringify_keys! content = Array(json["content"]).map do |node| - TipTap.node_for(node["type"]).from_json(node) + schema.node_for(node["type"]).from_json(node, schema: schema) end new(content, **Hash(json["attrs"])) diff --git a/lib/tip_tap/nodes/text.rb b/lib/tip_tap/nodes/text.rb index 576d2e2..a585b36 100644 --- a/lib/tip_tap/nodes/text.rb +++ b/lib/tip_tap/nodes/text.rb @@ -17,7 +17,7 @@ def initialize(content, **attributes) yield self if block_given? end - def self.from_json(json) + def self.from_json(json, schema: TipTap.default_schema) json.deep_stringify_keys! new(json["text"], marks: Array(json["marks"])) diff --git a/lib/tip_tap/registerable.rb b/lib/tip_tap/registerable.rb index 1af993d..b735127 100644 --- a/lib/tip_tap/registerable.rb +++ b/lib/tip_tap/registerable.rb @@ -9,6 +9,8 @@ def self.included(base) end module ClassMethods + # Setting type_name registers this class on TipTap.default_schema + # (via the Registry compatibility façade). def type_name=(type_name) @type_name = type_name Registry.register(type_name, self) diff --git a/lib/tip_tap/registry.rb b/lib/tip_tap/registry.rb index e923258..fa11517 100644 --- a/lib/tip_tap/registry.rb +++ b/lib/tip_tap/registry.rb @@ -1,27 +1,28 @@ # frozen_string_literal: true -# This is the registry for all the nodes that TipTap knows about. -# It's a simple hash that maps TipTap JS node names to Ruby classes. -# Registering a new node is as simple as: -# TipTap::Registry.register('myNode', MyNode) +require "tip_tap/schema" + +# Compatibility façade over TipTap.default_schema. +# Prefer TipTap::Schema (and TipTap.default_schema) for new code. +# TipTap::Registry.register / node_for / clear / registry still work. module TipTap class Registry - MissingNodeError = Class.new(StandardError) + MissingNodeError = Schema::MissingNodeError def self.register(name, klass) - registry[name.to_s] = klass + TipTap.default_schema.register(name, klass) end def self.node_for(name) - registry.fetch(name.to_s) { raise MissingNodeError.new("Unknown node type: #{name}") } + TipTap.default_schema.node_for(name) end def self.clear - @registry = {} + TipTap.default_schema.clear end def self.registry - @registry ||= {} + TipTap.default_schema.nodes end end end diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb new file mode 100644 index 0000000..4b4effe --- /dev/null +++ b/lib/tip_tap/schema.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# TipTap::Schema is the canonical store for node type registrations. +# TipTap::Registry delegates to TipTap.default_schema for backward compatibility. +module TipTap + class Schema + MissingNodeError = Class.new(StandardError) + + attr_reader :nodes + + def initialize(nodes = {}) + @nodes = nodes.transform_keys(&:to_s) + end + + def register(name, klass) + nodes[name.to_s] = klass + end + + def node_for(name) + nodes.fetch(name.to_s) { raise MissingNodeError, "Unknown node type: #{name}" } + end + + def registered?(name) + nodes.key?(name.to_s) + end + + def clear + nodes.clear + end + + # Merge node registrations from another schema or from objects that + # respond to #register(schema). Returns self for chaining. + def use(*extensions) + extensions.each do |extension| + if extension.is_a?(Schema) + extension.nodes.each { |name, klass| register(name, klass) } + elsif extension.respond_to?(:register) + extension.register(self) + else + raise ArgumentError, "Extension must be a Schema or respond to #register" + end + end + self + end + + def dup + self.class.new(nodes.dup) + end + + def ==(other) + other.is_a?(Schema) && nodes == other.nodes + end + end +end diff --git a/spec/tip_tap/schema_spec.rb b/spec/tip_tap/schema_spec.rb new file mode 100644 index 0000000..2b15014 --- /dev/null +++ b/spec/tip_tap/schema_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "tip_tap" + +RSpec.describe TipTap::Schema do + describe "#register / #node_for" do + it "registers and looks up a custom node" do + schema = TipTap::Schema.new + schema.register("myCustomNode", String) + + expect(schema.node_for("myCustomNode")).to eq(String) + expect(schema.registered?("myCustomNode")).to eq(true) + end + + it "raises MissingNodeError for unknown types" do + schema = TipTap::Schema.new + + expect { schema.node_for("unknown") }.to raise_error(TipTap::Schema::MissingNodeError) + end + end + + describe "default schema" do + it "includes built-in node types" do + schema = TipTap.default_schema + + expect(schema.node_for("doc")).to eq(TipTap::Document) + expect(schema.node_for("paragraph")).to eq(TipTap::Nodes::Paragraph) + expect(schema.node_for("text")).to eq(TipTap::Nodes::Text) + expect(schema.node_for("heading")).to eq(TipTap::Nodes::Heading) + end + + it "is used by TipTap.node_for" do + expect(TipTap.node_for("paragraph")).to eq(TipTap::Nodes::Paragraph) + end + end + + describe "parsing with a custom schema" do + let(:custom_node_class) do + Class.new(TipTap::Node) do + self.html_tag = :div + + def self.type_name + "customBlock" + end + end + end + + it "parses JSON using the given schema without registering on the default schema" do + schema = TipTap.default_schema.dup + schema.register("customBlock", custom_node_class) + + json = { + type: "doc", + content: [ + { + type: "customBlock", + content: [ + {type: "text", text: "Hello custom"} + ] + } + ] + } + + document = TipTap::Document.from_json(json, schema: schema) + + expect(document.content.first).to be_a(custom_node_class) + expect(document.content.first.content.first).to be_a(TipTap::Nodes::Text) + expect(document.to_plain_text).to eq("Hello custom") + + expect { + TipTap::Document.from_json(json) + }.to raise_error(TipTap::Schema::MissingNodeError) + + expect(TipTap.default_schema.registered?("customBlock")).to eq(false) + end + end + + describe "#use" do + it "merges nodes from another schema" do + base = TipTap::Schema.new + base.register("paragraph", TipTap::Nodes::Paragraph) + + extra = TipTap::Schema.new + extra.register("text", TipTap::Nodes::Text) + + base.use(extra) + + expect(base.node_for("paragraph")).to eq(TipTap::Nodes::Paragraph) + expect(base.node_for("text")).to eq(TipTap::Nodes::Text) + end + + it "accepts an extension that responds to #register" do + schema = TipTap::Schema.new + extension = Object.new + def extension.register(schema) + schema.register("text", TipTap::Nodes::Text) + end + + schema.use(extension) + + expect(schema.node_for("text")).to eq(TipTap::Nodes::Text) + end + end + + describe "#dup" do + it "copies registrations without sharing the hash" do + original = TipTap.default_schema.dup + copy = original.dup + copy.register("onlyOnCopy", String) + + expect(copy.registered?("onlyOnCopy")).to eq(true) + expect(original.registered?("onlyOnCopy")).to eq(false) + end + end +end From 7c6a0ba6ba707b7940d626e33a53b111fa6a6d92 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:14:06 -0500 Subject: [PATCH 2/9] Extract TipTap marks into Schema-registered Mark types Phase 2: introduce TipTap::Mark and TipTap::Marks::* for bold, italic, underline, strike, code, link, textStyle, superscript, subscript, and highlight. Schema gains register_mark/mark_for; Text renders via the node schema's mark registry. Builder API and built-in HTML/Markdown output are unchanged. --- CHANGELOG.md | 7 +- docs/schema.md | 67 +++++++++++--- lib/tip_tap.rb | 18 +++- lib/tip_tap/mark.rb | 102 +++++++++++++++++++++ lib/tip_tap/marks/bold.rb | 21 +++++ lib/tip_tap/marks/code.rb | 27 ++++++ lib/tip_tap/marks/highlight.rb | 39 +++++++++ lib/tip_tap/marks/italic.rb | 21 +++++ lib/tip_tap/marks/link.rb | 46 ++++++++++ lib/tip_tap/marks/strike.rb | 21 +++++ lib/tip_tap/marks/subscript.rb | 21 +++++ lib/tip_tap/marks/superscript.rb | 21 +++++ lib/tip_tap/marks/text_style.rb | 23 +++++ lib/tip_tap/marks/underline.rb | 21 +++++ lib/tip_tap/nodes/text.rb | 146 +++++-------------------------- lib/tip_tap/registry.rb | 9 ++ lib/tip_tap/schema.rb | 28 ++++-- spec/tip_tap/marks/mark_spec.rb | 90 +++++++++++++++++++ spec/tip_tap/schema_spec.rb | 51 +++++++++++ 19 files changed, 634 insertions(+), 145 deletions(-) create mode 100644 lib/tip_tap/mark.rb create mode 100644 lib/tip_tap/marks/bold.rb create mode 100644 lib/tip_tap/marks/code.rb create mode 100644 lib/tip_tap/marks/highlight.rb create mode 100644 lib/tip_tap/marks/italic.rb create mode 100644 lib/tip_tap/marks/link.rb create mode 100644 lib/tip_tap/marks/strike.rb create mode 100644 lib/tip_tap/marks/subscript.rb create mode 100644 lib/tip_tap/marks/superscript.rb create mode 100644 lib/tip_tap/marks/text_style.rb create mode 100644 lib/tip_tap/marks/underline.rb create mode 100644 spec/tip_tap/marks/mark_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ec0f6..3bcd0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,16 @@ ### Added - `TipTap::Schema` — first-class node type registry (register, lookup, `use`/`dup`). +- `TipTap::Mark` and built-in mark classes (`TipTap::Marks::*`) registered on Schema. +- Schema mark API: `register_mark` / `mark_for` / `mark_registered?`; `TipTap.mark_for`. - Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). -- `TipTap.default_schema` accessor; `TipTap.node_for` reads from it. +- `TipTap.default_schema` accessor; `TipTap.node_for` / `TipTap.mark_for` read from it. - Docs: `docs/schema.md`. ### Changed -- `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API). +- `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API; optional `register_mark` / `mark_for`). +- `Text` HTML/Markdown rendering composes via schema-registered mark renderers (output for built-ins unchanged). ## [0.10.1] - 2025-11-21 diff --git a/docs/schema.md b/docs/schema.md index 8985a12..3fc363f 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -1,17 +1,21 @@ # TipTap::Schema -`TipTap::Schema` is the canonical registry of node type names → Ruby classes. -Parsing (`Document.from_json` / `Node.from_json`) resolves each JSON `type` through a schema. +`TipTap::Schema` is the canonical registry of **node** and **mark** type names → Ruby classes. +Parsing (`Document.from_json` / `Node.from_json`) resolves each JSON node `type` through a schema. +`Text` resolves mark `type` values through the same schema when rendering HTML/Markdown. ## Default schema -Loading the gem builds registrations into `TipTap.default_schema` via each node's -`self.type_name = "..."` (see `TipTap::Registerable`). Built-ins (`doc`, `paragraph`, -`text`, lists, tables, etc.) live there. +Loading the gem builds registrations into `TipTap.default_schema` via each node's or mark's +`self.type_name = "..."` . Built-in nodes (`doc`, `paragraph`, `text`, …) and marks +(`bold`, `italic`, `link`, …) live there. ```ruby TipTap.default_schema.node_for("paragraph") # => TipTap::Nodes::Paragraph -TipTap.node_for("paragraph") # same, via default schema +TipTap.node_for("paragraph") # same + +TipTap.default_schema.mark_for("bold") # => TipTap::Marks::Bold +TipTap.mark_for("bold") # same ``` ## Registry compatibility @@ -22,10 +26,12 @@ TipTap.node_for("paragraph") # same, via default schema | --- | --- | | `Registry.register(name, klass)` | `TipTap.default_schema.register` | | `Registry.node_for(name)` | `TipTap.default_schema.node_for` | -| `Registry.clear` | `TipTap.default_schema.clear` | +| `Registry.register_mark(name, klass)` | `TipTap.default_schema.register_mark` | +| `Registry.mark_for(name)` | `TipTap.default_schema.mark_for` | +| `Registry.clear` | `TipTap.default_schema.clear` (nodes **and** marks) | | `Registry.registry` | `TipTap.default_schema.nodes` | -`Registry::MissingNodeError` is an alias of `Schema::MissingNodeError`. +`Registry::MissingNodeError` / `MissingMarkError` alias the Schema error classes. Prefer `Schema` for new code; keep using `Registry` if you already depend on it. @@ -55,23 +61,58 @@ document = TipTap::Document.from_json(json, schema: schema) Isolated schemas do not affect other parses that use the default schema. +## Custom marks + +Marks subclass `TipTap::Mark` and implement `wrap_html` / `wrap_markdown`. +`html_priority` / `markdown_priority` control nesting order (lower = applied first = innermost). + +### Global registration + +```ruby +class Spoiler < TipTap::Mark + self.type_name = "spoiler" + self.html_priority = 55 + + def wrap_html(value) + content_tag(:span, value, class: "spoiler") + end + + def wrap_markdown(value, context: nil) + "||#{value}||" + end +end +``` + +### Isolated schema + +```ruby +schema = TipTap.default_schema.dup +schema.register_mark("spoiler", Spoiler) + +text = TipTap::Nodes::Text.new("secret", marks: [{type: "spoiler"}], schema: schema) +text.to_html # => secret + +# Builder API unchanged — marks stay TipTap-shaped hashes in to_h: +text.to_h # => { type: "text", text: "secret", marks: [{ type: "spoiler" }] } +``` + +Unknown mark types on a schema are ignored during rendering but still appear in `to_h`. + ## Composing schemas ```ruby schema = TipTap::Schema.new -schema.use(TipTap.default_schema) # copy built-ins +schema.use(TipTap.default_schema) # copy built-in nodes and marks schema.register("callout", MyCallout) +schema.register_mark("spoiler", Spoiler) # or an extension object: module CalloutExtension def self.register(schema) schema.register("callout", MyCallout) + schema.register_mark("spoiler", Spoiler) end end schema.use(CalloutExtension) ``` - -## Marks - -Mark handling is unchanged in this phase; schema registration for marks comes later. diff --git a/lib/tip_tap.rb b/lib/tip_tap.rb index 1a44541..ce211fb 100644 --- a/lib/tip_tap.rb +++ b/lib/tip_tap.rb @@ -3,6 +3,7 @@ require_relative "tip_tap/version" require "tip_tap/schema" require "tip_tap/registry" +require "tip_tap/mark" module TipTap class Error < StandardError; end @@ -18,10 +19,25 @@ def self.default_schema=(schema) def self.node_for(name) default_schema.node_for(name) end + + def self.mark_for(name) + default_schema.mark_for(name) + end end -# Nodes register themselves onto TipTap.default_schema via type_name=, +# Nodes and marks register themselves onto TipTap.default_schema via type_name=, # so default_schema must exist before these requires. +require "tip_tap/marks/bold" +require "tip_tap/marks/italic" +require "tip_tap/marks/underline" +require "tip_tap/marks/strike" +require "tip_tap/marks/code" +require "tip_tap/marks/link" +require "tip_tap/marks/text_style" +require "tip_tap/marks/superscript" +require "tip_tap/marks/subscript" +require "tip_tap/marks/highlight" + require "tip_tap/document" require "tip_tap/nodes/bullet_list" require "tip_tap/nodes/hard_break" diff --git a/lib/tip_tap/mark.rb b/lib/tip_tap/mark.rb new file mode 100644 index 0000000..01d7306 --- /dev/null +++ b/lib/tip_tap/mark.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "action_view" +require "uri" +require "active_support/core_ext/hash" +require "active_support/core_ext/object/blank" + +module TipTap + # Base class for TipTap marks (bold, link, highlight, …). + # Subclasses register on TipTap.default_schema via type_name=. + class Mark + include ActionView::Helpers::TextHelper + include ActionView::Helpers::AssetTagHelper + + attr_accessor :output_buffer + attr_reader :attrs + + def self.type_name=(name) + @type_name = name + TipTap.default_schema.register_mark(name, self) + end + + def self.type_name + @type_name + end + + def self.html_priority=(value) + @html_priority = value + end + + def self.html_priority + @html_priority || 100 + end + + def self.markdown_priority=(value) + @markdown_priority = value + end + + def self.markdown_priority + @markdown_priority || 100 + end + + # When true, Text#to_markdown applies only this mark (no escaping / other marks). + def self.exclusive_markdown? + false + end + + def self.from_hash(hash) + hash = hash.deep_stringify_keys + new(Hash(hash["attrs"])) + end + + def initialize(attrs = {}) + @attrs = Hash(attrs).deep_stringify_keys + end + + def type_name + self.class.type_name + end + + def to_h + data = {type: type_name} + data[:attrs] = attrs.deep_symbolize_keys if attrs.present? + data + end + + # Wrap already-rendered HTML (or plain text) with this mark's tags. + def wrap_html(value) + value + end + + # Wrap markdown source with this mark's syntax / HTML fallbacks. + def wrap_markdown(value, context: nil) + value + end + + protected + + def inline_style_content(styles) + return nil if styles.nil? || styles.empty? + + styles.reduce("") { |acc, val| acc + "#{val[0]}:#{val[1]};" } + end + + def wrap_with_html_tag(tag, value, attributes = {}, styles = {}) + attr_segments = [] + attributes.each do |key, attr_value| + next if attr_value.blank? + + attr_segments << "#{key}=\"#{escape_double_quotes(attr_value)}\"" + end + style_segment = inline_style_content(styles) + attr_segments << "style=\"#{escape_double_quotes(style_segment)}\"" if style_segment.present? + attributes_string = attr_segments.empty? ? "" : " #{attr_segments.join(" ")}" + "<#{tag}#{attributes_string}>#{value}" + end + + def escape_double_quotes(value) + value.to_s.gsub('"', """) + end + end +end diff --git a/lib/tip_tap/marks/bold.rb b/lib/tip_tap/marks/bold.rb new file mode 100644 index 0000000..1ed992b --- /dev/null +++ b/lib/tip_tap/marks/bold.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Bold < Mark + self.type_name = "bold" + self.html_priority = 70 + self.markdown_priority = 10 + + def wrap_html(value) + content_tag(:strong, value) + end + + def wrap_markdown(value, context: nil) + "**#{value}**" + end + end + end +end diff --git a/lib/tip_tap/marks/code.rb b/lib/tip_tap/marks/code.rb new file mode 100644 index 0000000..b3d7769 --- /dev/null +++ b/lib/tip_tap/marks/code.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Code < Mark + self.type_name = "code" + self.html_priority = 40 + self.markdown_priority = 0 + + def self.exclusive_markdown? + true + end + + def wrap_html(value) + content_tag(:code, value) + end + + def wrap_markdown(value, context: nil) + max_tick_sequence = value.scan(/`+/).map(&:length).max || 0 + wrapper = "`" * (max_tick_sequence + 1) + "#{wrapper}#{value}#{wrapper}" + end + end + end +end diff --git a/lib/tip_tap/marks/highlight.rb b/lib/tip_tap/marks/highlight.rb new file mode 100644 index 0000000..3a013d1 --- /dev/null +++ b/lib/tip_tap/marks/highlight.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Highlight < Mark + self.type_name = "highlight" + self.html_priority = 30 + self.markdown_priority = 50 + + def wrap_html(value) + data = {} + styles = {} + if color + data[:color] = color + styles["background-color"] = color + styles[:color] = "inherit" + end + content_tag(:mark, value, data: data, style: inline_style_content(styles)) + end + + def wrap_markdown(value, context: nil) + attributes = {} + styles = {} + if color + attributes["data-color"] = color + styles["background-color"] = color + styles["color"] = "inherit" + end + wrap_with_html_tag("mark", value, attributes, styles) + end + + def color + attrs["color"] + end + end + end +end diff --git a/lib/tip_tap/marks/italic.rb b/lib/tip_tap/marks/italic.rb new file mode 100644 index 0000000..b316c02 --- /dev/null +++ b/lib/tip_tap/marks/italic.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Italic < Mark + self.type_name = "italic" + self.html_priority = 60 + self.markdown_priority = 20 + + def wrap_html(value) + content_tag(:em, value) + end + + def wrap_markdown(value, context: nil) + "_#{value}_" + end + end + end +end diff --git a/lib/tip_tap/marks/link.rb b/lib/tip_tap/marks/link.rb new file mode 100644 index 0000000..65f0248 --- /dev/null +++ b/lib/tip_tap/marks/link.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Link < Mark + self.type_name = "link" + self.html_priority = 90 + self.markdown_priority = 90 + + def wrap_html(value) + content_tag(:a, value, href: href, target: target) + end + + def wrap_markdown(value, context: nil) + return value if href.blank? + + destination = escape_link_destination(href) + title_part = title.present? ? " \"#{escape_double_quotes(title)}\"" : "" + "[#{value}](#{destination}#{title_part})" + end + + def href + attrs["href"] + end + + def target + attrs["target"] + end + + def title + attrs["title"] + end + + private + + def escape_link_destination(url) + escaped = url.to_s.gsub("(", "\\(").gsub(")", "\\)") + escaped.gsub(/[\s]/) do |char| + (char == " ") ? "%20" : char + end + end + end + end +end diff --git a/lib/tip_tap/marks/strike.rb b/lib/tip_tap/marks/strike.rb new file mode 100644 index 0000000..c20c1a9 --- /dev/null +++ b/lib/tip_tap/marks/strike.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Strike < Mark + self.type_name = "strike" + self.html_priority = 80 + self.markdown_priority = 30 + + def wrap_html(value) + content_tag(:s, value) + end + + def wrap_markdown(value, context: nil) + "~~#{value}~~" + end + end + end +end diff --git a/lib/tip_tap/marks/subscript.rb b/lib/tip_tap/marks/subscript.rb new file mode 100644 index 0000000..532748e --- /dev/null +++ b/lib/tip_tap/marks/subscript.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Subscript < Mark + self.type_name = "subscript" + self.html_priority = 20 + self.markdown_priority = 80 + + def wrap_html(value) + content_tag(:sub, value) + end + + def wrap_markdown(value, context: nil) + wrap_with_html_tag("sub", value) + end + end + end +end diff --git a/lib/tip_tap/marks/superscript.rb b/lib/tip_tap/marks/superscript.rb new file mode 100644 index 0000000..e912438 --- /dev/null +++ b/lib/tip_tap/marks/superscript.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Superscript < Mark + self.type_name = "superscript" + self.html_priority = 10 + self.markdown_priority = 70 + + def wrap_html(value) + content_tag(:sup, value) + end + + def wrap_markdown(value, context: nil) + wrap_with_html_tag("sup", value) + end + end + end +end diff --git a/lib/tip_tap/marks/text_style.rb b/lib/tip_tap/marks/text_style.rb new file mode 100644 index 0000000..f42e26f --- /dev/null +++ b/lib/tip_tap/marks/text_style.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class TextStyle < Mark + self.type_name = "textStyle" + self.html_priority = 100 + self.markdown_priority = 60 + + def wrap_html(value) + content_tag(:span, value, style: inline_style_content(attrs)) + end + + def wrap_markdown(value, context: nil) + return value if attrs.empty? + + wrap_with_html_tag("span", value, {}, attrs) + end + end + end +end diff --git a/lib/tip_tap/marks/underline.rb b/lib/tip_tap/marks/underline.rb new file mode 100644 index 0000000..08b1e06 --- /dev/null +++ b/lib/tip_tap/marks/underline.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "tip_tap/mark" + +module TipTap + module Marks + class Underline < Mark + self.type_name = "underline" + self.html_priority = 50 + self.markdown_priority = 40 + + def wrap_html(value) + content_tag(:u, value) + end + + def wrap_markdown(value, context: nil) + wrap_with_html_tag("u", value) + end + end + end +end diff --git a/lib/tip_tap/nodes/text.rb b/lib/tip_tap/nodes/text.rb index a585b36..3135680 100644 --- a/lib/tip_tap/nodes/text.rb +++ b/lib/tip_tap/nodes/text.rb @@ -7,12 +7,13 @@ module Nodes class Text < Node # Allow the text to be set and accessed directly attr_accessor :text - attr_reader :marks + attr_reader :marks, :schema self.type_name = "text" def initialize(content, **attributes) @text = content + @schema = attributes[:schema] || TipTap.default_schema @marks = Array(attributes[:marks]).map(&:deep_stringify_keys) yield self if block_given? end @@ -20,7 +21,7 @@ def initialize(content, **attributes) def self.from_json(json, schema: TipTap.default_schema) json.deep_stringify_keys! - new(json["text"], marks: Array(json["marks"])) + new(json["text"], marks: Array(json["marks"]), schema: schema) end def to_h @@ -30,18 +31,7 @@ def to_h end def to_html - value = text - value = content_tag(:sup, value) if superscript? - value = content_tag(:sub, value) if subscript? - value = highlight_tag(value) if highlight? - value = content_tag(:code, value) if code? - value = content_tag(:u, value) if underline? - value = content_tag(:em, value) if italic? - value = content_tag(:strong, value) if bold? - value = content_tag(:s, value) if strike? - value = content_tag(:a, value, href: link_href, target: link_target) if link? - value = content_tag(:span, value, style: inline_style_content(text_styles)) if text_style? - value + apply_marks(text, :html) end def to_plain_text(separator: " ") @@ -54,19 +44,11 @@ def to_markdown(context = Markdown::Context.root) return value if context.within_code_block? - return wrap_with_backticks(value) if code? + exclusive = mark_objects.find { |mark| mark.class.exclusive_markdown? } + return exclusive.wrap_markdown(value, context: context) if exclusive value = escape_markdown(value) - value = apply_bold(value) if bold? - value = apply_italic(value) if italic? - value = apply_strike(value) if strike? - value = wrap_with_html_tag("u", value) if underline? - value = apply_highlight(value) if highlight? - value = apply_text_style(value) if text_style? - value = wrap_with_html_tag("sup", value) if superscript? - value = wrap_with_html_tag("sub", value) if subscript? - value = wrap_with_link(value) if link? - value + apply_marks(value, :markdown, context: context) end def italic? @@ -115,113 +97,31 @@ def has_mark_with_type?(type) marks.any? { |mark| mark["type"] == type } end - def link_href - marks.find { |mark| mark["type"] == "link" }&.dig("attrs", "href") - end - - def link_target - marks.find { |mark| mark["type"] == "link" }&.dig("attrs", "target") - end - - def text_styles - marks.find { |mark| mark["type"] == "textStyle" }&.dig("attrs") - end - - def highlight_color - marks.find { |mark| mark["type"] == "highlight" }&.dig("attrs", "color") - end - - def inline_style_content(styles) - return nil if styles.empty? - styles.reduce("") { |acc, val| acc + "#{val[0]}:#{val[1]};" } - end + def mark_objects + marks.filter_map do |mark_hash| + type = mark_hash["type"] + next unless schema.mark_registered?(type) - def highlight_tag(value) - data = {} - styles = {} - if highlight_color - data[:color] = highlight_color - styles["background-color"] = highlight_color - styles[:color] = "inherit" + schema.mark_for(type).from_hash(mark_hash) end - content_tag(:mark, text, data: data, style: inline_style_content(styles)) end - def escape_markdown(value) - value.gsub(/([\\`*_{}\[\]()#+!><~-])/) { |char| "\\#{char}" } - end - - def wrap_with_backticks(value) - max_tick_sequence = value.scan(/`+/).map(&:length).max || 0 - wrapper = "`" * (max_tick_sequence + 1) - "#{wrapper}#{value}#{wrapper}" - end - - def apply_bold(value) - "**#{value}**" - end - - def apply_italic(value) - "_#{value}_" - end - - def apply_strike(value) - "~~#{value}~~" - end - - def apply_highlight(value) - attributes = {} - styles = {} - if highlight_color - attributes["data-color"] = highlight_color - styles["background-color"] = highlight_color - styles["color"] = "inherit" + def apply_marks(value, format, context: nil) + sorted = mark_objects.sort_by do |mark| + (format == :html) ? mark.class.html_priority : mark.class.markdown_priority end - wrap_with_html_tag("mark", value, attributes, styles) - end - - def apply_text_style(value) - styles = text_styles || {} - return value if styles.empty? - wrap_with_html_tag("span", value, {}, styles) - end - - def wrap_with_link(value) - href = link_href.to_s - return value if href.blank? - - destination = escape_link_destination(href) - title = link_title - title_part = title.present? ? " \"#{escape_double_quotes(title)}\"" : "" - "[#{value}](#{destination}#{title_part})" - end - - def wrap_with_html_tag(tag, value, attributes = {}, styles = {}) - attr_segments = [] - attributes.each do |key, attr_value| - next if attr_value.blank? - attr_segments << "#{key}=\"#{escape_double_quotes(attr_value)}\"" - end - style_segment = inline_style_content(styles) - attr_segments << "style=\"#{escape_double_quotes(style_segment)}\"" if style_segment.present? - attributes_string = attr_segments.empty? ? "" : " #{attr_segments.join(" ")}" - "<#{tag}#{attributes_string}>#{value}" - end - - def escape_link_destination(href) - escaped = href.gsub("(", "\\(").gsub(")", "\\)") - escaped.gsub(/[\s]/) do |char| - (char == " ") ? "%20" : char + sorted.reduce(value) do |current, mark| + if format == :html + mark.wrap_html(current) + else + mark.wrap_markdown(current, context: context) + end end end - def escape_double_quotes(value) - value.to_s.gsub('"', """) - end - - def link_title - marks.find { |mark| mark["type"] == "link" }&.dig("attrs", "title") + def escape_markdown(value) + value.gsub(/([\\`*_{}\[\]()#+!><~-])/) { |char| "\\#{char}" } end end end diff --git a/lib/tip_tap/registry.rb b/lib/tip_tap/registry.rb index fa11517..f1395fb 100644 --- a/lib/tip_tap/registry.rb +++ b/lib/tip_tap/registry.rb @@ -8,6 +8,7 @@ module TipTap class Registry MissingNodeError = Schema::MissingNodeError + MissingMarkError = Schema::MissingMarkError def self.register(name, klass) TipTap.default_schema.register(name, klass) @@ -17,6 +18,14 @@ def self.node_for(name) TipTap.default_schema.node_for(name) end + def self.register_mark(name, klass) + TipTap.default_schema.register_mark(name, klass) + end + + def self.mark_for(name) + TipTap.default_schema.mark_for(name) + end + def self.clear TipTap.default_schema.clear end diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb index 4b4effe..77bee2a 100644 --- a/lib/tip_tap/schema.rb +++ b/lib/tip_tap/schema.rb @@ -1,15 +1,17 @@ # frozen_string_literal: true -# TipTap::Schema is the canonical store for node type registrations. +# TipTap::Schema is the canonical store for node and mark type registrations. # TipTap::Registry delegates to TipTap.default_schema for backward compatibility. module TipTap class Schema MissingNodeError = Class.new(StandardError) + MissingMarkError = Class.new(StandardError) - attr_reader :nodes + attr_reader :nodes, :marks - def initialize(nodes = {}) + def initialize(nodes = {}, marks = {}) @nodes = nodes.transform_keys(&:to_s) + @marks = marks.transform_keys(&:to_s) end def register(name, klass) @@ -24,16 +26,30 @@ def registered?(name) nodes.key?(name.to_s) end + def register_mark(name, klass) + marks[name.to_s] = klass + end + + def mark_for(name) + marks.fetch(name.to_s) { raise MissingMarkError, "Unknown mark type: #{name}" } + end + + def mark_registered?(name) + marks.key?(name.to_s) + end + def clear nodes.clear + marks.clear end - # Merge node registrations from another schema or from objects that + # Merge registrations from another schema or from objects that # respond to #register(schema). Returns self for chaining. def use(*extensions) extensions.each do |extension| if extension.is_a?(Schema) extension.nodes.each { |name, klass| register(name, klass) } + extension.marks.each { |name, klass| register_mark(name, klass) } elsif extension.respond_to?(:register) extension.register(self) else @@ -44,11 +60,11 @@ def use(*extensions) end def dup - self.class.new(nodes.dup) + self.class.new(nodes.dup, marks.dup) end def ==(other) - other.is_a?(Schema) && nodes == other.nodes + other.is_a?(Schema) && nodes == other.nodes && marks == other.marks end end end diff --git a/spec/tip_tap/marks/mark_spec.rb b/spec/tip_tap/marks/mark_spec.rb new file mode 100644 index 0000000..542da5e --- /dev/null +++ b/spec/tip_tap/marks/mark_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "tip_tap" + +RSpec.describe TipTap::Mark do + describe "built-in registration" do + it "registers all built-in marks on the default schema" do + schema = TipTap.default_schema + + { + "bold" => TipTap::Marks::Bold, + "italic" => TipTap::Marks::Italic, + "underline" => TipTap::Marks::Underline, + "strike" => TipTap::Marks::Strike, + "code" => TipTap::Marks::Code, + "link" => TipTap::Marks::Link, + "textStyle" => TipTap::Marks::TextStyle, + "superscript" => TipTap::Marks::Superscript, + "subscript" => TipTap::Marks::Subscript, + "highlight" => TipTap::Marks::Highlight + }.each do |name, klass| + expect(schema.mark_for(name)).to eq(klass) + expect(TipTap.mark_for(name)).to eq(klass) + end + end + end + + describe ".from_hash / #to_h" do + it "round-trips attrs" do + mark = TipTap::Marks::Link.from_hash( + "type" => "link", + "attrs" => {"href" => "https://example.com", "target" => "_blank"} + ) + + expect(mark.href).to eq("https://example.com") + expect(mark.to_h).to eq({ + type: "link", + attrs: {href: "https://example.com", target: "_blank"} + }) + end + end +end + +RSpec.describe "custom marks via Schema" do + let(:spoiler_mark) do + Class.new(TipTap::Mark) do + def self.type_name + "spoiler" + end + + self.html_priority = 55 + self.markdown_priority = 5 + + def wrap_html(value) + content_tag(:span, value, class: "spoiler") + end + + def wrap_markdown(value, context: nil) + "||#{value}||" + end + end + end + + it "renders a custom mark registered on an isolated schema" do + schema = TipTap.default_schema.dup + schema.register_mark("spoiler", spoiler_mark) + + node = TipTap::Nodes::Text.new( + "secret", + marks: [{type: "spoiler"}, {type: "bold"}], + schema: schema + ) + + expect(node.to_html).to eq('secret') + expect(node.to_markdown).to eq("**||secret||**") + expect(node.to_h[:marks]).to eq([{type: "spoiler"}, {type: "bold"}]) + + # Default schema does not know spoiler — only bold applies + default_node = TipTap::Nodes::Text.new( + "secret", + marks: [{type: "spoiler"}, {type: "bold"}] + ) + expect(default_node.to_html).to eq("secret") + expect(TipTap.default_schema.mark_registered?("spoiler")).to eq(false) + end + + it "exposes Registry mark helpers as a façade" do + expect(TipTap::Registry.mark_for("bold")).to eq(TipTap::Marks::Bold) + end +end diff --git a/spec/tip_tap/schema_spec.rb b/spec/tip_tap/schema_spec.rb index 2b15014..c61f2af 100644 --- a/spec/tip_tap/schema_spec.rb +++ b/spec/tip_tap/schema_spec.rb @@ -112,4 +112,55 @@ def extension.register(schema) expect(original.registered?("onlyOnCopy")).to eq(false) end end + + describe "#register_mark / #mark_for" do + it "registers and looks up a custom mark" do + schema = TipTap::Schema.new + schema.register_mark("myMark", String) + + expect(schema.mark_for("myMark")).to eq(String) + expect(schema.mark_registered?("myMark")).to eq(true) + end + + it "raises MissingMarkError for unknown types" do + schema = TipTap::Schema.new + + expect { schema.mark_for("unknown") }.to raise_error(TipTap::Schema::MissingMarkError) + end + end + + describe "default schema marks" do + it "includes built-in mark types" do + schema = TipTap.default_schema + + expect(schema.mark_for("bold")).to eq(TipTap::Marks::Bold) + expect(schema.mark_for("link")).to eq(TipTap::Marks::Link) + end + end + + describe "#dup with marks" do + it "copies mark registrations without sharing the hash" do + original = TipTap.default_schema.dup + copy = original.dup + copy.register_mark("onlyOnCopy", String) + + expect(copy.mark_registered?("onlyOnCopy")).to eq(true) + expect(original.mark_registered?("onlyOnCopy")).to eq(false) + end + end + + describe "#use with marks" do + it "merges marks from another schema" do + base = TipTap::Schema.new + base.register_mark("bold", TipTap::Marks::Bold) + + extra = TipTap::Schema.new + extra.register_mark("italic", TipTap::Marks::Italic) + + base.use(extra) + + expect(base.mark_for("bold")).to eq(TipTap::Marks::Bold) + expect(base.mark_for("italic")).to eq(TipTap::Marks::Italic) + end + end end From d6ff46b6fc6061bdf2606d999d8e4b4e594a8ac8 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:17:18 -0500 Subject: [PATCH 3/9] Add declarative parent_builder for child node construction Phase 3: node classes declare parent_builder to install builder methods on allowed parents at registration time. Migrate Document, ListItem, lists, tables, and related parents off duplicated hand-written builders. Text#text / Codeblock#code remain special-cased. --- CHANGELOG.md | 4 +- docs/schema.md | 28 ++++++ lib/tip_tap.rb | 29 ++++-- lib/tip_tap/document.rb | 52 +--------- lib/tip_tap/nodes/blockquote.rb | 6 +- lib/tip_tap/nodes/bullet_list.rb | 9 +- lib/tip_tap/nodes/codeblock.rb | 3 + lib/tip_tap/nodes/heading.rb | 2 + lib/tip_tap/nodes/image.rb | 2 + lib/tip_tap/nodes/list_item.rb | 27 +----- lib/tip_tap/nodes/ordered_list.rb | 9 +- lib/tip_tap/nodes/paragraph.rb | 8 ++ lib/tip_tap/nodes/table.rb | 6 +- lib/tip_tap/nodes/table_cell.rb | 6 +- lib/tip_tap/nodes/table_header.rb | 6 +- lib/tip_tap/nodes/table_row.rb | 12 +-- lib/tip_tap/nodes/task_item.rb | 2 + lib/tip_tap/nodes/task_list.rb | 9 +- lib/tip_tap/parent_buildable.rb | 135 ++++++++++++++++++++++++++ lib/tip_tap/registerable.rb | 5 +- lib/tip_tap/schema.rb | 1 + spec/tip_tap/parent_buildable_spec.rb | 70 +++++++++++++ 22 files changed, 301 insertions(+), 130 deletions(-) create mode 100644 lib/tip_tap/parent_buildable.rb create mode 100644 spec/tip_tap/parent_buildable_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcd0c4..b268e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,14 @@ - Schema mark API: `register_mark` / `mark_for` / `mark_registered?`; `TipTap.mark_for`. - Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). - `TipTap.default_schema` accessor; `TipTap.node_for` / `TipTap.mark_for` read from it. -- Docs: `docs/schema.md`. +- `TipTap::ParentBuildable` / `parent_builder` — declarative builders on parent nodes when a child type is registered. +- Docs: `docs/schema.md` (nodes, marks, parent builders). ### Changed - `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API; optional `register_mark` / `mark_for`). - `Text` HTML/Markdown rendering composes via schema-registered mark renderers (output for built-ins unchanged). +- Built-in child builders (Document, ListItem, lists, tables, …) come from `parent_builder` declarations on child classes. ## [0.10.1] - 2025-11-21 diff --git a/docs/schema.md b/docs/schema.md index 3fc363f..5fd5e50 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -116,3 +116,31 @@ end schema.use(CalloutExtension) ``` + + +## Parent builders + +Child node classes declare which parents get a builder method via `parent_builder`. +Registration (`type_name=` / `Schema#register`) installs those methods onto the parents +through an included module (idempotent; no hand-edited parent source required). + +```ruby +class Callout < TipTap::Node + self.type_name = "callout" + self.html_tag = :aside + + parent_builder on: TipTap::Document, as: :callout, require_block: true + # args: {level: 1} → default kwargs + # args: [:src] → required keywords +end + +document.callout { |c| … } +``` + +Parents may be Class objects or fully qualified strings (for load-order safety). +`TipTap.install_parent_builders!` runs at gem load to resolve forward references. + +### Still special-cased (not parent_builder) + +- `Paragraph#text` / `Heading#text` — positional text + marks +- `Codeblock#code` — builds a `Text` node with a code mark diff --git a/lib/tip_tap.rb b/lib/tip_tap.rb index ce211fb..9e3f1f6 100644 --- a/lib/tip_tap.rb +++ b/lib/tip_tap.rb @@ -23,10 +23,17 @@ def self.node_for(name) def self.mark_for(name) default_schema.mark_for(name) end + + # Re-run parent_builder installation after all node classes are loaded + # (declarations may reference parents that were not defined yet). + def self.install_parent_builders! + default_schema.nodes.values.uniq.each do |klass| + klass.install_parent_builders! if klass.respond_to?(:install_parent_builders!) + end + end end -# Nodes and marks register themselves onto TipTap.default_schema via type_name=, -# so default_schema must exist before these requires. +# Marks register themselves onto TipTap.default_schema via type_name=. require "tip_tap/marks/bold" require "tip_tap/marks/italic" require "tip_tap/marks/underline" @@ -38,21 +45,25 @@ def self.mark_for(name) require "tip_tap/marks/subscript" require "tip_tap/marks/highlight" +# Nodes register themselves and declare parent_builder onto parents. +# Order is mostly free-form; install_parent_builders! finalizes forward refs. require "tip_tap/document" -require "tip_tap/nodes/bullet_list" +require "tip_tap/nodes/text" require "tip_tap/nodes/hard_break" -require "tip_tap/nodes/heading" require "tip_tap/nodes/horizontal_rule" -require "tip_tap/nodes/list_item" -require "tip_tap/nodes/ordered_list" require "tip_tap/nodes/paragraph" -require "tip_tap/nodes/task_item" -require "tip_tap/nodes/task_list" -require "tip_tap/nodes/text" +require "tip_tap/nodes/heading" require "tip_tap/nodes/image" require "tip_tap/nodes/blockquote" require "tip_tap/nodes/codeblock" +require "tip_tap/nodes/bullet_list" +require "tip_tap/nodes/ordered_list" +require "tip_tap/nodes/task_list" +require "tip_tap/nodes/list_item" +require "tip_tap/nodes/task_item" require "tip_tap/nodes/table" require "tip_tap/nodes/table_row" require "tip_tap/nodes/table_cell" require "tip_tap/nodes/table_header" + +TipTap.install_parent_builders! diff --git a/lib/tip_tap/document.rb b/lib/tip_tap/document.rb index 91c507b..3ffd7d1 100644 --- a/lib/tip_tap/document.rb +++ b/lib/tip_tap/document.rb @@ -4,60 +4,12 @@ # This is the class that all child nodes will be added to. # This is the root object for TipTap. +# Child builder methods (paragraph, heading, …) are installed via +# each child node's `parent_builder` declaration. module TipTap class Document < Node self.type_name = "doc" self.html_tag = :div self.html_class_name = "tiptap-document" - - def heading(level: 1, &block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::Heading.new(level: level, &block)) - end - - def paragraph(&block) - add_content(Nodes::Paragraph.new(&block)) - end - - def task_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::TaskList.new(&block)) - end - - def bullet_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::BulletList.new(&block)) - end - - def ordered_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::OrderedList.new(&block)) - end - - def image(src:) - add_content(Nodes::Image.new(src: src)) - end - - def blockquote(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::Blockquote.new(&block)) - end - - def codeblock(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::Codeblock.new(&block)) - end - - def table(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Nodes::Table.new(&block)) - end end end diff --git a/lib/tip_tap/nodes/blockquote.rb b/lib/tip_tap/nodes/blockquote.rb index 73a6646..3d6cb02 100644 --- a/lib/tip_tap/nodes/blockquote.rb +++ b/lib/tip_tap/nodes/blockquote.rb @@ -9,11 +9,7 @@ class Blockquote < Node self.html_tag = :blockquote self.html_class_name = "blockquote" - def paragraph(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Paragraph.new(&block)) - end + parent_builder on: TipTap::Document, as: :blockquote, require_block: true def to_markdown(context = Markdown::Context.root) inner = super(context) diff --git a/lib/tip_tap/nodes/bullet_list.rb b/lib/tip_tap/nodes/bullet_list.rb index 9baf406..e1b4614 100644 --- a/lib/tip_tap/nodes/bullet_list.rb +++ b/lib/tip_tap/nodes/bullet_list.rb @@ -9,11 +9,10 @@ class BulletList < Node self.html_tag = :ul self.html_class_name = "bullet-list" - def list_item(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(ListItem.new(&block)) - end + parent_builder on: [ + TipTap::Document, + "TipTap::Nodes::ListItem" + ], as: :bullet_list, require_block: true def to_markdown(context = Markdown::Context.root) content.map { |node| node.to_markdown(context, marker: "- ") }.join("\n") diff --git a/lib/tip_tap/nodes/codeblock.rb b/lib/tip_tap/nodes/codeblock.rb index ad55826..4a22b62 100644 --- a/lib/tip_tap/nodes/codeblock.rb +++ b/lib/tip_tap/nodes/codeblock.rb @@ -8,6 +8,9 @@ class Codeblock < Node self.type_name = "codeBlock" self.html_tag = :pre + parent_builder on: TipTap::Document, as: :codeblock, require_block: true + + # Special-cased: builds a Text node with a code mark (not a child node type builder). def code(text) add_content(Text.new(text, marks: [{type: "code"}])) end diff --git a/lib/tip_tap/nodes/heading.rb b/lib/tip_tap/nodes/heading.rb index 240306c..ea65a53 100644 --- a/lib/tip_tap/nodes/heading.rb +++ b/lib/tip_tap/nodes/heading.rb @@ -8,6 +8,8 @@ class Heading < Node self.type_name = "heading" self.html_tag = proc { "h#{level}" } + parent_builder on: TipTap::Document, as: :heading, require_block: true, args: {level: 1} + def initialize(content = [], **attributes) super(content, **attributes) uuid = SecureRandom.uuid diff --git a/lib/tip_tap/nodes/image.rb b/lib/tip_tap/nodes/image.rb index c46bdbf..86b199b 100644 --- a/lib/tip_tap/nodes/image.rb +++ b/lib/tip_tap/nodes/image.rb @@ -7,6 +7,8 @@ module Nodes class Image < Node self.type_name = "image" + parent_builder on: TipTap::Document, as: :image, args: [:src] + def include_empty_content_in_json? false end diff --git a/lib/tip_tap/nodes/list_item.rb b/lib/tip_tap/nodes/list_item.rb index 5235667..117d077 100644 --- a/lib/tip_tap/nodes/list_item.rb +++ b/lib/tip_tap/nodes/list_item.rb @@ -9,29 +9,10 @@ class ListItem < Node self.html_tag = :li self.html_class_name = "list-item" - def paragraph(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Paragraph.new(&block)) - end - - def bullet_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(BulletList.new(&block)) - end - - def ordered_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(OrderedList.new(&block)) - end - - def task_list(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(TaskList.new(&block)) - end + parent_builder on: [ + "TipTap::Nodes::BulletList", + "TipTap::Nodes::OrderedList" + ], as: :list_item, require_block: true def to_markdown(context = Markdown::Context.root, marker: "- ") marker_length = marker.length diff --git a/lib/tip_tap/nodes/ordered_list.rb b/lib/tip_tap/nodes/ordered_list.rb index bb9eeaa..ce84b8a 100644 --- a/lib/tip_tap/nodes/ordered_list.rb +++ b/lib/tip_tap/nodes/ordered_list.rb @@ -9,11 +9,10 @@ class OrderedList < Node self.html_tag = :ol self.html_class_name = "ordered-list" - def list_item(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(ListItem.new(&block)) - end + parent_builder on: [ + TipTap::Document, + "TipTap::Nodes::ListItem" + ], as: :ordered_list, require_block: true def start attrs["start"] diff --git a/lib/tip_tap/nodes/paragraph.rb b/lib/tip_tap/nodes/paragraph.rb index b47728a..b2e4d5a 100644 --- a/lib/tip_tap/nodes/paragraph.rb +++ b/lib/tip_tap/nodes/paragraph.rb @@ -8,6 +8,14 @@ class Paragraph < Node self.type_name = "paragraph" self.html_tag = :p + # Document allows an empty paragraph (no block); other parents require one. + parent_builder on: TipTap::Document, as: :paragraph + parent_builder on: [ + "TipTap::Nodes::ListItem", + "TipTap::Nodes::Blockquote", + "TipTap::Nodes::TableCell" + ], as: :paragraph, require_block: true + def text(text, marks: []) add_content(Text.new(text, marks: marks)) end diff --git a/lib/tip_tap/nodes/table.rb b/lib/tip_tap/nodes/table.rb index 62b1d23..efb7529 100644 --- a/lib/tip_tap/nodes/table.rb +++ b/lib/tip_tap/nodes/table.rb @@ -8,11 +8,7 @@ class Table < Node self.type_name = "table" self.html_tag = :table - def table_row(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(TableRow.new(&block)) - end + parent_builder on: TipTap::Document, as: :table, require_block: true def to_markdown(context = Markdown::Context.root) rows_data = content.map { |row| row.to_markdown_row(context) } diff --git a/lib/tip_tap/nodes/table_cell.rb b/lib/tip_tap/nodes/table_cell.rb index 0430abb..38121af 100644 --- a/lib/tip_tap/nodes/table_cell.rb +++ b/lib/tip_tap/nodes/table_cell.rb @@ -8,11 +8,7 @@ class TableCell < Node self.type_name = "tableCell" self.html_tag = :td - def paragraph(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Paragraph.new(&block)) - end + parent_builder on: "TipTap::Nodes::TableRow", as: :table_cell, require_block: true def to_markdown(context = Markdown::Context.root) values = content.map { |node| node.to_markdown(context) }.reject(&:blank?) diff --git a/lib/tip_tap/nodes/table_header.rb b/lib/tip_tap/nodes/table_header.rb index f4b2103..348d606 100644 --- a/lib/tip_tap/nodes/table_header.rb +++ b/lib/tip_tap/nodes/table_header.rb @@ -8,11 +8,7 @@ class TableHeader < TableCell self.type_name = "tableHeader" self.html_tag = :th - def paragraph(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(Paragraph.new(&block)) - end + parent_builder on: "TipTap::Nodes::TableRow", as: :table_header, require_block: true end end end diff --git a/lib/tip_tap/nodes/table_row.rb b/lib/tip_tap/nodes/table_row.rb index 5b7f7f0..c7578c0 100644 --- a/lib/tip_tap/nodes/table_row.rb +++ b/lib/tip_tap/nodes/table_row.rb @@ -8,17 +8,7 @@ class TableRow < Node self.type_name = "tableRow" self.html_tag = :tr - def table_cell(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(TableCell.new(&block)) - end - - def table_header(&block) - raise ArgumentError, "Block required" if block.nil? - - add_content(TableHeader.new(&block)) - end + parent_builder on: "TipTap::Nodes::Table", as: :table_row, require_block: true def to_markdown(context = Markdown::Context.root) row_data = to_markdown_row(context) diff --git a/lib/tip_tap/nodes/task_item.rb b/lib/tip_tap/nodes/task_item.rb index a4094c3..ef3166e 100644 --- a/lib/tip_tap/nodes/task_item.rb +++ b/lib/tip_tap/nodes/task_item.rb @@ -9,6 +9,8 @@ class TaskItem < ListItem self.html_tag = :li self.html_class_name = proc { class_names("task-item", {checked: checked?}) } + parent_builder on: "TipTap::Nodes::TaskList", as: :task_item, require_block: true, args: {checked: false} + def checked? attrs["checked"] end diff --git a/lib/tip_tap/nodes/task_list.rb b/lib/tip_tap/nodes/task_list.rb index 23d0a35..7a0ab62 100644 --- a/lib/tip_tap/nodes/task_list.rb +++ b/lib/tip_tap/nodes/task_list.rb @@ -9,11 +9,10 @@ class TaskList < Node self.html_tag = :ul self.html_class_name = "task-list" - def task_item(checked: false, &block) - raise ArgumentError, "Block required" if block.nil? - - add_content(TaskItem.new(checked: checked, &block)) - end + parent_builder on: [ + TipTap::Document, + "TipTap::Nodes::ListItem" + ], as: :task_list, require_block: true def to_markdown(context = Markdown::Context.root) content.map { |node| node.to_markdown(context) }.join("\n") diff --git a/lib/tip_tap/parent_buildable.rb b/lib/tip_tap/parent_buildable.rb new file mode 100644 index 0000000..c39aba5 --- /dev/null +++ b/lib/tip_tap/parent_buildable.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/inflections" + +module TipTap + # Declares builder methods on parent node classes when a child node type is registered. + # + # class Paragraph < Node + # parent_builder on: TipTap::Document, as: :paragraph + # parent_builder on: [ListItem, Blockquote], as: :paragraph, require_block: true + # end + # + # class Heading < Node + # parent_builder on: TipTap::Document, as: :heading, require_block: true, args: {level: 1} + # end + # + # class Image < Node + # parent_builder on: TipTap::Document, as: :image, args: [:src] + # end + module ParentBuildable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def parent_builders + @parent_builders ||= [] + end + + # @param on [Class, String, Array] parent class(es) that receive the builder + # @param as [Symbol, String] method name on the parent (default: demodulized underscore name) + # @param require_block [Boolean] raise ArgumentError when no block is given + # @param args [Hash, Array] default kwargs (Hash) and/or required keyword names (Array) + def parent_builder(on:, as: nil, require_block: false, args: {}) + method_name = (as || name.demodulize.underscore).to_sym + defaults, required_keywords = normalize_builder_args(args) + + parent_builders << { + parents: Array(on), + method_name: method_name, + require_block: require_block, + defaults: defaults, + required_keywords: required_keywords + } + + install_parent_builders! + end + + def install_parent_builders! + parent_builders.each do |declaration| + declaration[:parents].each do |parent| + parent_class = resolve_parent_class(parent) + next if parent_class.nil? + + ParentBuilderInstaller.install( + parent_class, + child_class: self, + method_name: declaration[:method_name], + require_block: declaration[:require_block], + defaults: declaration[:defaults], + required_keywords: declaration[:required_keywords] + ) + end + end + end + + private + + def normalize_builder_args(args) + case args + when Hash + [args.transform_keys(&:to_sym), []] + when Array + [{}, args.map(&:to_sym)] + else + raise ArgumentError, "args must be a Hash of defaults or an Array of required keywords" + end + end + + def resolve_parent_class(parent) + case parent + when Class + parent + when String, Symbol + parent.to_s.constantize + end + rescue NameError + nil + end + end + end + + # Installs generated builder methods onto a parent via an included module (idempotent). + module ParentBuilderInstaller + MODULE_NAME = :TipTapParentBuilders + + module_function + + def install(parent_class, child_class:, method_name:, require_block:, defaults:, required_keywords:) + mod = builder_module_for(parent_class) + owners = mod.instance_variable_get(:@tiptap_builder_owners) || {} + return if owners[method_name] == child_class + + owners[method_name] = child_class + mod.instance_variable_set(:@tiptap_builder_owners, owners) + + child = child_class + needs_block = require_block + default_attrs = defaults.dup.freeze + required = required_keywords.dup.freeze + + mod.define_method(method_name) do |**kwargs, &block| + raise ArgumentError, "Block required" if needs_block && block.nil? + + missing = required.reject { |key| kwargs.key?(key) } + if missing.any? + label = (missing.size == 1) ? "missing keyword" : "missing keywords" + raise ArgumentError, "#{label}: #{missing.map(&:inspect).join(", ")}" + end + + add_content(child.new(**default_attrs.merge(kwargs), &block)) + end + + parent_class.include(mod) unless parent_class.included_modules.include?(mod) + end + + def builder_module_for(parent_class) + if parent_class.const_defined?(MODULE_NAME, false) + parent_class.const_get(MODULE_NAME, false) + else + parent_class.const_set(MODULE_NAME, Module.new) + end + end + end +end diff --git a/lib/tip_tap/registerable.rb b/lib/tip_tap/registerable.rb index b735127..1fac671 100644 --- a/lib/tip_tap/registerable.rb +++ b/lib/tip_tap/registerable.rb @@ -1,19 +1,22 @@ # frozen_string_literal: true require "tip_tap/registry" +require "tip_tap/parent_buildable" module TipTap module Registerable def self.included(base) base.extend(ClassMethods) + base.include(ParentBuildable) end module ClassMethods # Setting type_name registers this class on TipTap.default_schema - # (via the Registry compatibility façade). + # (via the Registry compatibility façade) and installs parent builders. def type_name=(type_name) @type_name = type_name Registry.register(type_name, self) + install_parent_builders! if respond_to?(:install_parent_builders!) end def type_name diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb index 77bee2a..0610f9c 100644 --- a/lib/tip_tap/schema.rb +++ b/lib/tip_tap/schema.rb @@ -16,6 +16,7 @@ def initialize(nodes = {}, marks = {}) def register(name, klass) nodes[name.to_s] = klass + klass.install_parent_builders! if klass.respond_to?(:install_parent_builders!) end def node_for(name) diff --git a/spec/tip_tap/parent_buildable_spec.rb b/spec/tip_tap/parent_buildable_spec.rb new file mode 100644 index 0000000..34b39e2 --- /dev/null +++ b/spec/tip_tap/parent_buildable_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "tip_tap" + +RSpec.describe TipTap::ParentBuildable do + describe "built-in builders via parent_builder" do + it "installs Document builders from child declarations" do + document = TipTap::Document.new do |doc| + doc.paragraph { |p| p.text("Hi") } + doc.heading(level: 2) { |h| h.text("Title") } + doc.image(src: "https://example.com/a.png") + end + + expect(document.content.map(&:class)).to eq([ + TipTap::Nodes::Paragraph, + TipTap::Nodes::Heading, + TipTap::Nodes::Image + ]) + expect(document.content[1].level).to eq(2) + expect(document.content[2].src).to eq("https://example.com/a.png") + end + + it "preserves Document#paragraph without a required block" do + document = TipTap::Document.new.tap(&:paragraph) + expect(document.content.first).to be_a(TipTap::Nodes::Paragraph) + expect(document.blank?).to eq(true) + end + + it "requires a block for ListItem#paragraph" do + item = TipTap::Nodes::ListItem.new + expect { item.paragraph }.to raise_error(ArgumentError, "Block required") + end + + it "requires src for Document#image" do + document = TipTap::Document.new + expect { document.image }.to raise_error(ArgumentError, /missing keyword/) + end + end + + describe "custom node with parent_builder" do + let(:callout_class) do + Class.new(TipTap::Node) do + self.html_tag = :aside + self.html_class_name = "callout" + + def self.name + "TipTap::Nodes::Callout" + end + + parent_builder on: TipTap::Document, as: :callout, require_block: true + + def self.type_name + "callout" + end + end + end + + it "adds a builder on Document without monkey-patching Document's source" do + TipTap.default_schema.register("callout", callout_class) + # parent_builder already ran at class definition; register also reinstalls + + document = TipTap::Document.new do |doc| + doc.callout { |node| node } + end + + expect(document.content.first).to be_a(callout_class) + expect { TipTap::Document.new.callout }.to raise_error(ArgumentError, "Block required") + end + end +end From 1a9347f8233123c33b2068806c4b517eec3a8245 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:19:28 -0500 Subject: [PATCH 4/9] Add opt-in unknown node passthrough for JSON parsing Default remains MissingNodeError. Schema#unknown_node and from_json(..., unknown_node:) accept :passthrough to preserve unregistered types as TipTap::Nodes::Unknown for to_h round-trips, recursively parsing nested known and unknown children. --- CHANGELOG.md | 1 + docs/schema.md | 20 ++++ lib/tip_tap.rb | 1 + lib/tip_tap/has_content.rb | 13 ++- lib/tip_tap/nodes/text.rb | 2 +- lib/tip_tap/nodes/unknown.rb | 47 +++++++++ lib/tip_tap/schema.rb | 40 +++++++- spec/tip_tap/nodes/unknown_spec.rb | 159 +++++++++++++++++++++++++++++ 8 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 lib/tip_tap/nodes/unknown.rb create mode 100644 spec/tip_tap/nodes/unknown_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b268e0e..cb94fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). - `TipTap.default_schema` accessor; `TipTap.node_for` / `TipTap.mark_for` read from it. - `TipTap::ParentBuildable` / `parent_builder` — declarative builders on parent nodes when a child type is registered. +- `Schema#unknown_node` / `from_json(..., unknown_node:)` — opt-in `:passthrough` via `TipTap::Nodes::Unknown` (default remains `:raise`). - Docs: `docs/schema.md` (nodes, marks, parent builders). ### Changed diff --git a/docs/schema.md b/docs/schema.md index 5fd5e50..87ffea9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -144,3 +144,23 @@ Parents may be Class objects or fully qualified strings (for load-order safety). - `Paragraph#text` / `Heading#text` — positional text + marks - `Codeblock#code` — builds a `Text` node with a code mark + + +## Unknown nodes + +By default, parsing raises `Schema::MissingNodeError` for unregistered types (backward compatible). + +Opt into passthrough so unknown nodes are kept as `TipTap::Nodes::Unknown` (original type, +attrs, and recursively parsed content) for `to_h` round-trips: + +```ruby +# Schema-level +schema = TipTap.default_schema.dup +schema.unknown_node = :passthrough # or :raise (default) +document = TipTap::Document.from_json(json, schema: schema) + +# Per-call override (does not mutate the schema) +document = TipTap::Document.from_json(json, unknown_node: :passthrough) +``` + +`Unknown#to_html` / `#to_markdown` / `#to_plain_text` render **children only** (no wrapper tag). diff --git a/lib/tip_tap.rb b/lib/tip_tap.rb index 9e3f1f6..6ebdb7d 100644 --- a/lib/tip_tap.rb +++ b/lib/tip_tap.rb @@ -49,6 +49,7 @@ def self.install_parent_builders! # Order is mostly free-form; install_parent_builders! finalizes forward refs. require "tip_tap/document" require "tip_tap/nodes/text" +require "tip_tap/nodes/unknown" require "tip_tap/nodes/hard_break" require "tip_tap/nodes/horizontal_rule" require "tip_tap/nodes/paragraph" diff --git a/lib/tip_tap/has_content.rb b/lib/tip_tap/has_content.rb index 69bac7b..799717d 100644 --- a/lib/tip_tap/has_content.rb +++ b/lib/tip_tap/has_content.rb @@ -46,17 +46,20 @@ def blank? module ClassMethods # Create a new instance from a TipTap JSON object. - # All nodes are recursively parsed and converted to Ruby objects - # All nodes must be registered in the schema (defaults to TipTap.default_schema). + # All nodes are recursively parsed and converted to Ruby objects. # @param json [Hash] The JSON object to parse - # @param schema [TipTap::Schema] Optional schema used to resolve node types - def from_json(json, schema: TipTap.default_schema) + # @param schema [TipTap::Schema] Schema used to resolve node types + # @param unknown_node [Symbol, nil] Override schema policy: :raise or :passthrough + def from_json(json, schema: TipTap.default_schema, unknown_node: nil) return new if json.nil? json.deep_stringify_keys! + policy = unknown_node.nil? ? schema.unknown_node : unknown_node + content = Array(json["content"]).map do |node| - schema.node_for(node["type"]).from_json(node, schema: schema) + klass = schema.resolve_node_class(node["type"], policy: policy) + klass.from_json(node, schema: schema, unknown_node: policy) end new(content, **Hash(json["attrs"])) diff --git a/lib/tip_tap/nodes/text.rb b/lib/tip_tap/nodes/text.rb index 3135680..77033c9 100644 --- a/lib/tip_tap/nodes/text.rb +++ b/lib/tip_tap/nodes/text.rb @@ -18,7 +18,7 @@ def initialize(content, **attributes) yield self if block_given? end - def self.from_json(json, schema: TipTap.default_schema) + def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil) json.deep_stringify_keys! new(json["text"], marks: Array(json["marks"]), schema: schema) diff --git a/lib/tip_tap/nodes/unknown.rb b/lib/tip_tap/nodes/unknown.rb new file mode 100644 index 0000000..d18e548 --- /dev/null +++ b/lib/tip_tap/nodes/unknown.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "tip_tap/node" + +module TipTap + module Nodes + # Passthrough for unregistered TipTap node types when the schema + # (or from_json call) uses unknown_node: :passthrough. + # Preserves original type, attrs, and recursively parsed content for to_h. + class Unknown < Node + attr_reader :original_type + + def initialize(content = [], **attributes) + @original_type = ( + attributes.delete(:original_type) || attributes.delete("original_type") + ).to_s + super(content, **attributes) + end + + def type_name + original_type + end + + def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil) + json.deep_stringify_keys! + + policy = unknown_node.nil? ? schema.unknown_node : unknown_node + content = Array(json["content"]).map do |node| + klass = schema.resolve_node_class(node["type"], policy: policy) + klass.from_json(node, schema: schema, unknown_node: policy) + end + + new(content, original_type: json["type"], **Hash(json["attrs"])) + end + + # Render children only — no inventing a wrapper tag for unknown types. + def to_html + safe_join(content.map(&:to_html)) + end + + def to_markdown(context = Markdown::Context.root) + rendered = content.map { |node| node.to_markdown(context) }.reject(&:blank?) + rendered.join("\n\n") + end + end + end +end diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb index 0610f9c..127e5e7 100644 --- a/lib/tip_tap/schema.rb +++ b/lib/tip_tap/schema.rb @@ -7,11 +7,23 @@ class Schema MissingNodeError = Class.new(StandardError) MissingMarkError = Class.new(StandardError) - attr_reader :nodes, :marks + UNKNOWN_NODE_POLICIES = %i[raise passthrough].freeze - def initialize(nodes = {}, marks = {}) + attr_reader :nodes, :marks, :unknown_node + + def initialize(nodes = {}, marks = {}, unknown_node: :raise) @nodes = nodes.transform_keys(&:to_s) @marks = marks.transform_keys(&:to_s) + self.unknown_node = unknown_node + end + + def unknown_node=(policy) + policy = policy.to_sym + unless UNKNOWN_NODE_POLICIES.include?(policy) + raise ArgumentError, "unknown_node must be one of #{UNKNOWN_NODE_POLICIES.join(", ")} (got #{policy.inspect})" + end + + @unknown_node = policy end def register(name, klass) @@ -20,7 +32,22 @@ def register(name, klass) end def node_for(name) - nodes.fetch(name.to_s) { raise MissingNodeError, "Unknown node type: #{name}" } + resolve_node_class(name, policy: unknown_node) + end + + # Resolve a node class for +name+ using the given unknown-node policy. + # :raise (default) → MissingNodeError; :passthrough → Nodes::Unknown. + def resolve_node_class(name, policy: unknown_node) + nodes.fetch(name.to_s) do + case policy.to_sym + when :passthrough + Nodes::Unknown + when :raise + raise MissingNodeError, "Unknown node type: #{name}" + else + raise ArgumentError, "unknown_node must be one of #{UNKNOWN_NODE_POLICIES.join(", ")} (got #{policy.inspect})" + end + end end def registered?(name) @@ -61,11 +88,14 @@ def use(*extensions) end def dup - self.class.new(nodes.dup, marks.dup) + self.class.new(nodes.dup, marks.dup, unknown_node: unknown_node) end def ==(other) - other.is_a?(Schema) && nodes == other.nodes && marks == other.marks + other.is_a?(Schema) && + nodes == other.nodes && + marks == other.marks && + unknown_node == other.unknown_node end end end diff --git a/spec/tip_tap/nodes/unknown_spec.rb b/spec/tip_tap/nodes/unknown_spec.rb new file mode 100644 index 0000000..01181f8 --- /dev/null +++ b/spec/tip_tap/nodes/unknown_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "tip_tap" + +RSpec.describe TipTap::Nodes::Unknown do + let(:unknown_json) do + { + type: "doc", + content: [ + { + type: "paragraph", + content: [{type: "text", text: "Known"}] + }, + { + type: "customWidget", + attrs: {widget_id: 42}, + content: [ + { + type: "paragraph", + content: [{type: "text", text: "Inside unknown"}] + } + ] + } + ] + } + end + + describe "default :raise policy" do + it "raises MissingNodeError for unregistered types" do + expect { + TipTap::Document.from_json(unknown_json) + }.to raise_error(TipTap::Schema::MissingNodeError, /customWidget/) + end + + it "keeps Registry::MissingNodeError as an alias" do + expect { + TipTap::Document.from_json(unknown_json) + }.to raise_error(TipTap::Registry::MissingNodeError) + end + end + + describe "schema.unknown_node = :passthrough" do + let(:schema) do + TipTap.default_schema.dup.tap { |s| s.unknown_node = :passthrough } + end + + it "preserves unknown nodes for to_h round-trip" do + document = TipTap::Document.from_json(unknown_json, schema: schema) + + expect(document.content.size).to eq(2) + expect(document.content.first).to be_a(TipTap::Nodes::Paragraph) + unknown = document.content.last + expect(unknown).to be_a(TipTap::Nodes::Unknown) + expect(unknown.original_type).to eq("customWidget") + expect(unknown.attrs["widgetId"]).to eq(42) + expect(unknown.content.first).to be_a(TipTap::Nodes::Paragraph) + + expect(document.to_h).to eq({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{type: "text", text: "Known"}] + }, + { + type: "customWidget", + content: [ + { + type: "paragraph", + content: [{type: "text", text: "Inside unknown"}] + } + ], + attrs: {widgetId: 42} + } + ] + }) + end + + it "renders HTML/markdown/plain from children only" do + document = TipTap::Document.from_json(unknown_json, schema: schema) + unknown = document.content.last + + expect(unknown.to_html).to eq("

Inside unknown

") + expect(unknown.to_markdown).to eq("Inside unknown") + expect(unknown.to_plain_text).to eq("Inside unknown") + expect(document.to_plain_text).to include("Known", "Inside unknown") + end + end + + describe "from_json(unknown_node: :passthrough)" do + it "opts in without mutating the schema" do + schema = TipTap.default_schema.dup + expect(schema.unknown_node).to eq(:raise) + + document = TipTap::Document.from_json(unknown_json, schema: schema, unknown_node: :passthrough) + + expect(document.content.last).to be_a(TipTap::Nodes::Unknown) + expect(schema.unknown_node).to eq(:raise) + + expect { + TipTap::Document.from_json(unknown_json, schema: schema) + }.to raise_error(TipTap::Schema::MissingNodeError) + end + end + + describe "nested unknown nodes" do + it "passthroughs unknown parents and children with the same policy" do + json = { + type: "doc", + content: [ + { + type: "outerUnknown", + content: [ + { + type: "innerUnknown", + attrs: {flag: true}, + content: [ + {type: "text", text: "Deep"} + ] + }, + { + type: "paragraph", + content: [{type: "text", text: "Sibling"}] + } + ] + } + ] + } + + document = TipTap::Document.from_json(json, unknown_node: :passthrough) + outer = document.content.first + expect(outer).to be_a(TipTap::Nodes::Unknown) + expect(outer.original_type).to eq("outerUnknown") + + inner = outer.content.first + expect(inner).to be_a(TipTap::Nodes::Unknown) + expect(inner.original_type).to eq("innerUnknown") + expect(inner.content.first).to be_a(TipTap::Nodes::Text) + expect(outer.content.last).to be_a(TipTap::Nodes::Paragraph) + + expect(document.to_h[:content].first[:type]).to eq("outerUnknown") + expect(document.to_h[:content].first[:content].first[:type]).to eq("innerUnknown") + expect(document.to_html).to eq('
Deep

Sibling

') + end + end + + describe "Schema#unknown_node=" do + it "rejects invalid policies" do + schema = TipTap::Schema.new + expect { schema.unknown_node = :skip }.to raise_error(ArgumentError, /passthrough/) + end + + it "is copied by dup" do + schema = TipTap.default_schema.dup + schema.unknown_node = :passthrough + expect(schema.dup.unknown_node).to eq(:passthrough) + end + end +end From 7b7704a98a30a5a4bac5c39a01dd04a25a689c77 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:20:42 -0500 Subject: [PATCH 5/9] Add parent_builder for HardBreak and HorizontalRule Expose paragraph/heading#hard_break and document#horizontal_rule as declarative leaf builders so registered types match the rest of the parent_builder migration. --- CHANGELOG.md | 1 + docs/schema.md | 5 +++ lib/tip_tap/nodes/hard_break.rb | 5 +++ lib/tip_tap/nodes/horizontal_rule.rb | 2 + spec/tip_tap/nodes/hard_break_spec.rb | 44 ++++++++++++++++++++++ spec/tip_tap/nodes/horizontal_rule_spec.rb | 27 +++++++++++++ 6 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb94fe8..b4b50e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API; optional `register_mark` / `mark_for`). - `Text` HTML/Markdown rendering composes via schema-registered mark renderers (output for built-ins unchanged). - Built-in child builders (Document, ListItem, lists, tables, …) come from `parent_builder` declarations on child classes. +- `HardBreak` / `HorizontalRule` expose `hard_break` (Paragraph, Heading) and `horizontal_rule` (Document) builders. ## [0.10.1] - 2025-11-21 diff --git a/docs/schema.md b/docs/schema.md index 87ffea9..7634189 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -140,6 +140,11 @@ document.callout { |c| … } Parents may be Class objects or fully qualified strings (for load-order safety). `TipTap.install_parent_builders!` runs at gem load to resolve forward references. +Leaf builders (no block): + +- `paragraph.hard_break` / `heading.hard_break` → `hardBreak` +- `document.horizontal_rule` → `horizontalRule` + ### Still special-cased (not parent_builder) - `Paragraph#text` / `Heading#text` — positional text + marks diff --git a/lib/tip_tap/nodes/hard_break.rb b/lib/tip_tap/nodes/hard_break.rb index f6cde2a..5d89c94 100644 --- a/lib/tip_tap/nodes/hard_break.rb +++ b/lib/tip_tap/nodes/hard_break.rb @@ -7,6 +7,11 @@ module Nodes class HardBreak < Node self.type_name = "hardBreak" + parent_builder on: [ + "TipTap::Nodes::Paragraph", + "TipTap::Nodes::Heading" + ], as: :hard_break + def include_empty_content_in_json? false end diff --git a/lib/tip_tap/nodes/horizontal_rule.rb b/lib/tip_tap/nodes/horizontal_rule.rb index c522bf9..3364e1b 100644 --- a/lib/tip_tap/nodes/horizontal_rule.rb +++ b/lib/tip_tap/nodes/horizontal_rule.rb @@ -7,6 +7,8 @@ module Nodes class HorizontalRule < Node self.type_name = "horizontalRule" + parent_builder on: TipTap::Document, as: :horizontal_rule + def include_empty_content_in_json? false end diff --git a/spec/tip_tap/nodes/hard_break_spec.rb b/spec/tip_tap/nodes/hard_break_spec.rb index f58b333..a8992ed 100644 --- a/spec/tip_tap/nodes/hard_break_spec.rb +++ b/spec/tip_tap/nodes/hard_break_spec.rb @@ -18,4 +18,48 @@ expect(node.to_h).to eq({type: "hardBreak"}) end end + + describe "parent_builder" do + it "adds hard_break on Paragraph and Heading" do + paragraph = TipTap::Nodes::Paragraph.new do |p| + p.text("Hello") + p.hard_break + p.text("World") + end + + expect(paragraph.content.map(&:class)).to eq([ + TipTap::Nodes::Text, + TipTap::Nodes::HardBreak, + TipTap::Nodes::Text + ]) + expect(paragraph.to_h).to eq({ + type: "paragraph", + content: [ + {type: "text", text: "Hello"}, + {type: "hardBreak"}, + {type: "text", text: "World"} + ] + }) + + round_trip = TipTap::Nodes::Paragraph.from_json(paragraph.to_h) + expect(round_trip.to_h).to eq(paragraph.to_h) + + heading = TipTap::Nodes::Heading.new(level: 1) do |h| + h.text("Title") + h.hard_break + h.text("Line 2") + end + + expect(heading.content.map(&:class)).to eq([ + TipTap::Nodes::Text, + TipTap::Nodes::HardBreak, + TipTap::Nodes::Text + ]) + expect(heading.to_h[:content]).to eq([ + {type: "text", text: "Title"}, + {type: "hardBreak"}, + {type: "text", text: "Line 2"} + ]) + end + end end diff --git a/spec/tip_tap/nodes/horizontal_rule_spec.rb b/spec/tip_tap/nodes/horizontal_rule_spec.rb index b309ab8..5793d52 100644 --- a/spec/tip_tap/nodes/horizontal_rule_spec.rb +++ b/spec/tip_tap/nodes/horizontal_rule_spec.rb @@ -18,4 +18,31 @@ expect(node.to_h).to eq({type: "horizontalRule"}) end end + + describe "parent_builder" do + it "adds horizontal_rule on Document" do + document = TipTap::Document.new do |doc| + doc.paragraph { |p| p.text("Above") } + doc.horizontal_rule + doc.paragraph { |p| p.text("Below") } + end + + expect(document.content.map(&:class)).to eq([ + TipTap::Nodes::Paragraph, + TipTap::Nodes::HorizontalRule, + TipTap::Nodes::Paragraph + ]) + expect(document.to_h).to eq({ + type: "doc", + content: [ + {type: "paragraph", content: [{type: "text", text: "Above"}]}, + {type: "horizontalRule"}, + {type: "paragraph", content: [{type: "text", text: "Below"}]} + ] + }) + + round_trip = TipTap::Document.from_json(document.to_h) + expect(round_trip.to_h).to eq(document.to_h) + end + end end From 2c882ee85fbb046620d0b98395ac62d177f6b118 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:22:52 -0500 Subject: [PATCH 6/9] Preserve Heading TOC ids on parse; generate only when missing id and data-toc-id from JSON/attrs are no longer overwritten. Missing values are still filled with a UUID by default; pass generate_toc_ids: false on Heading.new or Document.from_json to skip. --- CHANGELOG.md | 2 + docs/schema.md | 9 ++++ lib/tip_tap/has_content.rb | 5 ++- lib/tip_tap/nodes/heading.rb | 46 ++++++++++++++++++--- lib/tip_tap/nodes/text.rb | 2 +- lib/tip_tap/nodes/unknown.rb | 4 +- spec/tip_tap/nodes/heading_spec.rb | 66 ++++++++++++++++++++++++++++-- 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b50e0..a8847af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Changed +- **Heading TOC ids:** `id` / `data-toc-id` are preserved from JSON/attrs when present (no longer overwritten on `from_json`). Missing ids are still generated by default; pass `generate_toc_ids: false` to `Heading.new` / `Document.from_json` to skip. Anyone relying on regenerate-on-parse will see stable ids when the source already has them. + - `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API; optional `register_mark` / `mark_for`). - `Text` HTML/Markdown rendering composes via schema-registered mark renderers (output for built-ins unchanged). - Built-in child builders (Document, ListItem, lists, tables, …) come from `parent_builder` declarations on child classes. diff --git a/docs/schema.md b/docs/schema.md index 7634189..58ae9fc 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -169,3 +169,12 @@ document = TipTap::Document.from_json(json, unknown_node: :passthrough) ``` `Unknown#to_html` / `#to_markdown` / `#to_plain_text` render **children only** (no wrapper tag). + +## Heading TOC ids + +Headings may carry `id` and `data-toc-id` for table-of-contents links. + +- **Preserve:** values from JSON/attrs are kept (never overwritten on parse). +- **Generate when missing (default):** `Heading.new` and `from_json` fill both attrs with a UUID when absent. +- **Opt out:** `Heading.new(..., generate_toc_ids: false)` or `Document.from_json(json, generate_toc_ids: false)`. + diff --git a/lib/tip_tap/has_content.rb b/lib/tip_tap/has_content.rb index 799717d..36397c1 100644 --- a/lib/tip_tap/has_content.rb +++ b/lib/tip_tap/has_content.rb @@ -50,7 +50,8 @@ module ClassMethods # @param json [Hash] The JSON object to parse # @param schema [TipTap::Schema] Schema used to resolve node types # @param unknown_node [Symbol, nil] Override schema policy: :raise or :passthrough - def from_json(json, schema: TipTap.default_schema, unknown_node: nil) + # @param generate_toc_ids [Boolean, nil] Forwarded to nested nodes (Heading uses it) + def from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) return new if json.nil? json.deep_stringify_keys! @@ -59,7 +60,7 @@ def from_json(json, schema: TipTap.default_schema, unknown_node: nil) content = Array(json["content"]).map do |node| klass = schema.resolve_node_class(node["type"], policy: policy) - klass.from_json(node, schema: schema, unknown_node: policy) + klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) end new(content, **Hash(json["attrs"])) diff --git a/lib/tip_tap/nodes/heading.rb b/lib/tip_tap/nodes/heading.rb index ea65a53..8957ae5 100644 --- a/lib/tip_tap/nodes/heading.rb +++ b/lib/tip_tap/nodes/heading.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "securerandom" require "tip_tap/node" module TipTap @@ -10,11 +11,37 @@ class Heading < Node parent_builder on: TipTap::Document, as: :heading, require_block: true, args: {level: 1} + # @param generate_toc_ids [Boolean] when true (default), set id / data-toc-id + # only if missing. Existing values from JSON/attrs are never overwritten. def initialize(content = [], **attributes) + generate_toc_ids = true + if attributes.key?(:generate_toc_ids) + generate_toc_ids = attributes.delete(:generate_toc_ids) + elsif attributes.key?("generate_toc_ids") + generate_toc_ids = attributes.delete("generate_toc_ids") + end + super(content, **attributes) - uuid = SecureRandom.uuid - @attrs["id"] = uuid - @attrs["data-toc-id"] = uuid + ensure_toc_ids! if generate_toc_ids + end + + def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) + return new(generate_toc_ids: generate_toc_ids.nil? ? true : generate_toc_ids) if json.nil? + + json.deep_stringify_keys! + + policy = unknown_node.nil? ? schema.unknown_node : unknown_node + content = Array(json["content"]).map do |node| + klass = schema.resolve_node_class(node["type"], policy: policy) + klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) + end + + attrs = Hash(json["attrs"]) + if generate_toc_ids.nil? + new(content, **attrs) + else + new(content, generate_toc_ids: generate_toc_ids, **attrs) + end end def text(text, marks: []) @@ -26,8 +53,7 @@ def level end def html_attributes - # doc-toc-id comes from TipTap and Ruby symbols do not support - - # so we use string keys here instead. + # data-toc-id uses a string key — Ruby symbols cannot contain "-". { "style" => inline_styles, "class" => html_class_name, @@ -42,6 +68,16 @@ def to_markdown(context = Markdown::Context.root) body = content.map { |node| node.to_markdown(context) }.join.strip body.empty? ? prefix : "#{prefix} #{body}" end + + private + + def ensure_toc_ids! + return if attrs["id"].present? && attrs["data-toc-id"].present? + + uuid = attrs["id"].presence || attrs["data-toc-id"].presence || SecureRandom.uuid + attrs["id"] = uuid if attrs["id"].blank? + attrs["data-toc-id"] = uuid if attrs["data-toc-id"].blank? + end end end end diff --git a/lib/tip_tap/nodes/text.rb b/lib/tip_tap/nodes/text.rb index 77033c9..bf2dc54 100644 --- a/lib/tip_tap/nodes/text.rb +++ b/lib/tip_tap/nodes/text.rb @@ -18,7 +18,7 @@ def initialize(content, **attributes) yield self if block_given? end - def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil) + def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) json.deep_stringify_keys! new(json["text"], marks: Array(json["marks"]), schema: schema) diff --git a/lib/tip_tap/nodes/unknown.rb b/lib/tip_tap/nodes/unknown.rb index d18e548..463405a 100644 --- a/lib/tip_tap/nodes/unknown.rb +++ b/lib/tip_tap/nodes/unknown.rb @@ -21,13 +21,13 @@ def type_name original_type end - def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil) + def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) json.deep_stringify_keys! policy = unknown_node.nil? ? schema.unknown_node : unknown_node content = Array(json["content"]).map do |node| klass = schema.resolve_node_class(node["type"], policy: policy) - klass.from_json(node, schema: schema, unknown_node: policy) + klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) end new(content, original_type: json["type"], **Hash(json["attrs"])) diff --git a/spec/tip_tap/nodes/heading_spec.rb b/spec/tip_tap/nodes/heading_spec.rb index 7af88c8..044adf4 100644 --- a/spec/tip_tap/nodes/heading_spec.rb +++ b/spec/tip_tap/nodes/heading_spec.rb @@ -8,7 +8,7 @@ end describe "to_html" do - it "returns a tag corresponding to the level attribute with auto-generated UUID" do + it "returns a tag corresponding to the level attribute with generated TOC ids when missing" do node = TipTap::Nodes::Heading.from_json({content: [], attrs: {level: 2}}) html = node.to_html @@ -34,13 +34,73 @@ end end - describe "table of contents key" do - it "automatically generates and sets UUID in attrs" do + describe "table of contents ids" do + it "generates id and data-toc-id when missing (Heading.new default)" do heading = TipTap::Nodes::Heading.new(level: 1) expect(heading.attrs["id"]).to eq("auto-uuid-999") expect(heading.attrs["data-toc-id"]).to eq("auto-uuid-999") end + + it "preserves id and data-toc-id from attrs / JSON" do + heading = TipTap::Nodes::Heading.from_json({ + content: [{type: "text", text: "Hi"}], + attrs: {level: 2, id: "keep-me", "data-toc-id": "keep-me-toc"} + }) + + expect(heading.attrs["id"]).to eq("keep-me") + expect(heading.attrs["data-toc-id"]).to eq("keep-me-toc") + expect(SecureRandom).not_to have_received(:uuid) + + expect(heading.to_h).to eq({ + type: "heading", + attrs: {level: 2, id: "keep-me", "data-toc-id": "keep-me-toc"}, + content: [{type: "text", text: "Hi"}] + }) + + round_trip = TipTap::Nodes::Heading.from_json(heading.to_h) + expect(round_trip.to_h).to eq(heading.to_h) + end + + it "fills only the missing TOC attr from the other when generating" do + heading = TipTap::Nodes::Heading.new(level: 1, id: "only-id") + + expect(heading.attrs["id"]).to eq("only-id") + expect(heading.attrs["data-toc-id"]).to eq("only-id") + expect(SecureRandom).not_to have_received(:uuid) + end + + it "skips generation when generate_toc_ids: false" do + heading = TipTap::Nodes::Heading.new(level: 1, generate_toc_ids: false) + + expect(heading.attrs["id"]).to be_nil + expect(heading.attrs["data-toc-id"]).to be_nil + expect(SecureRandom).not_to have_received(:uuid) + end + + it "honors generate_toc_ids: false on Document.from_json" do + document = TipTap::Document.from_json({ + type: "doc", + content: [ + {type: "heading", attrs: {level: 1}, content: [{type: "text", text: "A"}]} + ] + }, generate_toc_ids: false) + + heading = document.content.first + expect(heading.attrs["id"]).to be_nil + expect(heading.attrs["data-toc-id"]).to be_nil + expect(heading.to_html).to eq("

A

") + end + + it "honors generate_toc_ids: true on from_json when ids are missing" do + heading = TipTap::Nodes::Heading.from_json( + {content: [], attrs: {level: 1}}, + generate_toc_ids: true + ) + + expect(heading.attrs["id"]).to eq("auto-uuid-999") + expect(heading.attrs["data-toc-id"]).to eq("auto-uuid-999") + end end describe "text" do From ab5dc59e2cd12010215a137fbe133c277094af9e Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Thu, 1 Oct 2026 16:45:57 -0500 Subject: [PATCH 7/9] Document Schema-based custom nodes in README Replace the Document monkey-patch extension guide with type_name, parent_builder, and isolated schema examples; point to docs/schema.md for marks and related options. --- README.md | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d450c49..4390818 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ document.heading(level: 1) do |heading| end ``` -Until the gem implements all of the node types and the documentation is complete, refer to the `Document` class to see the nodes that can be appended. +Built-in builders (heading, paragraph, lists, table, `hard_break`, `horizontal_rule`, and so on) are declared on each node via `parent_builder`. See `lib/tip_tap/nodes/` or [docs/schema.md](docs/schema.md). ### Generate Output @@ -86,7 +86,7 @@ document.to_plain_text # => My Important Document ### Custom Nodes -You can extend the library to add custom node types. First, define your `Node` subclass. +Node types are registered on a `TipTap::Schema` (the default schema is populated when the gem loads). Setting `type_name` registers the class for parsing; declare `parent_builder` so parents get a fluent builder—no monkey-patching required. ```ruby # lib/tip_tap/nodes/gallery.rb @@ -94,41 +94,55 @@ You can extend the library to add custom node types. First, define your `Node` s module TipTap module Nodes class Gallery < Node - self.type_name = 'gallery' + self.type_name = "gallery" # registers on TipTap.default_schema self.html_tag = :div - self.html_class_name = 'gallery' + self.html_class_name = "gallery" + + parent_builder on: TipTap::Document, as: :gallery, require_block: true + end + + class GalleryItem < Node + self.type_name = "galleryItem" + self.html_tag = :div + + parent_builder on: Gallery, as: :gallery_item, args: [:src] end end end ``` -Then create an initializer and define an extensions module and include it in the corresponding node. For example: +Require your nodes (for example from an initializer), then build as usual: ```ruby -# config/initializers/tiptap.rb +require "tip_tap/nodes/gallery" -require 'tip_tap' -require 'tip_tap/nodes/gallery' - -module TipTap::DocumentAdditions - def gallery(&block) - raise ArgumentError, "Block required" if block.nil? - add_content(TipTap::Nodes::Gallery.new(&block)) - end +document = TipTap::Document.new +document.gallery do |gallery| + gallery.gallery_item(src: "https://example.com/photo.jpg") end -TipTap::Document.include(TipTap::DocumentAdditions) +# Parsing also resolves the custom type via the schema: +TipTap::Document.from_json(document.to_h) ``` -Now you can generate gallery nodes on a `Document` instance: +#### Isolated schema (recommended for app-specific types) + +To avoid registering globally on `TipTap.default_schema`, duplicate the default schema and register there: ```ruby -document = TipTap::Document.new -document.gallery do |gallery| - gallery.gallery_item(src: 'example.com') -end +schema = TipTap.default_schema.dup +schema.register("gallery", TipTap::Nodes::Gallery) +schema.register("galleryItem", TipTap::Nodes::GalleryItem) + +document = TipTap::Document.from_json(json, schema: schema) ``` +`TipTap::Registry` still works; it is a thin façade over the default schema. Prefer `Schema` for new code. + +#### Custom marks + +Inline marks work the same way with `TipTap::Mark` and `register_mark` / `self.type_name =`. See [docs/schema.md](docs/schema.md) for marks, unknown-node passthrough, and Heading TOC id options. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. From da43cc54d274b279dd6cdd07b62bf05433826268 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Tue, 6 Oct 2026 09:45:26 -0500 Subject: [PATCH 8/9] Remove Schema#dup; isolate schemas with Schema.new.use(default_schema) Schema no longer supports dup/clone. Docs, README, and specs now build isolated schemas via TipTap::Schema.new.use(TipTap.default_schema). --- CHANGELOG.md | 2 +- README.md | 6 ++-- docs/schema.md | 7 +++-- lib/tip_tap/schema.rb | 6 ++-- spec/tip_tap/marks/mark_spec.rb | 2 +- spec/tip_tap/nodes/unknown_spec.rb | 10 ++----- spec/tip_tap/schema_spec.rb | 46 +++++++++++++++--------------- 7 files changed, 38 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8847af..cc82538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Added -- `TipTap::Schema` — first-class node type registry (register, lookup, `use`/`dup`). +- `TipTap::Schema` — first-class node type registry (register, lookup, `use`). Build isolated schemas with `Schema.new.use(TipTap.default_schema)`. - `TipTap::Mark` and built-in mark classes (`TipTap::Marks::*`) registered on Schema. - Schema mark API: `register_mark` / `mark_for` / `mark_registered?`; `TipTap.mark_for`. - Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). diff --git a/README.md b/README.md index 4390818..d9e7f1d 100644 --- a/README.md +++ b/README.md @@ -127,16 +127,18 @@ TipTap::Document.from_json(document.to_h) #### Isolated schema (recommended for app-specific types) -To avoid registering globally on `TipTap.default_schema`, duplicate the default schema and register there: +To avoid registering globally on `TipTap.default_schema`, build a new schema that pulls in the built-ins with `use`, then register your types there: ```ruby -schema = TipTap.default_schema.dup +schema = TipTap::Schema.new.use(TipTap.default_schema) schema.register("gallery", TipTap::Nodes::Gallery) schema.register("galleryItem", TipTap::Nodes::GalleryItem) document = TipTap::Document.from_json(json, schema: schema) ``` +Classes registered this way should not also set `self.type_name =`, since that registers them on the default schema too. + `TipTap::Registry` still works; it is a thin façade over the default schema. Prefer `Schema` for new code. #### Custom marks diff --git a/docs/schema.md b/docs/schema.md index 58ae9fc..4a01550 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -53,13 +53,14 @@ TipTap.default_schema.register("callout", MyCallout) ### Isolated schema (recommended for app-specific nodes) ```ruby -schema = TipTap.default_schema.dup +schema = TipTap::Schema.new.use(TipTap.default_schema) schema.register("callout", MyCallout) document = TipTap::Document.from_json(json, schema: schema) ``` Isolated schemas do not affect other parses that use the default schema. +`use` copies the default schema's node and mark registrations into the new schema. ## Custom marks @@ -86,7 +87,7 @@ end ### Isolated schema ```ruby -schema = TipTap.default_schema.dup +schema = TipTap::Schema.new.use(TipTap.default_schema) schema.register_mark("spoiler", Spoiler) text = TipTap::Nodes::Text.new("secret", marks: [{type: "spoiler"}], schema: schema) @@ -160,7 +161,7 @@ attrs, and recursively parsed content) for `to_h` round-trips: ```ruby # Schema-level -schema = TipTap.default_schema.dup +schema = TipTap::Schema.new.use(TipTap.default_schema) schema.unknown_node = :passthrough # or :raise (default) document = TipTap::Document.from_json(json, schema: schema) diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb index 127e5e7..b43bda2 100644 --- a/lib/tip_tap/schema.rb +++ b/lib/tip_tap/schema.rb @@ -87,9 +87,9 @@ def use(*extensions) self end - def dup - self.class.new(nodes.dup, marks.dup, unknown_node: unknown_node) - end + # Schemas are not copyable. Build an isolated schema with + # Schema.new.use(TipTap.default_schema) instead. + undef_method :dup, :clone def ==(other) other.is_a?(Schema) && diff --git a/spec/tip_tap/marks/mark_spec.rb b/spec/tip_tap/marks/mark_spec.rb index 542da5e..7b8f994 100644 --- a/spec/tip_tap/marks/mark_spec.rb +++ b/spec/tip_tap/marks/mark_spec.rb @@ -62,7 +62,7 @@ def wrap_markdown(value, context: nil) end it "renders a custom mark registered on an isolated schema" do - schema = TipTap.default_schema.dup + schema = TipTap::Schema.new.use(TipTap.default_schema) schema.register_mark("spoiler", spoiler_mark) node = TipTap::Nodes::Text.new( diff --git a/spec/tip_tap/nodes/unknown_spec.rb b/spec/tip_tap/nodes/unknown_spec.rb index 01181f8..2aae0c0 100644 --- a/spec/tip_tap/nodes/unknown_spec.rb +++ b/spec/tip_tap/nodes/unknown_spec.rb @@ -41,7 +41,7 @@ describe "schema.unknown_node = :passthrough" do let(:schema) do - TipTap.default_schema.dup.tap { |s| s.unknown_node = :passthrough } + TipTap::Schema.new.use(TipTap.default_schema).tap { |s| s.unknown_node = :passthrough } end it "preserves unknown nodes for to_h round-trip" do @@ -89,7 +89,7 @@ describe "from_json(unknown_node: :passthrough)" do it "opts in without mutating the schema" do - schema = TipTap.default_schema.dup + schema = TipTap::Schema.new.use(TipTap.default_schema) expect(schema.unknown_node).to eq(:raise) document = TipTap::Document.from_json(unknown_json, schema: schema, unknown_node: :passthrough) @@ -149,11 +149,5 @@ schema = TipTap::Schema.new expect { schema.unknown_node = :skip }.to raise_error(ArgumentError, /passthrough/) end - - it "is copied by dup" do - schema = TipTap.default_schema.dup - schema.unknown_node = :passthrough - expect(schema.dup.unknown_node).to eq(:passthrough) - end end end diff --git a/spec/tip_tap/schema_spec.rb b/spec/tip_tap/schema_spec.rb index c61f2af..a1ad6f9 100644 --- a/spec/tip_tap/schema_spec.rb +++ b/spec/tip_tap/schema_spec.rb @@ -46,7 +46,7 @@ def self.type_name end it "parses JSON using the given schema without registering on the default schema" do - schema = TipTap.default_schema.dup + schema = TipTap::Schema.new.use(TipTap.default_schema) schema.register("customBlock", custom_node_class) json = { @@ -102,17 +102,6 @@ def extension.register(schema) end end - describe "#dup" do - it "copies registrations without sharing the hash" do - original = TipTap.default_schema.dup - copy = original.dup - copy.register("onlyOnCopy", String) - - expect(copy.registered?("onlyOnCopy")).to eq(true) - expect(original.registered?("onlyOnCopy")).to eq(false) - end - end - describe "#register_mark / #mark_for" do it "registers and looks up a custom mark" do schema = TipTap::Schema.new @@ -138,17 +127,6 @@ def extension.register(schema) end end - describe "#dup with marks" do - it "copies mark registrations without sharing the hash" do - original = TipTap.default_schema.dup - copy = original.dup - copy.register_mark("onlyOnCopy", String) - - expect(copy.mark_registered?("onlyOnCopy")).to eq(true) - expect(original.mark_registered?("onlyOnCopy")).to eq(false) - end - end - describe "#use with marks" do it "merges marks from another schema" do base = TipTap::Schema.new @@ -163,4 +141,26 @@ def extension.register(schema) expect(base.mark_for("italic")).to eq(TipTap::Marks::Italic) end end + + describe "copying" do + it "does not support dup or clone" do + schema = TipTap::Schema.new + + expect { schema.dup }.to raise_error(NoMethodError) + expect { schema.clone }.to raise_error(NoMethodError) + end + end + + describe "isolated schema via use" do + it "copies registrations without sharing state with the default schema" do + schema = TipTap::Schema.new.use(TipTap.default_schema) + schema.register("onlyOnIsolated", String) + schema.register_mark("onlyOnIsolated", String) + + expect(schema.node_for("paragraph")).to eq(TipTap::Nodes::Paragraph) + expect(schema.mark_for("bold")).to eq(TipTap::Marks::Bold) + expect(TipTap.default_schema.registered?("onlyOnIsolated")).to eq(false) + expect(TipTap.default_schema.mark_registered?("onlyOnIsolated")).to eq(false) + end + end end From d9b96ef198956fac5678bca8ae27eb94c4aa31f8 Mon Sep 17 00:00:00 2001 From: Chad Wilken Date: Tue, 6 Oct 2026 09:50:32 -0500 Subject: [PATCH 9/9] Use only TipTap.default_schema; drop isolated schema: API Remove schema: from from_json/Text initialize. Custom nodes and marks register on the default schema via type_name= (or register). Docs and specs no longer describe Schema.new.use isolation. --- CHANGELOG.md | 4 +- README.md | 18 +------- docs/schema.md | 74 ++++++++++-------------------- lib/tip_tap/has_content.rb | 12 ++--- lib/tip_tap/nodes/heading.rb | 5 +- lib/tip_tap/nodes/text.rb | 8 ++-- lib/tip_tap/nodes/unknown.rb | 5 +- lib/tip_tap/schema.rb | 8 ++-- spec/tip_tap/marks/mark_spec.rb | 20 +++----- spec/tip_tap/nodes/unknown_spec.rb | 30 ++++++------ spec/tip_tap/schema_spec.rb | 32 ++++--------- 11 files changed, 79 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc82538..c4a8ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ ### Added -- `TipTap::Schema` — first-class node type registry (register, lookup, `use`). Build isolated schemas with `Schema.new.use(TipTap.default_schema)`. +- `TipTap::Schema` — store behind `TipTap.default_schema` (register, lookup, `use` for extensions). - `TipTap::Mark` and built-in mark classes (`TipTap::Marks::*`) registered on Schema. - Schema mark API: `register_mark` / `mark_for` / `mark_registered?`; `TipTap.mark_for`. -- Optional `schema:` keyword on `Document.from_json` / `Node.from_json` (defaults to `TipTap.default_schema`). - `TipTap.default_schema` accessor; `TipTap.node_for` / `TipTap.mark_for` read from it. - `TipTap::ParentBuildable` / `parent_builder` — declarative builders on parent nodes when a child type is registered. - `Schema#unknown_node` / `from_json(..., unknown_node:)` — opt-in `:passthrough` via `TipTap::Nodes::Unknown` (default remains `:raise`). @@ -13,6 +12,7 @@ ### Changed +- Parsing and rendering always use `TipTap.default_schema` (no per-document / isolated `schema:` argument). - **Heading TOC ids:** `id` / `data-toc-id` are preserved from JSON/attrs when present (no longer overwritten on `from_json`). Missing ids are still generated by default; pass `generate_toc_ids: false` to `Heading.new` / `Document.from_json` to skip. Anyone relying on regenerate-on-parse will see stable ids when the source already has them. - `TipTap::Registry` is now a compatibility façade over `TipTap.default_schema` (same public API; optional `register_mark` / `mark_for`). diff --git a/README.md b/README.md index d9e7f1d..2d987e2 100644 --- a/README.md +++ b/README.md @@ -121,25 +121,11 @@ document.gallery do |gallery| gallery.gallery_item(src: "https://example.com/photo.jpg") end -# Parsing also resolves the custom type via the schema: +# Parsing resolves the custom type via TipTap.default_schema: TipTap::Document.from_json(document.to_h) ``` -#### Isolated schema (recommended for app-specific types) - -To avoid registering globally on `TipTap.default_schema`, build a new schema that pulls in the built-ins with `use`, then register your types there: - -```ruby -schema = TipTap::Schema.new.use(TipTap.default_schema) -schema.register("gallery", TipTap::Nodes::Gallery) -schema.register("galleryItem", TipTap::Nodes::GalleryItem) - -document = TipTap::Document.from_json(json, schema: schema) -``` - -Classes registered this way should not also set `self.type_name =`, since that registers them on the default schema too. - -`TipTap::Registry` still works; it is a thin façade over the default schema. Prefer `Schema` for new code. +`TipTap::Registry` still works; it is a thin façade over `TipTap.default_schema`. #### Custom marks diff --git a/docs/schema.md b/docs/schema.md index 4a01550..54e6b46 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -1,13 +1,13 @@ # TipTap::Schema -`TipTap::Schema` is the canonical registry of **node** and **mark** type names → Ruby classes. -Parsing (`Document.from_json` / `Node.from_json`) resolves each JSON node `type` through a schema. -`Text` resolves mark `type` values through the same schema when rendering HTML/Markdown. +`TipTap::Schema` is the store of **node** and **mark** type names → Ruby classes. +There is a single process-wide schema: `TipTap.default_schema`. +`Document.from_json` / `Node.from_json` and `Text` mark rendering always use it. ## Default schema -Loading the gem builds registrations into `TipTap.default_schema` via each node's or mark's -`self.type_name = "..."` . Built-in nodes (`doc`, `paragraph`, `text`, …) and marks +Loading the gem registers built-ins onto `TipTap.default_schema` via each node's or mark's +`self.type_name = "..."`. Built-in nodes (`doc`, `paragraph`, `text`, …) and marks (`bold`, `italic`, `link`, …) live there. ```ruby @@ -33,42 +33,35 @@ TipTap.mark_for("bold") # same `Registry::MissingNodeError` / `MissingMarkError` alias the Schema error classes. -Prefer `Schema` for new code; keep using `Registry` if you already depend on it. +Prefer `TipTap.default_schema` for new code; keep using `Registry` if you already depend on it. ## Custom nodes -### Register on the default schema (global) - ```ruby -class MyCallout < TipTap::Node +class Callout < TipTap::Node self.type_name = "callout" # auto-registers on TipTap.default_schema self.html_tag = :aside + + parent_builder on: TipTap::Document, as: :callout, require_block: true end -# or explicitly: -TipTap.default_schema.register("callout", MyCallout) -# TipTap::Registry.register("callout", MyCallout) # equivalent +# Require the file (e.g. from an initializer), then: +document = TipTap::Document.from_json(json) +document.callout { |c| … } ``` -### Isolated schema (recommended for app-specific nodes) +Or register explicitly: ```ruby -schema = TipTap::Schema.new.use(TipTap.default_schema) -schema.register("callout", MyCallout) - -document = TipTap::Document.from_json(json, schema: schema) +TipTap.default_schema.register("callout", MyCallout) +# TipTap::Registry.register("callout", MyCallout) # equivalent ``` -Isolated schemas do not affect other parses that use the default schema. -`use` copies the default schema's node and mark registrations into the new schema. - ## Custom marks Marks subclass `TipTap::Mark` and implement `wrap_html` / `wrap_markdown`. `html_priority` / `markdown_priority` control nesting order (lower = applied first = innermost). -### Global registration - ```ruby class Spoiler < TipTap::Mark self.type_name = "spoiler" @@ -84,30 +77,13 @@ class Spoiler < TipTap::Mark end ``` -### Isolated schema - -```ruby -schema = TipTap::Schema.new.use(TipTap.default_schema) -schema.register_mark("spoiler", Spoiler) - -text = TipTap::Nodes::Text.new("secret", marks: [{type: "spoiler"}], schema: schema) -text.to_html # => secret - -# Builder API unchanged — marks stay TipTap-shaped hashes in to_h: -text.to_h # => { type: "text", text: "secret", marks: [{ type: "spoiler" }] } -``` +Unknown mark types are ignored during rendering but still appear in `to_h`. -Unknown mark types on a schema are ignored during rendering but still appear in `to_h`. +## Extensions -## Composing schemas +Apply an object that responds to `#register(schema)` onto the default schema: ```ruby -schema = TipTap::Schema.new -schema.use(TipTap.default_schema) # copy built-in nodes and marks -schema.register("callout", MyCallout) -schema.register_mark("spoiler", Spoiler) - -# or an extension object: module CalloutExtension def self.register(schema) schema.register("callout", MyCallout) @@ -115,10 +91,9 @@ module CalloutExtension end end -schema.use(CalloutExtension) +TipTap.default_schema.use(CalloutExtension) ``` - ## Parent builders Child node classes declare which parents get a builder method via `parent_builder`. @@ -151,7 +126,6 @@ Leaf builders (no block): - `Paragraph#text` / `Heading#text` — positional text + marks - `Codeblock#code` — builds a `Text` node with a code mark - ## Unknown nodes By default, parsing raises `Schema::MissingNodeError` for unregistered types (backward compatible). @@ -160,12 +134,11 @@ Opt into passthrough so unknown nodes are kept as `TipTap::Nodes::Unknown` (orig attrs, and recursively parsed content) for `to_h` round-trips: ```ruby -# Schema-level -schema = TipTap::Schema.new.use(TipTap.default_schema) -schema.unknown_node = :passthrough # or :raise (default) -document = TipTap::Document.from_json(json, schema: schema) +# Default-schema setting +TipTap.default_schema.unknown_node = :passthrough # or :raise (default) +document = TipTap::Document.from_json(json) -# Per-call override (does not mutate the schema) +# Per-call override (does not mutate the default schema) document = TipTap::Document.from_json(json, unknown_node: :passthrough) ``` @@ -178,4 +151,3 @@ Headings may carry `id` and `data-toc-id` for table-of-contents links. - **Preserve:** values from JSON/attrs are kept (never overwritten on parse). - **Generate when missing (default):** `Heading.new` and `from_json` fill both attrs with a UUID when absent. - **Opt out:** `Heading.new(..., generate_toc_ids: false)` or `Document.from_json(json, generate_toc_ids: false)`. - diff --git a/lib/tip_tap/has_content.rb b/lib/tip_tap/has_content.rb index 36397c1..2fac06d 100644 --- a/lib/tip_tap/has_content.rb +++ b/lib/tip_tap/has_content.rb @@ -46,21 +46,21 @@ def blank? module ClassMethods # Create a new instance from a TipTap JSON object. - # All nodes are recursively parsed and converted to Ruby objects. + # All nodes are recursively parsed via TipTap.default_schema. # @param json [Hash] The JSON object to parse - # @param schema [TipTap::Schema] Schema used to resolve node types - # @param unknown_node [Symbol, nil] Override schema policy: :raise or :passthrough - # @param generate_toc_ids [Boolean, nil] Forwarded to nested nodes (Heading uses it) - def from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) + # @param unknown_node [Symbol, nil] Override default schema policy: :raise or :passthrough + # @param generate_toc_ids [Boolean, nil] Forwarded to nested Heading nodes + def from_json(json, unknown_node: nil, generate_toc_ids: nil) return new if json.nil? json.deep_stringify_keys! + schema = TipTap.default_schema policy = unknown_node.nil? ? schema.unknown_node : unknown_node content = Array(json["content"]).map do |node| klass = schema.resolve_node_class(node["type"], policy: policy) - klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) + klass.from_json(node, unknown_node: policy, generate_toc_ids: generate_toc_ids) end new(content, **Hash(json["attrs"])) diff --git a/lib/tip_tap/nodes/heading.rb b/lib/tip_tap/nodes/heading.rb index 8957ae5..a5a5f84 100644 --- a/lib/tip_tap/nodes/heading.rb +++ b/lib/tip_tap/nodes/heading.rb @@ -25,15 +25,16 @@ def initialize(content = [], **attributes) ensure_toc_ids! if generate_toc_ids end - def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) + def self.from_json(json, unknown_node: nil, generate_toc_ids: nil) return new(generate_toc_ids: generate_toc_ids.nil? ? true : generate_toc_ids) if json.nil? json.deep_stringify_keys! + schema = TipTap.default_schema policy = unknown_node.nil? ? schema.unknown_node : unknown_node content = Array(json["content"]).map do |node| klass = schema.resolve_node_class(node["type"], policy: policy) - klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) + klass.from_json(node, unknown_node: policy, generate_toc_ids: generate_toc_ids) end attrs = Hash(json["attrs"]) diff --git a/lib/tip_tap/nodes/text.rb b/lib/tip_tap/nodes/text.rb index bf2dc54..dec0c91 100644 --- a/lib/tip_tap/nodes/text.rb +++ b/lib/tip_tap/nodes/text.rb @@ -7,21 +7,20 @@ module Nodes class Text < Node # Allow the text to be set and accessed directly attr_accessor :text - attr_reader :marks, :schema + attr_reader :marks self.type_name = "text" def initialize(content, **attributes) @text = content - @schema = attributes[:schema] || TipTap.default_schema @marks = Array(attributes[:marks]).map(&:deep_stringify_keys) yield self if block_given? end - def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) + def self.from_json(json, unknown_node: nil, generate_toc_ids: nil) json.deep_stringify_keys! - new(json["text"], marks: Array(json["marks"]), schema: schema) + new(json["text"], marks: Array(json["marks"])) end def to_h @@ -98,6 +97,7 @@ def has_mark_with_type?(type) end def mark_objects + schema = TipTap.default_schema marks.filter_map do |mark_hash| type = mark_hash["type"] next unless schema.mark_registered?(type) diff --git a/lib/tip_tap/nodes/unknown.rb b/lib/tip_tap/nodes/unknown.rb index 463405a..888c5dc 100644 --- a/lib/tip_tap/nodes/unknown.rb +++ b/lib/tip_tap/nodes/unknown.rb @@ -21,13 +21,14 @@ def type_name original_type end - def self.from_json(json, schema: TipTap.default_schema, unknown_node: nil, generate_toc_ids: nil) + def self.from_json(json, unknown_node: nil, generate_toc_ids: nil) json.deep_stringify_keys! + schema = TipTap.default_schema policy = unknown_node.nil? ? schema.unknown_node : unknown_node content = Array(json["content"]).map do |node| klass = schema.resolve_node_class(node["type"], policy: policy) - klass.from_json(node, schema: schema, unknown_node: policy, generate_toc_ids: generate_toc_ids) + klass.from_json(node, unknown_node: policy, generate_toc_ids: generate_toc_ids) end new(content, original_type: json["type"], **Hash(json["attrs"])) diff --git a/lib/tip_tap/schema.rb b/lib/tip_tap/schema.rb index b43bda2..76a8e93 100644 --- a/lib/tip_tap/schema.rb +++ b/lib/tip_tap/schema.rb @@ -71,8 +71,9 @@ def clear marks.clear end - # Merge registrations from another schema or from objects that - # respond to #register(schema). Returns self for chaining. + # Apply extension objects that respond to #register(schema), or merge + # node/mark maps from another Schema instance. Prefer + # TipTap.default_schema.use(MyExtension) for app code. def use(*extensions) extensions.each do |extension| if extension.is_a?(Schema) @@ -87,8 +88,7 @@ def use(*extensions) self end - # Schemas are not copyable. Build an isolated schema with - # Schema.new.use(TipTap.default_schema) instead. + # Schemas are not copyable. Use TipTap.default_schema only. undef_method :dup, :clone def ==(other) diff --git a/spec/tip_tap/marks/mark_spec.rb b/spec/tip_tap/marks/mark_spec.rb index 7b8f994..6b648e6 100644 --- a/spec/tip_tap/marks/mark_spec.rb +++ b/spec/tip_tap/marks/mark_spec.rb @@ -61,27 +61,21 @@ def wrap_markdown(value, context: nil) end end - it "renders a custom mark registered on an isolated schema" do - schema = TipTap::Schema.new.use(TipTap.default_schema) - schema.register_mark("spoiler", spoiler_mark) + after do + TipTap.default_schema.marks.delete("spoiler") + end + + it "renders a custom mark registered on TipTap.default_schema" do + TipTap.default_schema.register_mark("spoiler", spoiler_mark) node = TipTap::Nodes::Text.new( "secret", - marks: [{type: "spoiler"}, {type: "bold"}], - schema: schema + marks: [{type: "spoiler"}, {type: "bold"}] ) expect(node.to_html).to eq('secret') expect(node.to_markdown).to eq("**||secret||**") expect(node.to_h[:marks]).to eq([{type: "spoiler"}, {type: "bold"}]) - - # Default schema does not know spoiler — only bold applies - default_node = TipTap::Nodes::Text.new( - "secret", - marks: [{type: "spoiler"}, {type: "bold"}] - ) - expect(default_node.to_html).to eq("secret") - expect(TipTap.default_schema.mark_registered?("spoiler")).to eq(false) end it "exposes Registry mark helpers as a façade" do diff --git a/spec/tip_tap/nodes/unknown_spec.rb b/spec/tip_tap/nodes/unknown_spec.rb index 2aae0c0..66471ab 100644 --- a/spec/tip_tap/nodes/unknown_spec.rb +++ b/spec/tip_tap/nodes/unknown_spec.rb @@ -39,13 +39,17 @@ end end - describe "schema.unknown_node = :passthrough" do - let(:schema) do - TipTap::Schema.new.use(TipTap.default_schema).tap { |s| s.unknown_node = :passthrough } + describe "default_schema.unknown_node = :passthrough" do + around do |example| + previous = TipTap.default_schema.unknown_node + TipTap.default_schema.unknown_node = :passthrough + example.run + ensure + TipTap.default_schema.unknown_node = previous end it "preserves unknown nodes for to_h round-trip" do - document = TipTap::Document.from_json(unknown_json, schema: schema) + document = TipTap::Document.from_json(unknown_json) expect(document.content.size).to eq(2) expect(document.content.first).to be_a(TipTap::Nodes::Paragraph) @@ -77,7 +81,7 @@ end it "renders HTML/markdown/plain from children only" do - document = TipTap::Document.from_json(unknown_json, schema: schema) + document = TipTap::Document.from_json(unknown_json) unknown = document.content.last expect(unknown.to_html).to eq("

Inside unknown

") @@ -88,17 +92,16 @@ end describe "from_json(unknown_node: :passthrough)" do - it "opts in without mutating the schema" do - schema = TipTap::Schema.new.use(TipTap.default_schema) - expect(schema.unknown_node).to eq(:raise) + it "opts in without mutating the default schema" do + expect(TipTap.default_schema.unknown_node).to eq(:raise) - document = TipTap::Document.from_json(unknown_json, schema: schema, unknown_node: :passthrough) + document = TipTap::Document.from_json(unknown_json, unknown_node: :passthrough) expect(document.content.last).to be_a(TipTap::Nodes::Unknown) - expect(schema.unknown_node).to eq(:raise) + expect(TipTap.default_schema.unknown_node).to eq(:raise) expect { - TipTap::Document.from_json(unknown_json, schema: schema) + TipTap::Document.from_json(unknown_json) }.to raise_error(TipTap::Schema::MissingNodeError) end end @@ -146,8 +149,9 @@ describe "Schema#unknown_node=" do it "rejects invalid policies" do - schema = TipTap::Schema.new - expect { schema.unknown_node = :skip }.to raise_error(ArgumentError, /passthrough/) + expect { + TipTap.default_schema.unknown_node = :skip + }.to raise_error(ArgumentError, /passthrough/) end end end diff --git a/spec/tip_tap/schema_spec.rb b/spec/tip_tap/schema_spec.rb index a1ad6f9..71d533f 100644 --- a/spec/tip_tap/schema_spec.rb +++ b/spec/tip_tap/schema_spec.rb @@ -34,7 +34,7 @@ end end - describe "parsing with a custom schema" do + describe "parsing with a custom node on the default schema" do let(:custom_node_class) do Class.new(TipTap::Node) do self.html_tag = :div @@ -45,9 +45,12 @@ def self.type_name end end - it "parses JSON using the given schema without registering on the default schema" do - schema = TipTap::Schema.new.use(TipTap.default_schema) - schema.register("customBlock", custom_node_class) + after do + TipTap.default_schema.nodes.delete("customBlock") + end + + it "parses JSON after registering on TipTap.default_schema" do + TipTap.default_schema.register("customBlock", custom_node_class) json = { type: "doc", @@ -61,17 +64,11 @@ def self.type_name ] } - document = TipTap::Document.from_json(json, schema: schema) + document = TipTap::Document.from_json(json) expect(document.content.first).to be_a(custom_node_class) expect(document.content.first.content.first).to be_a(TipTap::Nodes::Text) expect(document.to_plain_text).to eq("Hello custom") - - expect { - TipTap::Document.from_json(json) - }.to raise_error(TipTap::Schema::MissingNodeError) - - expect(TipTap.default_schema.registered?("customBlock")).to eq(false) end end @@ -150,17 +147,4 @@ def extension.register(schema) expect { schema.clone }.to raise_error(NoMethodError) end end - - describe "isolated schema via use" do - it "copies registrations without sharing state with the default schema" do - schema = TipTap::Schema.new.use(TipTap.default_schema) - schema.register("onlyOnIsolated", String) - schema.register_mark("onlyOnIsolated", String) - - expect(schema.node_for("paragraph")).to eq(TipTap::Nodes::Paragraph) - expect(schema.mark_for("bold")).to eq(TipTap::Marks::Bold) - expect(TipTap.default_schema.registered?("onlyOnIsolated")).to eq(false) - expect(TipTap.default_schema.mark_registered?("onlyOnIsolated")).to eq(false) - end - end end