portable_text 0.1.0

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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +28 -0
  3. data/.rubocop_todo.yml +85 -0
  4. data/LICENSE +21 -0
  5. data/README.md +357 -0
  6. data/Rakefile +15 -0
  7. data/lib/portable_text/block_types/base.rb +19 -0
  8. data/lib/portable_text/block_types/block.rb +6 -0
  9. data/lib/portable_text/block_types/image.rb +6 -0
  10. data/lib/portable_text/block_types/list.rb +64 -0
  11. data/lib/portable_text/block_types/null.rb +6 -0
  12. data/lib/portable_text/block_types/span.rb +12 -0
  13. data/lib/portable_text/config.rb +27 -0
  14. data/lib/portable_text/errors/unimplemented_error.rb +9 -0
  15. data/lib/portable_text/errors/unknown_serializer_error.rb +9 -0
  16. data/lib/portable_text/html/base_component.rb +19 -0
  17. data/lib/portable_text/html/block_types/block.rb +32 -0
  18. data/lib/portable_text/html/block_types/image.rb +17 -0
  19. data/lib/portable_text/html/block_types/list.rb +29 -0
  20. data/lib/portable_text/html/block_types/null.rb +13 -0
  21. data/lib/portable_text/html/block_types/span.rb +84 -0
  22. data/lib/portable_text/html/config.rb +48 -0
  23. data/lib/portable_text/html/configured.rb +11 -0
  24. data/lib/portable_text/html/mark_defs/base.rb +14 -0
  25. data/lib/portable_text/html/mark_defs/link.rb +13 -0
  26. data/lib/portable_text/html/mark_defs/null.rb +13 -0
  27. data/lib/portable_text/html/rendering.rb +7 -0
  28. data/lib/portable_text/html/serializer.rb +17 -0
  29. data/lib/portable_text/mark_defs/base.rb +10 -0
  30. data/lib/portable_text/mark_defs/link.rb +7 -0
  31. data/lib/portable_text/mark_defs/null.rb +6 -0
  32. data/lib/portable_text/plain/serializer.rb +34 -0
  33. data/lib/portable_text/serializer.rb +97 -0
  34. data/lib/portable_text/version.rb +5 -0
  35. data/lib/portable_text.rb +20 -0
  36. data/sig/portable_text.rbs +4 -0
  37. metadata +196 -0
