First step rendering a page

The API responds with a bunch of paragraphs which the client converts
into Paragraph objects.

This turns the paragraphs in a PostResponse's Paragraph objects into the
form needed to render them on a page. This includes converting flat list
elements into list elements nested by a UL. And adding a limited markups
along the way.

The array of paragraphs is passed to a recursive function. The function
takes the first paragraph and either wraps the (marked up) contents in a
container tag (like Paragraph or Heading3), and then moves onto the next
tag. If it finds a list, it starts parsing the next paragraphs as a list
instead.

Originally, this was implemented like so:

```crystal
paragraph = paragraphs.shift
if list?
  convert_list([paragraph] + paragraphs)
end
```

However, passing the `paragraphs` after adding it to the already shifted
`paragraph` creates a new object. This means `paragraphs` won't be
mutated and once the list is parsed, it starts with the next element of
the list. Instead, the element is `shift`ed inside each converter.

```crystal
if paragraphs.first == list?
  convert_list(paragraphs)
end

def convert_list(paragraphs)
  paragraph = paragraphs.shift
  # ...
end
```

When rendering, there is an Empty and Container object. These represent
a kind of "null object" for both leafs and parent objects respectively.
They should never actually render. Emptys are filtered out, and
Containers are never created explicitly but this will make the types
pass.

IFrames are a bit of a special case. Each IFrame has custom data on it
that this system would need to be aware of. For now, instead of trying
to parse the seemingly large number of iframe variations and dealing
with embedded iframe problems, this will just keep track of the source
page URL and send the user there with a link.
This commit is contained in:
Edward Loveall 2021-05-16 14:14:25 -04:00
parent fe2f3ebe80
commit 5a5f68bcf8
No known key found for this signature in database
GPG key ID: 789A4AE983AC8901
21 changed files with 1003 additions and 43 deletions

View file

@ -0,0 +1,27 @@
require "../spec_helper"
include Nodes
describe IFrameMediaResolver do
around_each do |example|
original_client = IFrameMediaResolver.http_client
IFrameMediaResolver.http_client = FakeMediumClient
example.run
IFrameMediaResolver.http_client = original_client
end
it "returns a url of the embedded page" do
iframe = PostResponse::IFrame.from_json <<-JSON
{
"mediaResource": {
"id": "d4515fff7ecd02786e75fc8997c94bbf"
}
}
JSON
resolver = IFrameMediaResolver.new(iframe: iframe)
result = resolver.fetch_href
result.should eq("https://example.com")
end
end

View file

