yrby 0.7.1-aarch64-linux → 0.8.1-aarch64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +593 -384
- data/lib/generators/yrby/install/install_generator.rb +24 -13
- data/lib/generators/yrby/install/templates/document_channel.rb +4 -7
- data/lib/generators/yrby/tables/tables_generator.rb +7 -4
- data/lib/generators/yrby/tables/templates/create_y_tables.rb +1 -1
- data/lib/y/3.4/yrby.so +0 -0
- data/lib/y/4.0/yrby.so +0 -0
- data/lib/y/collaborative/attribute.rb +53 -0
- data/lib/y/collaborative/helper.rb +48 -0
- data/lib/y/collaborative.rb +98 -0
- data/lib/y/lexxy.rb +4 -4
- data/lib/y/rendering.rb +39 -15
- data/lib/y/tiptap.rb +3 -3
- data/lib/y/version.rb +1 -1
- metadata +5 -2
|
@@ -5,15 +5,16 @@ require "generators/yrby/tables/tables_generator"
|
|
|
5
5
|
|
|
6
6
|
module Yrby
|
|
7
7
|
module Generators
|
|
8
|
-
# `bin/rails generate yrby:install`
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# `bin/rails generate yrby:install` runs yrby:tables to create the storage
|
|
9
|
+
# migration. The models and Y::DocumentChannel ship in the gem, so that is
|
|
10
|
+
# the only file it generates by default. Pass --channel to also generate
|
|
11
|
+
# an application channel for custom authorization or room-keyed documents.
|
|
12
12
|
class InstallGenerator < ::Rails::Generators::Base
|
|
13
13
|
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
class_option :channel, type: :boolean, default: false, desc: "Generate a custom DocumentChannel"
|
|
14
15
|
|
|
15
16
|
def create_channel
|
|
16
|
-
template "document_channel.rb", "app/channels/document_channel.rb"
|
|
17
|
+
template "document_channel.rb", "app/channels/document_channel.rb" if options[:channel]
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def create_tables
|
|
@@ -25,15 +26,25 @@ module Yrby
|
|
|
25
26
|
|
|
26
27
|
Next steps:
|
|
27
28
|
|
|
28
|
-
1.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
3. Install the yrby-client npm package and connect an editor:
|
|
29
|
+
1. bin/rails db:migrate
|
|
30
|
+
2. Render a collaborative document on a page where the user may
|
|
31
|
+
edit the record:
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
<%= collaborative_document_tag @post, :body %>
|
|
34
|
+
|
|
35
|
+
3. Install the yrby-client npm package. The tag renders an
|
|
36
|
+
element that connects automatically. Once it syncs, your code
|
|
37
|
+
gets the document and can bind it to any Yjs editor:
|
|
38
|
+
|
|
39
|
+
import "yrby-client/element"
|
|
40
|
+
|
|
41
|
+
document.addEventListener("yrby:synced", ({ target, detail }) => {
|
|
42
|
+
const editor = bindYourEditor(target, detail.doc, detail.provider)
|
|
43
|
+
detail.signal.addEventListener("abort", () => editor.destroy(), { once: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
Run with --channel to also generate app/channels/document_channel.rb,
|
|
47
|
+
and implement its authorized? method before you use it.
|
|
37
48
|
|
|
38
49
|
The README's Editors section links working integrations for
|
|
39
50
|
Tiptap, Lexxy, Rhino Editor, and CodeMirror.
|
|
@@ -16,11 +16,7 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
16
16
|
# and yrby-client retries it.
|
|
17
17
|
on_change { |key, update| Y::Document.append(key, update) }
|
|
18
18
|
|
|
19
|
-
def subscribed
|
|
20
|
-
return reject unless authorized?(params[:id])
|
|
21
|
-
|
|
22
|
-
sync_subscribed(params[:id])
|
|
23
|
-
end
|
|
19
|
+
def subscribed = sync_subscribed(params[:id])
|
|
24
20
|
|
|
25
21
|
def receive(data) = sync_receive(data, params[:id])
|
|
26
22
|
|
|
@@ -28,8 +24,9 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
28
24
|
|
|
29
25
|
# Everyone is denied until you fill this in. Wire it to your app's auth:
|
|
30
26
|
# identify current_user on the cable connection, then check they may read
|
|
31
|
-
# and write this document.
|
|
32
|
-
#
|
|
27
|
+
# and write this document. This runs once when the client subscribes, and
|
|
28
|
+
# the subscription stays authorized until it ends. Raising in on_change is
|
|
29
|
+
# for store failures, so don't use it for access control.
|
|
33
30
|
def authorized?(_document_key)
|
|
34
31
|
false
|
|
35
32
|
end
|
|
@@ -10,10 +10,13 @@ module Yrby
|
|
|
10
10
|
# yrby:install, and by other gems building on the same storage.
|
|
11
11
|
#
|
|
12
12
|
# Template notes (kept here, not in the emitted migration): state is
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# 1.gigabyte - 1: the largest limit every adapter accepts. MySQL maps
|
|
14
|
+
# anything over 16 MB to longblob (a compacted snapshot is the whole
|
|
15
|
+
# document; a 16 MB cap would break compaction); Postgres raises above
|
|
16
|
+
# 1 GB - 1 and ignores the limit on bytea otherwise; SQLite ignores
|
|
17
|
+
# limits. Payload is 16.megabytes - 1 (one update can carry a big paste
|
|
18
|
+
# or a client's accumulated offline edits; the 64 KB default blob is
|
|
19
|
+
# too small).
|
|
17
20
|
# The partial unique index's WHERE only keeps
|
|
18
21
|
# key-only rows out of the index: uniqueness holds without it, since
|
|
19
22
|
# unique indexes treat NULLs as distinct on every supported database,
|
|
@@ -6,7 +6,7 @@ class CreateYTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
6
6
|
t.string :key, null: false, index: { unique: true }
|
|
7
7
|
t.references :record, polymorphic: true, null: true, index: false
|
|
8
8
|
t.string :name
|
|
9
|
-
t.binary :state, limit:
|
|
9
|
+
t.binary :state, limit: 1.gigabyte - 1
|
|
10
10
|
t.timestamps
|
|
11
11
|
t.index %i[record_type record_id name], unique: true,
|
|
12
12
|
where: "record_type IS NOT NULL",
|
data/lib/y/3.4/yrby.so
CHANGED
|
Binary file
|
data/lib/y/4.0/yrby.so
CHANGED
|
Binary file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
module Collaborative
|
|
5
|
+
# The collaborative document for one attribute of one record, such as a
|
|
6
|
+
# post's body. Ruby code and the shipped channel both use it, so they read
|
|
7
|
+
# and write the same rows.
|
|
8
|
+
class Attribute
|
|
9
|
+
attr_reader :record, :name
|
|
10
|
+
|
|
11
|
+
def initialize(record, name)
|
|
12
|
+
raise ArgumentError, "collaboration requires a persisted record" unless record.persisted?
|
|
13
|
+
|
|
14
|
+
@record = record
|
|
15
|
+
@name = name.to_s.dup.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The document as update bytes, or nil before the first write.
|
|
19
|
+
def load_state = existing_row&.load_state
|
|
20
|
+
|
|
21
|
+
# Records one change and returns once it's saved. The channel waits for
|
|
22
|
+
# this to return before it acknowledges or broadcasts the change.
|
|
23
|
+
def append(update) = document_row.append(update)
|
|
24
|
+
|
|
25
|
+
# Builds a new Y::Doc from storage on every call.
|
|
26
|
+
def y_doc
|
|
27
|
+
Y::Doc.new.tap do |doc|
|
|
28
|
+
state = load_state
|
|
29
|
+
doc.apply_update(state) if state
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The key clients sync the document under, such as "post/1/body". If a
|
|
34
|
+
# key-only channel created the document before it was linked to a
|
|
35
|
+
# record, this returns that original key.
|
|
36
|
+
def key = existing_row&.key || Y::Document.key_for(record, name)
|
|
37
|
+
|
|
38
|
+
# Finds or creates the Y::Document or Y::EncryptedDocument row. Writes
|
|
39
|
+
# and maintenance work such as compaction go through it.
|
|
40
|
+
def document_row = document_class.for(record, name)
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Returns the row if it exists without creating one, so reading a
|
|
45
|
+
# document never adds a row. It selects only id and key because the
|
|
46
|
+
# row's load_state reads the snapshot itself, and loading the snapshot
|
|
47
|
+
# here too would fetch it twice.
|
|
48
|
+
def existing_row = document_class.select(:id, :key).find_by(record:, name:)
|
|
49
|
+
|
|
50
|
+
def document_class = record.class.collaborative_document_class(name)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
module Collaborative
|
|
5
|
+
# The view side of a collaborative document. The engine includes it into
|
|
6
|
+
# Action View.
|
|
7
|
+
#
|
|
8
|
+
# <%= collaborative_document_tag @post, :body %>
|
|
9
|
+
#
|
|
10
|
+
# renders the auto-connecting element:
|
|
11
|
+
#
|
|
12
|
+
# <yrby-document grant="<signed sgid>" name="body"></yrby-document>
|
|
13
|
+
#
|
|
14
|
+
# Importing "yrby-client/element" registers the element. It subscribes to
|
|
15
|
+
# Y::DocumentChannel by itself and exposes the synced Y.Doc through its
|
|
16
|
+
# `doc` property and `yrby:synced` event, much like the element behind
|
|
17
|
+
# turbo_stream_from.
|
|
18
|
+
#
|
|
19
|
+
# The grant is a signed GlobalID scoped to this record and attribute
|
|
20
|
+
# (record.collaborative_sgid(name)). Only render the tag on pages where
|
|
21
|
+
# the user may edit the record, because by default anyone holding the
|
|
22
|
+
# grant can open the document. To also check the user's current
|
|
23
|
+
# permissions when they subscribe, use Y::DocumentChannel.authorize_document.
|
|
24
|
+
#
|
|
25
|
+
# Extra options pass through to the element and a block becomes its
|
|
26
|
+
# content, so the element can wrap the mount point an editor binds to:
|
|
27
|
+
#
|
|
28
|
+
# <%= collaborative_document_tag @post, :body, id: "editor" %>
|
|
29
|
+
#
|
|
30
|
+
# expires_in: limits how long the grant lasts. refresh: is a URL the
|
|
31
|
+
# element fetches a new grant from when a subscription is rejected, so it
|
|
32
|
+
# can resubscribe without a page load. The app's action there re-runs its
|
|
33
|
+
# own authorization and renders { grant: record.collaborative_sgid(name) }:
|
|
34
|
+
#
|
|
35
|
+
# <%= collaborative_document_tag @post, :body, expires_in: 10.minutes,
|
|
36
|
+
# refresh: grant_post_path(@post) %>
|
|
37
|
+
module Helper
|
|
38
|
+
def collaborative_document_tag(record, name, expires_in: nil, refresh: nil, **, &)
|
|
39
|
+
grant = record.collaborative_sgid(name, **{ expires_in: expires_in }.compact)
|
|
40
|
+
attributes = { grant: grant, name: name }
|
|
41
|
+
attributes[:refresh] = refresh if refresh
|
|
42
|
+
# The helper's attributes go last because a later key wins in a keyword
|
|
43
|
+
# splat, so a template can't override the grant or the name.
|
|
44
|
+
tag.yrby_document(**, **attributes, &)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "global_id"
|
|
5
|
+
|
|
6
|
+
module Y
|
|
7
|
+
# The signed token that connects a page to a channel for record-backed
|
|
8
|
+
# collaborative documents.
|
|
9
|
+
#
|
|
10
|
+
# The client never picks its own document. The server signs a token for the
|
|
11
|
+
# record and attribute, the page renders it, and the channel looks the
|
|
12
|
+
# record up from it. The token is a signed GlobalID scoped to one attribute.
|
|
13
|
+
#
|
|
14
|
+
# # the view
|
|
15
|
+
# tag.div data: { grant: post.collaborative_sgid(:body) }
|
|
16
|
+
#
|
|
17
|
+
# # the channel
|
|
18
|
+
# def authorized?(_key)
|
|
19
|
+
# record.present? && record.editable_by?(current_user)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # Not memoized, because AnyCable gives each command a fresh channel
|
|
23
|
+
# # instance and a cached record would go stale. Y::DocumentChannel
|
|
24
|
+
# # does the same.
|
|
25
|
+
# def record
|
|
26
|
+
# Y::Collaborative.locate(params[:grant], :body)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# The engine includes this into ActiveRecord::Base so any channel's
|
|
30
|
+
# authorized? can use it. lexxy-realtime uses the same token flow.
|
|
31
|
+
module Collaborative
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# The signed-GlobalID purpose for one collaborative attribute. A token
|
|
36
|
+
# signed for one attribute verifies only against that attribute's
|
|
37
|
+
# purpose, so it can't locate the record through any other attribute.
|
|
38
|
+
# The purpose leaves out the channel name, so a custom channel and the
|
|
39
|
+
# shipped one can both resolve the same tokens for an attribute.
|
|
40
|
+
def sgid_purpose(name) = "yrby/#{name}"
|
|
41
|
+
|
|
42
|
+
# Looks up the record for a token from `collaborative_sgid(name)`.
|
|
43
|
+
# Returns nil for an invalid, tampered, expired, or wrong-attribute
|
|
44
|
+
# token, and for a record that no longer exists.
|
|
45
|
+
def locate(sgid, name)
|
|
46
|
+
GlobalID::Locator.locate_signed(sgid, for: sgid_purpose(name))
|
|
47
|
+
rescue ActiveRecord::RecordNotFound
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
included do
|
|
53
|
+
class_attribute :collaborative_document_options,
|
|
54
|
+
instance_accessor: false, default: {}.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class_methods do
|
|
58
|
+
# Declares how one attribute's document is stored. The shipped channel
|
|
59
|
+
# and Ruby reads both follow it, and undeclared attributes use plain
|
|
60
|
+
# Y::Document.
|
|
61
|
+
def has_collaborative_document(name, encrypted: false) # rubocop:disable Naming/PredicatePrefix
|
|
62
|
+
self.collaborative_document_options = collaborative_document_options.merge(
|
|
63
|
+
name.to_s => { encrypted: encrypted }.freeze
|
|
64
|
+
).freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the model that stores this attribute's document,
|
|
68
|
+
# Y::EncryptedDocument for an attribute declared encrypted and
|
|
69
|
+
# Y::Document for the rest. It resolves the constant on every call so
|
|
70
|
+
# that declaring an attribute doesn't load the engine's models while the
|
|
71
|
+
# app's models are still loading.
|
|
72
|
+
def collaborative_document_class(name)
|
|
73
|
+
encrypted = collaborative_document_options.dig(name.to_s, :encrypted)
|
|
74
|
+
encrypted ? Y::EncryptedDocument : Y::Document
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the document for one attribute, stored the way the model
|
|
79
|
+
# declared. It responds to load_state, append, y_doc, and key.
|
|
80
|
+
def collaborative_document(name)
|
|
81
|
+
Attribute.new(self, name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A signed token that a channel can pass to Y::Collaborative.locate to get
|
|
85
|
+
# this record back, for this attribute only. Pass expires_in: to limit how
|
|
86
|
+
# long the grant lasts. Without it GlobalID's own default applies, which is
|
|
87
|
+
# one month under Rails. We pass the option through only when it's given,
|
|
88
|
+
# because an explicit nil would mean "never expire".
|
|
89
|
+
def collaborative_sgid(name, expires_in: nil)
|
|
90
|
+
options = { for: Y::Collaborative.sgid_purpose(name) }
|
|
91
|
+
options[:expires_in] = expires_in if expires_in
|
|
92
|
+
to_sgid(**options).to_s
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
require "y/collaborative/attribute"
|
|
98
|
+
require "y/collaborative/helper"
|
data/lib/y/lexxy.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Y
|
|
4
4
|
# The Lexxy renderer: Y::Lexical (core Lexical) plus the Lexxy-specific
|
|
5
|
-
# schema, applied beneath the app's rules
|
|
5
|
+
# schema, applied beneath the app's rules: an app rule for one of these
|
|
6
6
|
# types simply replaces it. This is the byte-parity class: the fixture
|
|
7
7
|
# tests hold `Y::Lexxy.new(doc).to_html` identical to a live editor's own
|
|
8
8
|
# serialized value.
|
|
@@ -36,14 +36,14 @@ module Y
|
|
|
36
36
|
%(<th class="lexxy-content__table-cell--header" #{style}>#{node.content}</th>)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# Attribute order follows Lexxy's export
|
|
39
|
+
# Attribute order follows Lexxy's export: checked items put aria-checked
|
|
40
40
|
# before value; items holding a nested list append the
|
|
41
41
|
# lexxy-nested-listitem class after value.
|
|
42
42
|
def self.list_item(node)
|
|
43
43
|
out = +"<li"
|
|
44
44
|
checked = node.attrs["__checked"]
|
|
45
|
-
out << %( aria-checked="#{checked}") unless checked.nil?
|
|
46
|
-
out << %( value="#{node.attrs["__value"] || 1}")
|
|
45
|
+
out << %( aria-checked="#{RenderRules.escape_attr(checked)}") unless checked.nil?
|
|
46
|
+
out << %( value="#{RenderRules.escape_attr(node.attrs["__value"] || 1)}")
|
|
47
47
|
out << %( class="lexxy-nested-listitem") if node.child_types.include?("list")
|
|
48
48
|
"#{out}>#{node.content}</li>"
|
|
49
49
|
end
|
data/lib/y/rendering.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Y
|
|
|
10
10
|
# `marks:`. A rule is consulted before the built-in schema, so it can add a
|
|
11
11
|
# custom node or override a built-in one.
|
|
12
12
|
#
|
|
13
|
-
# The usual way in is the block form
|
|
13
|
+
# The usual way in is the block form, one `rules.node` call per type,
|
|
14
14
|
# keyword options for markup-as-data, a Ruby block for logic (see Builder
|
|
15
15
|
# below). `contains:` says what's inside the node: :inline (formatted
|
|
16
16
|
# text, the default), :blocks (child block nodes), or :none (a leaf). The
|
|
@@ -26,7 +26,7 @@ module Y
|
|
|
26
26
|
# attrs: { "class" => ["callout callout--", :kind] },
|
|
27
27
|
# contains: :blocks
|
|
28
28
|
# end
|
|
29
|
-
# `tag` is the element; `attrs` values are templates
|
|
29
|
+
# `tag` is the element; `attrs` values are templates: a String literal,
|
|
30
30
|
# a Symbol referencing one of the node's stored attributes, or an Array
|
|
31
31
|
# mixing both (an attribute that resolves empty is omitted); `text` is a
|
|
32
32
|
# template for literal text content; `void: true` emits no closing tag;
|
|
@@ -45,20 +45,20 @@ module Y
|
|
|
45
45
|
# document is locked) and receives a RenderRules::Node with the node's
|
|
46
46
|
# type, stored attributes, children already rendered to HTML, and
|
|
47
47
|
# child_types (its element/block children by type). Its return value is
|
|
48
|
-
# spliced in verbatim
|
|
48
|
+
# spliced in verbatim: it is trusted HTML, so escape any attribute
|
|
49
49
|
# values you interpolate.
|
|
50
50
|
#
|
|
51
51
|
# Mark rules (ProseMirror only) are declarative: `tag` plus `attrs`
|
|
52
52
|
# templates whose Symbol refs resolve against the mark's own attributes. A
|
|
53
53
|
# custom mark wraps outside every built-in mark; several custom marks nest
|
|
54
54
|
# alphabetically. A rule for a built-in mark's stored name replaces its
|
|
55
|
-
# wrap (the markup changes, the semantics don't
|
|
55
|
+
# wrap (the markup changes, the semantics don't: an overridden code mark
|
|
56
56
|
# still excludes the other formatting).
|
|
57
57
|
module RenderRules
|
|
58
58
|
# What a callback receives. `attrs` keys are as stored (Lexical's own
|
|
59
59
|
# props keep their "__" prefix); `content` is the node's children,
|
|
60
60
|
# already rendered to an HTML string; `child_types` lists the node's
|
|
61
|
-
# element/block children by type, in document order
|
|
61
|
+
# element/block children by type, in document order: the structural
|
|
62
62
|
# facts attrs and content can't answer (a gallery's image count, whether
|
|
63
63
|
# a list item holds a nested list).
|
|
64
64
|
Node = Data.define(:type, :attrs, :content, :child_types)
|
|
@@ -137,7 +137,7 @@ module Y
|
|
|
137
137
|
# from stored values. Text content escapes `&`, `<`, `>` (quotes stay
|
|
138
138
|
# literal, matching the browser serializer); attribute values also
|
|
139
139
|
# escape `"`. Prefer these over ERB::Util.html_escape when byte parity
|
|
140
|
-
# with editor output matters
|
|
140
|
+
# with editor output matters: html_escape also rewrites apostrophes.
|
|
141
141
|
def escape_text(value)
|
|
142
142
|
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
143
143
|
end
|
|
@@ -212,10 +212,10 @@ module Y
|
|
|
212
212
|
# they compile the rules config, hold the callbacks, and splice deferred
|
|
213
213
|
# segments after a render. The native handle does everything else.
|
|
214
214
|
class Lexical
|
|
215
|
-
# `Y::Lexical.new(doc, nodes: { "type" => rule })
|
|
215
|
+
# `Y::Lexical.new(doc, nodes: { "type" => rule })`; see Y::RenderRules
|
|
216
216
|
# for the rule forms. This is core Lexical only: paragraphs, headings,
|
|
217
217
|
# quotes, code, lists, tables, links, text formatting. Editor-specific
|
|
218
|
-
# nodes arrive as rules
|
|
218
|
+
# nodes arrive as rules. Y::Lexxy subclasses this with the Lexxy schema;
|
|
219
219
|
# a different Lexical editor brings its own rule set the same way.
|
|
220
220
|
def initialize(doc, nodes: {})
|
|
221
221
|
builder = RenderRules::Builder.new(marks_allowed: false)
|
|
@@ -232,23 +232,35 @@ module Y
|
|
|
232
232
|
RenderRules.splice(result, @render_callbacks)
|
|
233
233
|
end
|
|
234
234
|
|
|
235
|
-
# What node types this document actually contains
|
|
235
|
+
# What node types this document actually contains: the discovery aid
|
|
236
236
|
# for writing rules. Facts per type: "count", "attrs" (names as stored),
|
|
237
237
|
# "children" (child node types), "text" (whether it holds text runs),
|
|
238
|
-
# and "handled" ("builtin", "rule", or nil
|
|
238
|
+
# and "handled" ("builtin", "rule", or nil; nil marks the types you
|
|
239
239
|
# still need a rule for). Children plus text is how you pick contains:.
|
|
240
240
|
def node_types(root = nil)
|
|
241
241
|
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
242
242
|
json && JSON.parse(json)
|
|
243
243
|
end
|
|
244
|
+
|
|
245
|
+
# The node types to_html can't fully render: types in the document that
|
|
246
|
+
# neither the built-in schema nor a registered rule handles. An unknown
|
|
247
|
+
# container or inline wrapper renders its children without its own
|
|
248
|
+
# markup, so its text stays. An unknown decorator renders nothing, so
|
|
249
|
+
# content kept only in its attributes is missing from the HTML. Returns
|
|
250
|
+
# [] when every type is handled, and when the root is missing or isn't
|
|
251
|
+
# Lexical-shaped, because to_html returns nil there.
|
|
252
|
+
def unknown_types(root = nil)
|
|
253
|
+
types = node_types(root)
|
|
254
|
+
types ? types.filter_map { |type, info| type if info["handled"].nil? } : []
|
|
255
|
+
end
|
|
244
256
|
end
|
|
245
257
|
|
|
246
258
|
class ProseMirror
|
|
247
|
-
# `Y::ProseMirror.new(doc, nodes: {...}, marks: {...})
|
|
259
|
+
# `Y::ProseMirror.new(doc, nodes: {...}, marks: {...})`; see
|
|
248
260
|
# Y::RenderRules for the rule forms. This is core ProseMirror only:
|
|
249
261
|
# prosemirror-schema-basic plus the prosemirror-tables family, and the
|
|
250
|
-
# full mark set (marks are native
|
|
251
|
-
# Editor-specific nodes arrive as rules
|
|
262
|
+
# full mark set (marks are native; see `rules.mark` for overrides).
|
|
263
|
+
# Editor-specific nodes arrive as rules. Y::Tiptap subclasses this with
|
|
252
264
|
# Tiptap's extension nodes; a different ProseMirror editor brings its
|
|
253
265
|
# own rule set the same way.
|
|
254
266
|
def initialize(doc, nodes: {}, marks: {})
|
|
@@ -267,15 +279,27 @@ module Y
|
|
|
267
279
|
RenderRules.splice(result, @render_callbacks)
|
|
268
280
|
end
|
|
269
281
|
|
|
270
|
-
# What node types this document actually contains
|
|
282
|
+
# What node types this document actually contains: the discovery aid
|
|
271
283
|
# for writing rules. Facts per type: "count", "attrs" (names as stored),
|
|
272
284
|
# "children" (child node types), "text" (whether it holds text runs),
|
|
273
|
-
# and "handled" ("builtin", "rule", or nil
|
|
285
|
+
# and "handled" ("builtin", "rule", or nil; nil marks the types you
|
|
274
286
|
# still need a rule for). Children plus text is how you pick contains:.
|
|
275
287
|
def node_types(root = nil)
|
|
276
288
|
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
277
289
|
json && JSON.parse(json)
|
|
278
290
|
end
|
|
291
|
+
|
|
292
|
+
# The node types to_html can't fully render: types in the document that
|
|
293
|
+
# neither the built-in schema nor a registered rule handles. An unknown
|
|
294
|
+
# node renders its text and children without its own markup. A node that
|
|
295
|
+
# keeps its content only in its attributes renders nothing. Marks aren't
|
|
296
|
+
# listed, and an unknown mark renders its text unformatted. Returns []
|
|
297
|
+
# when every type is handled, and when the root is missing or isn't
|
|
298
|
+
# ProseMirror-shaped, because to_html returns nil there.
|
|
299
|
+
def unknown_types(root = nil)
|
|
300
|
+
types = node_types(root)
|
|
301
|
+
types ? types.filter_map { |type, info| type if info["handled"].nil? } : []
|
|
302
|
+
end
|
|
279
303
|
end
|
|
280
304
|
|
|
281
305
|
private_constant :NativeLexical, :NativeProseMirror
|
data/lib/y/tiptap.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Y
|
|
4
|
-
# The Tiptap renderer: Y::ProseMirror (core ProseMirror
|
|
5
|
-
# tables) plus Tiptap's extension nodes, applied beneath the app's rules
|
|
4
|
+
# The Tiptap renderer: Y::ProseMirror (core ProseMirror, schema-basic plus
|
|
5
|
+
# tables) plus Tiptap's extension nodes, applied beneath the app's rules,
|
|
6
6
|
# an app rule for one of these types simply replaces it. This is the
|
|
7
7
|
# byte-parity class: the fixture tests hold `Y::Tiptap.new(doc).to_html`
|
|
8
8
|
# identical to a live editor's own `getHTML()`.
|
|
9
9
|
#
|
|
10
10
|
# Tiptap's marks (underline, highlight, sub/superscript, textStyle) render
|
|
11
|
-
# natively in the base class
|
|
11
|
+
# natively in the base class: mark serialization is text-run machinery
|
|
12
12
|
# the rule system can't express.
|
|
13
13
|
class Tiptap < ProseMirror
|
|
14
14
|
# Tiptap's TaskItem markup: the data-checked flag (false when unset), a
|
data/lib/y/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -72,6 +72,9 @@ files:
|
|
|
72
72
|
- lib/y.rb
|
|
73
73
|
- lib/y/3.4/yrby.so
|
|
74
74
|
- lib/y/4.0/yrby.so
|
|
75
|
+
- lib/y/collaborative.rb
|
|
76
|
+
- lib/y/collaborative/attribute.rb
|
|
77
|
+
- lib/y/collaborative/helper.rb
|
|
75
78
|
- lib/y/decoder.rb
|
|
76
79
|
- lib/y/lexxy.rb
|
|
77
80
|
- lib/y/rendering.rb
|