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 42cd94f..af6e80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ ## [Unreleased] +### Added + +- `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`. +- `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 + +- 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`). +- `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.11.0] - 2026-10-14 - Update various gems for CVE vulnerabilities diff --git a/README.md b/README.md index d450c49..2d987e2 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,43 @@ 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' - end - end -end -``` + self.html_class_name = "gallery" -Then create an initializer and define an extensions module and include it in the corresponding node. For example: - -```ruby -# config/initializers/tiptap.rb + parent_builder on: TipTap::Document, as: :gallery, require_block: true + end -require 'tip_tap' -require 'tip_tap/nodes/gallery' + class GalleryItem < Node + self.type_name = "galleryItem" + self.html_tag = :div -module TipTap::DocumentAdditions - def gallery(&block) - raise ArgumentError, "Block required" if block.nil? - add_content(TipTap::Nodes::Gallery.new(&block)) + parent_builder on: Gallery, as: :gallery_item, args: [:src] + end end end - -TipTap::Document.include(TipTap::DocumentAdditions) ``` -Now you can generate gallery nodes on a `Document` instance: +Require your nodes (for example from an initializer), then build as usual: ```ruby +require "tip_tap/nodes/gallery" + document = TipTap::Document.new document.gallery do |gallery| - gallery.gallery_item(src: 'example.com') + gallery.gallery_item(src: "https://example.com/photo.jpg") end + +# Parsing resolves the custom type via TipTap.default_schema: +TipTap::Document.from_json(document.to_h) ``` +`TipTap::Registry` still works; it is a thin façade over `TipTap.default_schema`. + +#### 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. diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..54e6b46 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,153 @@ +# TipTap::Schema + +`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 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 +TipTap.default_schema.node_for("paragraph") # => TipTap::Nodes::Paragraph +TipTap.node_for("paragraph") # same + +TipTap.default_schema.mark_for("bold") # => TipTap::Marks::Bold +TipTap.mark_for("bold") # same +``` + +## 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.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` / `MissingMarkError` alias the Schema error classes. + +Prefer `TipTap.default_schema` for new code; keep using `Registry` if you already depend on it. + +## Custom nodes + +```ruby +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 + +# Require the file (e.g. from an initializer), then: +document = TipTap::Document.from_json(json) +document.callout { |c| … } +``` + +Or register explicitly: + +```ruby +TipTap.default_schema.register("callout", MyCallout) +# TipTap::Registry.register("callout", MyCallout) # equivalent +``` + +## Custom marks + +Marks subclass `TipTap::Mark` and implement `wrap_html` / `wrap_markdown`. +`html_priority` / `markdown_priority` control nesting order (lower = applied first = innermost). + +```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 +``` + +Unknown mark types are ignored during rendering but still appear in `to_h`. + +## Extensions + +Apply an object that responds to `#register(schema)` onto the default schema: + +```ruby +module CalloutExtension + def self.register(schema) + schema.register("callout", MyCallout) + schema.register_mark("spoiler", Spoiler) + end +end + +TipTap.default_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. + +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 +- `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 +# 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 default schema) +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.rb b/lib/tip_tap.rb index bed1d02..6ebdb7d 100644 --- a/lib/tip_tap.rb +++ b/lib/tip_tap.rb @@ -1,30 +1,70 @@ # frozen_string_literal: true require_relative "tip_tap/version" +require "tip_tap/schema" require "tip_tap/registry" +require "tip_tap/mark" + +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 + + 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 + +# 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" +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" + +# 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/unknown" 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" -module TipTap - class Error < StandardError; end - - def self.node_for(name) - Registry.node_for(name) - end -end +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/has_content.rb b/lib/tip_tap/has_content.rb index 0ad51fb..2fac06d 100644 --- a/lib/tip_tap/has_content.rb +++ b/lib/tip_tap/has_content.rb @@ -46,16 +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 must be registered in the registry. + # All nodes are recursively parsed via TipTap.default_schema. # @param json [Hash] The JSON object to parse - def from_json(json) + # @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| - TipTap.node_for(node["type"]).from_json(node) + klass = schema.resolve_node_class(node["type"], policy: policy) + 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/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}#{tag}>" + 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/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/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/heading.rb b/lib/tip_tap/nodes/heading.rb index 240306c..a5a5f84 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 @@ -8,11 +9,40 @@ 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} + + # @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, 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, 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: []) @@ -24,8 +54,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, @@ -40,6 +69,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/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/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/nodes/text.rb b/lib/tip_tap/nodes/text.rb index 576d2e2..dec0c91 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, unknown_node: nil, generate_toc_ids: nil) json.deep_stringify_keys! new(json["text"], marks: Array(json["marks"])) @@ -30,18 +30,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 +43,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 +96,32 @@ 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 + schema = TipTap.default_schema + 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}#{tag}>" - 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/nodes/unknown.rb b/lib/tip_tap/nodes/unknown.rb new file mode 100644 index 0000000..888c5dc --- /dev/null +++ b/lib/tip_tap/nodes/unknown.rb @@ -0,0 +1,48 @@ +# 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, 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, unknown_node: policy, generate_toc_ids: generate_toc_ids) + 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/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 1af993d..1fac671 100644 --- a/lib/tip_tap/registerable.rb +++ b/lib/tip_tap/registerable.rb @@ -1,17 +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) 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/registry.rb b/lib/tip_tap/registry.rb index e923258..f1395fb 100644 --- a/lib/tip_tap/registry.rb +++ b/lib/tip_tap/registry.rb @@ -1,27 +1,37 @@ # 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 + MissingMarkError = Schema::MissingMarkError 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.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 - @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..76a8e93 --- /dev/null +++ b/lib/tip_tap/schema.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# 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) + + UNKNOWN_NODE_POLICIES = %i[raise passthrough].freeze + + 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) + nodes[name.to_s] = klass + klass.install_parent_builders! if klass.respond_to?(:install_parent_builders!) + end + + def node_for(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) + 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 + + # 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) + 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 + raise ArgumentError, "Extension must be a Schema or respond to #register" + end + end + self + end + + # Schemas are not copyable. Use TipTap.default_schema only. + undef_method :dup, :clone + + def ==(other) + other.is_a?(Schema) && + nodes == other.nodes && + marks == other.marks && + unknown_node == other.unknown_node + 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..6b648e6 --- /dev/null +++ b/spec/tip_tap/marks/mark_spec.rb @@ -0,0 +1,84 @@ +# 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 + + 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"}] + ) + + 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"}]) + 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/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/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("
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 default schema" do + expect(TipTap.default_schema.unknown_node).to eq(:raise) + + document = TipTap::Document.from_json(unknown_json, unknown_node: :passthrough) + + expect(document.content.last).to be_a(TipTap::Nodes::Unknown) + expect(TipTap.default_schema.unknown_node).to eq(:raise) + + expect { + TipTap::Document.from_json(unknown_json) + }.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('Sibling