@ -0,0 +1,99 @@
require "../spec_helper"
include Nodes
describe MarkupConverter do
it "returns just text with no markups" do
json = <<-JSON
{
"text": "Hello, world",
"type": "P",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
JSON
paragraph = PostResponse::Paragraph.from_json(json)
result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
result.should eq([Text.new(content: "Hello, world")])
end
it "returns just text with multiple markups" do
json = <<-JSON
{
"text": "strong and emphasized only",
"type": "P",
"markups": [
{
"title": null,
"type": "STRONG",
"href": null,
"start": 0,
"end": 6,
"rel": null,
"anchorType": null
},
{
"title": null,
"type": "EM",
"href": null,
"start": 11,
"end": 21,
"rel": null,
"anchorType": null
}
],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
JSON
paragraph = PostResponse::Paragraph.from_json(json)
result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
result.should eq([
Strong.new(children: [Text.new(content: "strong")] of Child),
Text.new(content: " and "),
Emphasis.new(children: [Text.new(content: "emphasized")] of Child),
Text.new(content: " only"),
])
end
it "returns just text with a code markup" do
json = <<-JSON
{
"text": "inline code",
"type": "P",
"markups": [
{
"title": null,
"type": "CODE",
"href": null,
"start": 7,
"end": 11,
"rel": null,
"anchorType": null
}
],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
JSON
paragraph = PostResponse::Paragraph.from_json(json)
result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups)
result.should eq([
Text.new(content: "inline "),
Code.new(children: [Text.new(content: "code")] of Child),
])
end
end

View file

@ -0,0 +1,271 @@
require "../spec_helper"
include Nodes
describe ParagraphConverter do
around_each do |example|
original_client = IFrameMediaResolver.http_client
IFrameMediaResolver.http_client = FakeMediumClient
example.run
IFrameMediaResolver.http_client = original_client
end
it "converts a simple structure with no markups" do
paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
[
{
"text": "Title",
"type": "H3",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
]
JSON
expected = [Heading3.new(children: [Text.new(content: "Title")] of Child)]
result = ParagraphConverter.new.convert(paragraphs)
result.should eq expected
end
it "converts a simple structure with a markup" do
paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
[
{
"text": "inline code",
"type": "P",
"markups": [
{
"name": null,
"title": null,
"type": "CODE",
"href": null,
"start": 7,
"end": 11,
"rel": null,
"anchorType": null
}
],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
]
JSON
expected = [
Paragraph.new(children: [
Text.new(content: "inline "),
Code.new(children: [Text.new(content: "code")] of Child),
] of Child)
]
result = ParagraphConverter.new.convert(paragraphs)
result.should eq expected
end
it "groups <ul> list items into one list" do
paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
[
{
"text": "One",
"type": "ULI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "Two",
"type": "ULI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "Not a list item",
"type": "P",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
]
JSON
expected = [
UnorderedList.new(children: [
ListItem.new(children: [Text.new(content: "One")] of Child),
ListItem.new(children: [Text.new(content: "Two")] of Child),
] of Child),
Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
]
result = ParagraphConverter.new.convert(paragraphs)
result.should eq expected
end
it "groups <ol> list items into one list" do
paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
[
{
"text": "One",
"type": "OLI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "Two",
"type": "OLI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "Not a list item",
"type": "P",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
}
]
JSON
expected = [
OrderedList.new(children: [
ListItem.new(children: [Text.new(content: "One")] of Child),
ListItem.new(children: [Text.new(content: "Two")] of Child),
] of Child),
Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
]
result = ParagraphConverter.new.convert(paragraphs)
result.should eq expected
end
it "converts all the tags" do
paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
[
{
"text": "text",
"type": "H3",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "H4",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "P",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "PRE",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "BQ",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "ULI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "OLI",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": null
},
{
"text": "text",
"type": "IMG",
"markups": [],
"href": null,
"iframe": null,
"layout": null,
"metadata": {
"id": "1*miroimage.png",
"originalWidth": 618,
"originalHeight": 682
}
},
{
"text": "",
"type": "IFRAME",
"markups": [],
"href": null,
"iframe": {
"mediaResource": {
"id": "7c6231d165bf9fc1853f259a7b55bd14"
}
},
"layout": null,
"metadata": null
}
]
JSON
expected = [
Heading3.new([Text.new("text")] of Child),
Heading4.new([Text.new("text")] of Child),
Paragraph.new([Text.new("text")] of Child),
Preformatted.new([Text.new("text")] of Child),
BlockQuote.new([Text.new("text")] of Child),
UnorderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
OrderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
Image.new(src: "1*miroimage.png"),
IFrame.new(href: "https://example.com"),
]
result = ParagraphConverter.new.convert(paragraphs)
result.should eq expected
end
end

View file

@ -0,0 +1,189 @@
require "../spec_helper"
include Nodes
describe PageContent do
it "renders a single parent/child node structure" do
page = Page.new(nodes: [
Paragraph.new(children: [
Text.new(content: "hi"),
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p>hi</p>)
end
it "renders multiple childrens" do
page = Page.new(nodes: [
Paragraph.new(children: [
Text.new(content: "Hello, "),
Emphasis.new(children: [
Text.new(content: "World!")
] of Child)
] of Child),
UnorderedList.new(children: [
ListItem.new(children: [
Text.new(content: "List!")
] of Child),
ListItem.new(children: [
Text.new(content: "Again!"),
] of Child)
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p>Hello, <em>World!</em></p><ul><li>List!</li><li>Again!</li></ul>)
end
it "renders a blockquote" do
page = Page.new(nodes: [
BlockQuote.new(children: [
Text.new("Wayne Gretzky. Michael Scott.")
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<blockquote>Wayne Gretzky. Michael Scott.</blockquote>)
end
it "renders code" do
page = Page.new(nodes: [
Code.new(children: [
Text.new("foo = bar")
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<code>foo = bar</code>)
end
it "renders empasis" do
page = Page.new(nodes: [
Paragraph.new(children: [
Text.new(content: "This is "),
Emphasis.new(children: [
Text.new(content: "neat!")
] of Child),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p>This is <em>neat!</em></p>)
end
it "renders an H3" do
page = Page.new(nodes: [
Heading3.new(children: [
Text.new(content: "Title!"),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<h3>Title!</h3>)
end
it "renders an H4" do
page = Page.new(nodes: [
Heading4.new(children: [
Text.new(content: "In Conclusion..."),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<h4>In Conclusion...</h4>)
end
it "renders an image" do
page = Page.new(nodes: [
Paragraph.new(children: [
Image.new(src: "image.png"),
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p><img src="https://cdn-images-1.medium.com/image.png"></p>)
end
it "renders an iframe container" do
page = Page.new(nodes: [
Paragraph.new(children: [
IFrame.new(href: "https://example.com"),
] of Child)
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p><div class="embedded"><a href="https://example.com">Click to visit embedded content</a></div></p>)
end
it "renders an ordered list" do
page = Page.new(nodes: [
OrderedList.new(children: [
ListItem.new(children: [Text.new("One")] of Child),
ListItem.new(children: [Text.new("Two")] of Child),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<ol><li>One</li><li>Two</li></ol>)
end
it "renders an preformatted text" do
page = Page.new(nodes: [
Paragraph.new(children: [
Text.new("Hello, world!"),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<p>Hello, world!</p>)
end
it "renders an preformatted text" do
page = Page.new(nodes: [
Preformatted.new(children: [
Text.new("New\nline"),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<pre>New\nline</pre>)
end
it "renders strong text" do
page = Page.new(nodes: [
Strong.new(children: [
Text.new("Oh yeah!"),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<strong>Oh yeah!</strong>)
end
it "renders an unordered list" do
page = Page.new(nodes: [
UnorderedList.new(children: [
ListItem.new(children: [Text.new("Apple")] of Child),
ListItem.new(children: [Text.new("Banana")] of Child),
] of Child),
] of Child)
html = PageContent.new(page: page).render_to_string
html.should eq %(<ul><li>Apple</li><li>Banana</li></ul>)
end
end

View file

@ -0,0 +1,9 @@
class FakeMediumClient < MediumClient
def self.media_data(media_id : String) : MediaResponse::Root
MediaResponse::Root.from_json(
<<-JSON
{"payload": {"value": {"href": "https://example.com"}}}
JSON
)
end
end

View file

@ -1,12 +1,16 @@
require "json"
class Articles::Show < BrowserAction
get "/post/:post_id" do
get "/posts/:post_id" do
if Lucky::Env.use_local?
response = LocalClient.post_data(post_id)
else
response = MediumClient.post_data(post_id)
end
html ShowPage, post_response: response
content = ParagraphConverter.new.convert(
response.data.post.content.bodyModel.paragraphs
)
page = Page.new(nodes: content)
html ShowPage, page: page
end
end

View file

@ -3,6 +3,7 @@ require "./shards"
Lucky::AssetHelpers.load_manifest
require "./app_database"
require "./constants"
require "./models/base_model"
require "./models/mixins/**"
require "./models/**"
@ -16,6 +17,8 @@ require "./actions/mixins/**"
require "./actions/**"
require "./components/base_component"
require "./components/**"
require "./classes/**"
require "./clients/**"
require "./pages/**"
require "../config/env"
require "../config/**"

View file

@ -0,0 +1,13 @@
class IFrameMediaResolver
class_property http_client : MediumClient.class = MediumClient
getter iframe
def initialize(@iframe : PostResponse::IFrame)
end
def fetch_href
response = @@http_client.media_data(iframe.mediaResource.id)
response.payload.value.href
end
end

View file

@ -0,0 +1,74 @@
struct StringSplit
getter pre, content, post
def initialize(@pre : String, @content : String, @post : String)
end
end
class MarkupConverter
include Nodes
getter markups : Array(PostResponse::Markup)
getter text : String
def self.convert(text : String, markups : Array(PostResponse::Markup))
new(text, markups).convert
end
def initialize(@text : String, @markups : Array(PostResponse::Markup))
end
def convert : Array(Child)
if markups.empty?
return [Text.new(content: text)] of Child
end
offset = 0
text_splits = markups.reduce([text]) do |splits, markup|
individual_split = split_string(markup.start - offset, markup.end - offset, splits.pop)
offset = markup.end
splits.push(individual_split.pre)
splits.push(individual_split.content)
splits.push(individual_split.post)
end
text_splits.in_groups_of(2, "").map_with_index do |split, index|
plain, to_be_marked = split
markup = markups.fetch(index, Text.new(""))
if markup.is_a?(Text)
[Text.new(plain)] of Child
else
case markup.type
when PostResponse::MarkupType::CODE
container = construct_markup(to_be_marked, Code)
when PostResponse::MarkupType::EM
container = construct_markup(to_be_marked, Emphasis)
when PostResponse::MarkupType::STRONG
container = construct_markup(to_be_marked, Strong)
else
container = construct_markup(to_be_marked, Code)
end
[Text.new(plain), container] of Child
end
end.flatten.reject(&.empty?)
end
private def construct_markup(text : String, container : Container.class) : Child
container.new(children: [Text.new(content: text)] of Child)
end
private def split_string(start : Int32, finish : Int32, string : String)
if start.zero?
pre = ""
else
pre = string[0...start]
end
if finish == string.size
post = ""
else
post = string[finish...string.size]
end
content = string[start...finish]
StringSplit.new(pre, content, post)
end
end

View file

@ -0,0 +1,79 @@
class ParagraphConverter
include Nodes
def convert(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
if paragraphs.first?.nil?
return [Empty.new] of Child
else
case paragraphs.first.type
when PostResponse::ParagraphType::BQ
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
node = BlockQuote.new(children: children)
when PostResponse::ParagraphType::H3
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
node = Heading3.new(children: children)
when PostResponse::ParagraphType::H4
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
node = Heading4.new(children: children)
when PostResponse::ParagraphType::IFRAME
paragraph = paragraphs.shift
if iframe = paragraph.iframe
resolver = IFrameMediaResolver.new(iframe: iframe)
href = resolver.fetch_href
node = IFrame.new(href: href)
else
node = Empty.new
end
when PostResponse::ParagraphType::IMG
paragraph = paragraphs.shift
if metadata = paragraph.metadata
node = Image.new(src: metadata.id)
else
node = Empty.new
end
when PostResponse::ParagraphType::OLI
list_items = convert_oli(paragraphs)
node = OrderedList.new(children: list_items)
when PostResponse::ParagraphType::P
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
node = Paragraph.new(children: children)
when PostResponse::ParagraphType::PRE
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
node = Preformatted.new(children: children)
when PostResponse::ParagraphType::ULI
list_items = convert_uli(paragraphs)
node = UnorderedList.new(children: list_items)
else
paragraphs.shift # so we don't recurse infinitely
node = Empty.new
end
[node, convert(paragraphs)].flatten.reject(&.empty?)
end
end
private def convert_uli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::ULI)
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
[ListItem.new(children: children)] + convert_uli(paragraphs)
else
[] of Child
end
end
private def convert_oli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::OLI)
paragraph = paragraphs.shift
children = MarkupConverter.convert(paragraph.text, paragraph.markups)
[ListItem.new(children: children)] + convert_oli(paragraphs)
else
[] of Child
end
end
end

View file

@ -1,9 +1,6 @@
require "json"
class MediumClient
# https://stackoverflow.com/questions/2669690/
JSON_HIJACK_STRING = "])}while(1);</x>"
def self.post_data(post_id : String) : PostResponse::Root
client = HTTP::Client.new("medium.com", tls: true)
response = client.post("/_/graphql", headers: headers, body: body(post_id))

View file

@ -0,0 +1,84 @@
class PageContent < BaseComponent
include Nodes
needs page : Page
def render
page.nodes.each do |node|
render_child(node)
end
end
def render_children(children : Children)
children.each { |child| render_child(child) }
end
def render_child(node : BlockQuote)
blockquote { render_children(node.children) }
end
def render_child(node : Code)
code { render_children(node.children) }
end
def render_child(container : Container)
# Should never get called
raw "<!-- a Container was rendered -->"
end
def render_child(node : Emphasis)
em { render_children(node.children) }
end
def render_child(container : Empty)
# Should never get called
raw "<!-- an Empty was rendered -->"
end
def render_child(node : Heading3)
h3 { render_children(node.children) }
end
def render_child(node : Heading4)
h4 { render_children(node.children) }
end
def render_child(child : IFrame)
div class: "embedded" do
a href: child.href do
text "Click to visit embedded content"
end
end
end
def render_child(child : Image)
img src: child.src
end
def render_child(node : ListItem)
li { render_children(node.children) }
end
def render_child(node : OrderedList)
ol { render_children(node.children) }
end
def render_child(node : Paragraph)
para { render_children(node.children) }
end
def render_child(node : Preformatted)
pre { render_children(node.children) }
end
def render_child(node : Strong)
strong { render_children(node.children) }
end
def render_child(child : Text)
text child.content
end
def render_child(node : UnorderedList)
ul { render_children(node.children) }
end
end

View file

@ -1,29 +0,0 @@
class Post::Post < BaseComponent
needs response : PostResponse::Root
def render
data = response.data.post.content.bodyModel.paragraphs
data.each do |paragraph|
case paragraph.type
when PostResponse::ParagraphType::H3
h3 paragraph.text
when PostResponse::ParagraphType::H4
h4 paragraph.text
when PostResponse::ParagraphType::P
para paragraph.text
when PostResponse::ParagraphType::PRE
pre paragraph.text
when PostResponse::ParagraphType::BQ
blockquote paragraph.text
when PostResponse::ParagraphType::OLI
li paragraph.text
when PostResponse::ParagraphType::ULI
li paragraph.text
when PostResponse::ParagraphType::IFRAME
mount IFrame, paragraph: paragraph
else
para "#{paragraph.type} not yet implimented"
end
end
end
end

2
src/constants.cr Normal file
View file

@ -0,0 +1,2 @@
# https://stackoverflow.com/questions/2669690/
JSON_HIJACK_STRING = "])}while(1);</x>"

View file

@ -13,6 +13,5 @@ class MediaResponse
class Value < Base
property href : String
property iframeSrc : String
end
end

107
src/models/nodes.cr Normal file
View file

@ -0,0 +1,107 @@
module Nodes
alias Leaf = Text | Image | IFrame
alias Child = Container | Leaf | Empty
alias Children = Array(Child)
class Container
getter children : Children
def initialize(@children : Children)
end
def ==(other : Container)
other.children == children
end
def empty?
children.empty? || children.each(&.empty?)
end
end
class Empty
def empty?
true
end
end
class BlockQuote < Container
end
class Code < Container
end
class Emphasis < Container
end
class Heading3 < Container
end
class Heading4 < Container
end
class ListItem < Container
end
class OrderedList < Container
end
class Paragraph < Container
end
class Preformatted < Container
end
class Strong < Container
end
class UnorderedList < Container
end
class Text
getter content : String
def initialize(@content : String)
end
def ==(other : Text)
other.content == content
end
def empty?
content.empty?
end
end
class Image
IMAGE_HOST = "https://cdn-images-1.medium.com"
getter src : String
def initialize(src : String)
@src = "#{IMAGE_HOST}/#{src}"
end
def ==(other : Image)
other.src == src
end
def empty?
false
end
end
class IFrame
getter href : String
def initialize(@href : String)
end
def ==(other : IFrame)
other.href == href
end
def empty?
false
end
end
end

6
src/models/page.cr Normal file
View file

@ -0,0 +1,6 @@
class Page
getter nodes : Nodes::Children
def initialize(@nodes : Nodes::Children)
end
end

View file

@ -33,20 +33,43 @@ class PostResponse
class Paragraph < Base
property text : String
property type : ParagraphType
property markups : Array(Markup)
property iframe : IFrame?
property layout : String?
property metadata : Metadata?
end
enum ParagraphType
BQ
H3
H4
P
PRE
BQ
ULI
OLI
IFRAME
IMG
OLI
P
PRE
ULI
end
class Markup < Base
property title : String?
property type : MarkupType
property href : String?
property start : Int32
property end : Int32
property anchorType : AnchorType?
end
enum MarkupType
A
CODE
EM
STRONG
end
enum AnchorType
LINK
USER
end
class IFrame < Base
@ -58,5 +81,8 @@ class PostResponse
end
class Metadata < Base
property id : String
property originalWidth : Int32
property originalHeight : Int32
end
end

View file

@ -1,7 +1,7 @@
class Articles::ShowPage < MainLayout
needs post_response : PostResponse::Root
needs page : Page
def content
mount Post::Post, response: post_response
mount PageContent, page: page
end
end