Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@

# rspec failure tracking
.rspec_status
/vendor/bundle/
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 24 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -86,49 +86,51 @@ 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

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.
Expand Down
153 changes: 153 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
@@ -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)`.
68 changes: 54 additions & 14 deletions lib/tip_tap.rb
Original file line number Diff line number Diff line change
@@ -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!
Loading
Loading