@@ -0,0 +1,19 @@
1
+ # Base component for PortableText HTML components
2
+ # It overrides Dry::Initializer option and params to allow using them without triggering weird errors
3
+ # See https://github.com/orgs/phlex-ruby/discussions/553
4
+
5
+ module PortableText
6
+ module Html
7
+ class BaseComponent < Phlex::HTML
8
+ extend Dry::Initializer
9
+
10
+ def self.option(*args, **kwargs, &block)
11
+ super(*args, reader: false, **kwargs, &block)
12
+ end
13
+
14
+ def self.param(*args, **kwargs, &block)
15
+ super(*args, reader: false, **kwargs, &block)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ module PortableText
2
+ module Html
3
+ module BlockTypes
4
+ class Block < Html::BaseComponent
5
+ include Configured
6
+
7
+ param :block
8
+ delegate :style, :children, :mark_defs, :list_item, to: :@block
9
+
10
+ def view_template
11
+ node_style = node.fetch(:node)
12
+ node_arguments = node.except(:node)
13
+
14
+ send(node_style, **node_arguments) do
15
+ children.each do |child|
16
+ render block_type(:span).new(child, mark_defs: mark_defs)
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def node
24
+ return @node if defined?(@node)
25
+ return @node = config.block.styles.fetch(:li) if list_item.present?
26
+
27
+ @node = config.block.styles.fetch(style&.to_sym, { node: :p })
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ module PortableText
2
+ module Html
3
+ module BlockTypes
4
+ class Image < Html::BaseComponent
5
+ param :image
6
+
7
+ def view_template
8
+ if @image.asset.key?("url")
9
+ img(src: @image.asset["url"])
10
+ else
11
+ div { "Please provide a url for this image" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ module PortableText
2
+ module Html
3
+ module BlockTypes
4
+ class List < Html::BaseComponent
5
+ include Configured
6
+
7
+ param :list
8
+ delegate :items, :list_type, to: :@list
9
+
10
+ def view_template
11
+ node_style = node.fetch(:node)
12
+ node_arguments = node.except(:node)
13
+
14
+ send(node_style, **node_arguments) do
15
+ items.each do |block|
16
+ render block_type(block.type).new(block)
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def node
24
+ @node ||= config.block.list_types.fetch(list_type.to_sym, { node: :ul })
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ module PortableText
2
+ module Html
3
+ module BlockTypes
4
+ class Null < Html::BaseComponent
5
+ param :block
6
+
7
+ def view_template
8
+ div { "This block type is not referenced yet: #{@block.type}" }
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,84 @@
1
+ module PortableText
2
+ module Html
3
+ module BlockTypes
4
+ class Span < Html::BaseComponent
5
+ include Configured
6
+
7
+ param :span
8
+ option :mark_defs, default: proc { [] }
9
+ delegate :marks, :text, to: :@span
10
+
11
+ def view_template
12
+ root = create_nodes
13
+ visit(root)
14
+ end
15
+
16
+ private
17
+
18
+ # Visit a node and render it
19
+ # If the node is a mark definition, render the appropriate mark definition
20
+ # Otherwise, render the node with the appropriate mark decorator
21
+ # If the node has a child, visit the child
22
+ # Else render the node as plain text
23
+ # -------------
24
+ # Mark definitions and mark decorators are defined in the configuration
25
+ # If a mark definition is not found, a Null mark definition is rendered along with an error message
26
+ # if a mark is not found, a span tag is rendered
27
+ def visit(node)
28
+ return plain(node.value) if node.child.nil?
29
+
30
+ if matching_mark_def?(node.value)
31
+ annotation = @mark_defs.find { |mark_def| mark_def.key == node.value }
32
+
33
+ render annotation_klass(annotation).new(annotation) do
34
+ visit(node.child)
35
+ end
36
+ else
37
+ decorator = config.span.marks.fetch(node.value.to_sym, { node: :span })
38
+ mark_node = decorator.fetch(:node)
39
+ node_arguments = decorator.except(:node)
40
+
41
+ send(mark_node, **node_arguments) { visit(node.child) }
42
+ end
43
+ end
44
+
45
+ def annotation_klass(annotation)
46
+ config.block.mark_defs.fetch(annotation.type.to_sym, Html::MarkDefs::Null)
47
+ end
48
+
49
+ def matching_mark_def?(mark)
50
+ return false unless @mark_defs.present?
51
+
52
+ @mark_defs.map(&:key).include?(mark)
53
+ end
54
+
55
+ class Node
56
+ attr_accessor :value, :child
57
+
58
+ def initialize(value:, child: nil)
59
+ @value = value
60
+ @child = child
61
+ end
62
+ end
63
+
64
+ # Create a linked list of nodes to make it easier to traverse the tree of marks
65
+ # Marks are traversed in the marks order and the text is the last node
66
+ def create_nodes
67
+ nodes = marks + [text]
68
+
69
+ root = Node.new(value: nodes.first)
70
+ return root if nodes.size == 1
71
+
72
+ current_node = root
73
+
74
+ nodes[1..].each do |mark|
75
+ current_node.child = Node.new(value: mark)
76
+ current_node = current_node.child
77
+ end
78
+
79
+ root
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,48 @@
1
+ module PortableText
2
+ module Html
3
+ class Config
4
+ extend Dry::Configurable
5
+
6
+ # Default settings
7
+ # These can be overridden
8
+ # Example: PortableText::Html.config.block.types.merge!({ block: MyCustomBlock })
9
+
10
+ setting :block do
11
+ setting :types, default: {
12
+ block: Html::BlockTypes::Block,
13
+ image: Html::BlockTypes::Image,
14
+ list: Html::BlockTypes::List,
15
+ span: Html::BlockTypes::Span
16
+ }
17
+
18
+ setting :styles, default: {
19
+ h1: { node: :h1 },
20
+ h2: { node: :h2 },
21
+ h3: { node: :h3 },
22
+ h4: { node: :h4 },
23
+ h5: { node: :h5 },
24
+ h6: { node: :h6 },
25
+ blockquote: { node: :blockquote },
26
+ normal: { node: :p },
27
+ li: { node: :li }
28
+ }
29
+
30
+ setting :mark_defs, default: {
31
+ link: Html::MarkDefs::Link
32
+ }
33
+
34
+ setting :list_types, default: {
35
+ bullet: { node: :ul },
36
+ numeric: { node: :ol }
37
+ }
38
+ end
39
+
40
+ setting :span do
41
+ setting :marks, default: {
42
+ strong: { node: :strong },
43
+ em: { node: :em }
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,11 @@
1
+ module PortableText
2
+ module Html
3
+ module Configured
4
+ def config = Config.config
5
+
6
+ def block_type(type)
7
+ config.block.types.fetch(type.to_sym, BlockTypes::Null)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module PortableText
2
+ module Html
3
+ module MarkDefs
4
+ class Base < Html::BaseComponent
5
+ param :mark_def
6
+ delegate :type, :key, to: :@mark_def
7
+
8
+ def view_template
9
+ raise PortableText::Errors::UnimplementedError
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module PortableText
2
+ module Html
3
+ module MarkDefs
4
+ class Link < Base
5
+ delegate :href, to: :@mark_def
6
+
7
+ def view_template(&block)
8
+ a(href: href, &block)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module PortableText
2
+ module Html
3
+ module MarkDefs
4
+ class Null < Base
5
+ def view_template
6
+ div do
7
+ "Missing mark def html renderer for type: #{type} - key: #{key}"
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ module PortableText
2
+ module Html
3
+ module Rendering
4
+ def render(view, **parameters) = view.call(**parameters)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ module PortableText
2
+ module Html
3
+ class Serializer < Html::BaseComponent
4
+ include Configured
5
+
6
+ param :blocks
7
+
8
+ def content(**_options) = self
9
+
10
+ def view_template
11
+ @blocks.each do |block|
12
+ render block_type(block.type).new(block)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ module PortableText
2
+ module MarkDefs
3
+ class Base
4
+ extend Dry::Initializer
5
+
6
+ option :_key, as: :key
7
+ option :_type, as: :type
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ module PortableText
2
+ module MarkDefs
3
+ class Link < Base
4
+ option :href, default: proc { "" }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module PortableText
2
+ module MarkDefs
3
+ class Null < Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,34 @@
1
+ module PortableText
2
+ module Plain
3
+ class Serializer
4
+ def initialize(blocks)
5
+ @blocks = blocks
6
+ end
7
+
8
+ def content(**_params)
9
+ visit(@blocks)
10
+ end
11
+
12
+ private
13
+
14
+ def visit(nodes)
15
+ nodes.map do |block|
16
+ case block.type
17
+ when "block"
18
+ block.children.map(&:text).join(" ").squeeze.strip
19
+ when "list"
20
+ visit(block.items)
21
+ when "image"
22
+ if block.asset.present?
23
+ block.asset["url"]
24
+ else
25
+ "image url not found"
26
+ end
27
+ else
28
+ "Block #{block.type} found"
29
+ end
30
+ end.join("\n")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,97 @@
1
+ module PortableText
2
+ class Serializer
3
+ attr_reader :content, :blocks, :to, :converted
4
+
5
+ def initialize(content:, to: :html)
6
+ @content = content
7
+ @blocks = []
8
+ @to = to
9
+ @converted = false
10
+ end
11
+
12
+ # After conversion, the Portable Text content is serialized to the desired format.
13
+ def render(**options)
14
+ convert!
15
+
16
+ serializer.new(blocks).content(**options)
17
+ end
18
+
19
+ # Converts the Portable Text content into a collection of blocks converted to ruby objects
20
+ # along with their children and markDefs.
21
+ # Object parameters are symbolized and camelCase keys are converted to snake_case.
22
+ # This method is idempotent.
23
+ def convert!
24
+ return if converted
25
+
26
+ content.each do |block_params|
27
+ params = block_params.transform_keys(&:to_sym)
28
+ params[:children] = create_children(params[:children])
29
+ params[:markDefs] = create_mark_defs(params[:markDefs])
30
+
31
+ block = block_klass(params.fetch(:_type)).new(**params)
32
+ add_block(block)
33
+ end
34
+
35
+ @converted = true
36
+ end
37
+
38
+ # The serializer is determined by the `to` parameter, which defaults to `:html`.
39
+ # The serializer must be defined in the PortableText configuration.
40
+ def serializer
41
+ config.serializers.fetch(to) { raise Errors::UnknownSerializerError }
42
+ end
43
+
44
+ private
45
+
46
+ def config = PortableText.config
47
+
48
+ def block_klass(type)
49
+ config.block.types.fetch(type.to_sym, BlockTypes::Null)
50
+ end
51
+
52
+ # Adds a block to the blocks collection.
53
+ # If the block is a list item, it will be added to the last list block if it exists.
54
+ # Else a new list block will be created.
55
+ def add_block(block)
56
+ return blocks.push(block) unless block.list_item?
57
+
58
+ last_block = blocks.last
59
+
60
+ if last_block&.list?
61
+ last_block.add(block)
62
+ else
63
+ blocks.push(
64
+ block_klass(:list).new(
65
+ items: [block],
66
+ level: block.level,
67
+ parent: nil
68
+ )
69
+ )
70
+ end
71
+ end
72
+
73
+ def create_children(children)
74
+ return [] if children.blank?
75
+
76
+ children.map do |child|
77
+ block_klass(:span).new(**child.transform_keys(&:to_sym))
78
+ end
79
+ end
80
+
81
+ def create_mark_defs(mark_defs)
82
+ return [] if mark_defs.blank?
83
+
84
+ inflector = Dry::Inflector.new
85
+
86
+ mark_defs.map do |mark_def|
87
+ mark_def.transform_keys!(&:to_sym)
88
+ mark_type = inflector.underscore(mark_def[:_type]).to_sym
89
+
90
+ config.block.mark_defs.fetch(
91
+ mark_type,
92
+ MarkDefs::Null
93
+ ).new(**mark_def.merge(_type: mark_type))
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PortableText
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext"
5
+ require "dry/configurable"
6
+ require "dry/inflector"
7
+ require "dry/initializer"
8
+ require "zeitwerk"
9
+ require "phlex"
10
+
11
+ module PortableText
12
+ def self.config = Config.config
13
+
14
+ module Html
15
+ def self.config = Html::Config.config
16
+ end
17
+ end
18
+
19
+ loader = Zeitwerk::Loader.for_gem
20
+ loader.setup
@@ -0,0 +1,4 @@
1
+ module PortableText
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end