Skip to content

Commit

Permalink
Extract article markdown rendering to service (forem#18754)
Browse files Browse the repository at this point in the history
* Beginning, needs cleanup & tests

* Minor refactor/cleanup

* Wondering if this factory ever worked?

* Minor tweak for blank body processing

* Better error handling for ContentRenderer

* Added some tests around ContentRenderer error conditions

* This test seems fine?

* Mocking internal dependencies

* Use new extraction for has_frontmatter?

* FeatureFlag :consistent_rendering

* Try to enable per-actor feature flag
  • Loading branch information
jaw6 authored Dec 14, 2022
1 parent 8cb170d commit be63f48
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 16 deletions.
73 changes: 59 additions & 14 deletions app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,16 @@ def current_state_path
end

def has_frontmatter?
if FeatureFlag.enabled?(:consistent_rendering, FeatureFlag::Actor[user])
front_matter.any? && front_matter["title"].present?
else
original_has_frontmatter?
end
rescue ContentRenderer::ContentParsingError
true
end

def original_has_frontmatter?
fixed_body_markdown = MarkdownProcessor::Fixer::FixAll.call(body_markdown)
begin
parsed = FrontMatterParser::Parser.new(:md).call(fixed_body_markdown)
Expand Down Expand Up @@ -620,7 +630,41 @@ def normalize_title
.strip
end

def processed_content
return @processed_content if @processed_content && !body_markdown_changed?
return unless user

@processed_content = ContentRenderer.new(body_markdown, source: self, user: user)
end

delegate :front_matter, to: :processed_content

def evaluate_markdown
if FeatureFlag.enabled?(:consistent_rendering, FeatureFlag::Actor[user])
extracted_evaluate_markdown
else
original_evaluate_markdown
end
end

def extracted_evaluate_markdown
return unless processed_content

self.reading_time = processed_content.calculate_reading_time
self.processed_html = processed_content.finalize

if front_matter.any?
evaluate_front_matter
elsif tag_list.any?
set_tag_list(tag_list)
end

self.description = processed_description if description.blank?
rescue ContentRenderer::ContentParsingError => e
errors.add(:base, ErrorMessages::Clean.call(e.message))
end

def original_evaluate_markdown
fixed_body_markdown = MarkdownProcessor::Fixer::FixAll.call(body_markdown || "")
parsed = FrontMatterParser::Parser.new(:md).call(fixed_body_markdown)
parsed_markdown = MarkdownProcessor::Parser.new(parsed.content, source: self, user: user)
Expand Down Expand Up @@ -686,25 +730,26 @@ def before_destroy_actions
Articles::BustMultipleCachesWorker.perform_bulk(job_params)
end

def evaluate_front_matter(front_matter)
self.title = front_matter["title"] if front_matter["title"].present?
set_tag_list(front_matter["tags"]) if front_matter["tags"].present?
self.published = front_matter["published"] if %w[true false].include?(front_matter["published"].to_s)
# TODO: The param can be removed after with FeatureFlag :consistent_rendering
def evaluate_front_matter(hash = front_matter)
self.title = hash["title"] if hash["title"].present?
set_tag_list(hash["tags"]) if hash["tags"].present?
self.published = hash["published"] if %w[true false].include?(hash["published"].to_s)

self.published_at = front_matter["published_at"] if front_matter["published_at"]
self.published_at ||= parse_date(front_matter["date"]) if published
self.published_at = hash["published_at"] if hash["published_at"]
self.published_at ||= parse_date(hash["date"]) if published

set_main_image(front_matter)
self.canonical_url = front_matter["canonical_url"] if front_matter["canonical_url"].present?
set_main_image
self.canonical_url = hash["canonical_url"] if hash["canonical_url"].present?

update_description = front_matter["description"].present? || front_matter["title"].present?
self.description = front_matter["description"] if update_description
update_description = hash["description"].present? || hash["title"].present?
self.description = hash["description"] if update_description

self.collection_id = nil if front_matter["title"].present?
self.collection_id = Collection.find_series(front_matter["series"], user).id if front_matter["series"].present?
self.collection_id = nil if hash["title"].present?
self.collection_id = Collection.find_series(hash["series"], user).id if hash["series"].present?
end

def set_main_image(front_matter)
def set_main_image
# At one point, we have set the main_image based on the front matter. Forever will that now dictate the behavior.
if main_image_from_frontmatter?
self.main_image = front_matter["cover_image"]
Expand Down Expand Up @@ -879,7 +924,7 @@ def set_nth_published_at
end

def title_to_slug
"#{Sterile.sluggerize(title)}-#{rand(100_000).to_s(26)}" # rubocop:disable Rails/ToSWithArgument
"#{Sterile.sluggerize(title)}-#{rand(100_000).to_s(26)}"
end

def touch_actor_latest_article_updated_at(destroying: false)
Expand Down
56 changes: 56 additions & 0 deletions app/services/content_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
class ContentRenderer
class_attribute :fixer, default: MarkdownProcessor::Fixer::FixAll
class_attribute :front_matter_parser, default: FrontMatterParser::Parser.new(:md)
class_attribute :processor, default: MarkdownProcessor::Parser

class ContentParsingError < StandardError
end

delegate :calculate_reading_time, to: :processed
delegate :content, :front_matter, to: :parsed_input

attr_reader :input, :source, :user

def initialize(input, source:, user:)
@input = input || ""
@source = source
@user = user
end

def processed
@processed ||= processor.new(content, source: source, user: user)
# TODO: Replicating prior behaviour, but this swallows errors we probably shouldn't
rescue StandardError => e
raise ContentParsingError, e.message
end

def finalize
processed.finalize
# TODO: Replicating prior behaviour, but this swallows errors we probably shouldn't
rescue StandardError => e
raise ContentParsingError, e.message
end

private

def fix(markdown)
fixer.call(markdown)
# TODO: Replicating prior behaviour, but this swallows errors we probably shouldn't
rescue StandardError => e
raise ContentParsingError, e.message
end

def parse_front_matter(markdown)
front_matter_parser.call(markdown)
# TODO: Replicating prior behaviour, but this swallows errors we probably shouldn't
rescue StandardError => e
raise ContentParsingError, e.message
end

def parsed_input
@parsed_input = parse_front_matter(fix(input))
# TODO: Replicating prior behaviour, but this swallows errors we probably shouldn't
rescue StandardError => e
raise ContentParsingError, e.message
end
end
10 changes: 10 additions & 0 deletions app/services/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
#
# @note A wrapper around the Flipper gem
module FeatureFlag
class Actor < SimpleDelegator
class << self
alias [] new
end

def flipper_id
respond_to?(:id) ? id : nil
end
end

class << self
delegate :add, :disable, :enable, :enabled?, :exist?, :remove, to: Flipper

Expand Down
3 changes: 2 additions & 1 deletion spec/factories/articles.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
published_at { Time.current }

transient do
title { generate :title }
title { generate(:title) }
published { true }
date { "01/01/2015" }
tags { "javascript, html, discuss" }
canonical_url { Faker::Internet.url }
with_canonical_url { false }
with_main_image { true }
main_image_from_frontmatter { false }
with_date { false }
with_tags { true }
with_hr_issue { false }
Expand Down
4 changes: 3 additions & 1 deletion spec/models/article_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ def build_and_validate_article(*args)
article
end

before { allow(FeatureFlag).to receive(:enabled?).with(:consistent_rendering, any_args).and_return(true) }

let(:user) { create(:user) }
let!(:article) { create(:article, user: user) }

Expand Down Expand Up @@ -206,7 +208,7 @@ def build_and_validate_article(*args)
end

describe "liquid tags" do
xit "is not valid if it contains invalid liquid tags" do
it "is not valid if it contains invalid liquid tags" do
body = "{% github /thepracticaldev/dev.to %}"
article = build(:article, body_markdown: body)
expect(article).not_to be_valid
Expand Down
2 changes: 2 additions & 0 deletions spec/services/articles/creator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
RSpec.describe Articles::Creator, type: :service do
let(:user) { create(:user) }

before { allow(FeatureFlag).to receive(:enabled?).with(:consistent_rendering, any_args).and_return(true) }

context "when valid attributes" do
let(:valid_attributes) { attributes_for(:article) }

Expand Down
2 changes: 2 additions & 0 deletions spec/services/articles/updater_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
let(:attributes) { { body_markdown: "sample" } }
let(:draft) { create(:article, user: user, published: false, published_at: nil) }

before { allow(FeatureFlag).to receive(:enabled?).with(:consistent_rendering, any_args).and_return(true) }

it "updates an article" do
described_class.call(user, article, attributes)
article.reload
Expand Down
107 changes: 107 additions & 0 deletions spec/services/content_renderer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
require "rails_helper"

RSpec.describe ContentRenderer do
describe "#finalize" do
let(:markdown) { "hello, hey" }
let(:expected_result) { "<p>hello, hey</p>\n\n" }
let(:mock_fixer) { class_double MarkdownProcessor::Fixer::FixAll }
let(:mock_front_matter_parser) { instance_double FrontMatterParser::Parser }
let(:mock_processor) { class_double MarkdownProcessor::Parser }
let(:fixed_markdown) { :fixed_markdown }
let(:parsed_contents) { Struct.new(:content).new(:parsed_content) }
let(:processed_contents) { instance_double MarkdownProcessor::Parser }

# rubocop:disable RSpec/InstanceVariable
before do
allow(mock_fixer).to receive(:call).and_return(fixed_markdown)
allow(mock_front_matter_parser).to receive(:call).with(fixed_markdown).and_return(parsed_contents)
allow(mock_processor).to receive(:new).and_return(processed_contents)
allow(processed_contents).to receive(:finalize).and_return(expected_result)

@original_fixer = described_class.fixer
@original_parser = described_class.front_matter_parser
@original_processor = described_class.processor

described_class.fixer = mock_fixer
described_class.front_matter_parser = mock_front_matter_parser
described_class.processor = mock_processor
end

after do
described_class.fixer = @original_fixer
described_class.front_matter_parser = @original_parser
described_class.processor = @original_processor
end
# rubocop:enable RSpec/InstanceVariable

it "is the result of fixing, parsing, and processing" do
result = described_class.new(markdown, source: nil, user: nil).finalize
expect(result).to eq(expected_result)
expect(mock_fixer).to have_received(:call)
expect(mock_front_matter_parser).to have_received(:call).with(fixed_markdown)
expect(mock_processor).to have_received(:new)
expect(processed_contents).to have_received(:finalize)
end
end

context "when markdown is valid" do
let(:markdown) { "# Hey\n\nHi, hello there, what's up?" }
let(:expected_result) { <<~RESULT }
<h1>
<a name="hey" href="#hey">
</a>
Hey
</h1>
<p>Hi, hello there, what's up?</p>
RESULT

it "processes markdown" do
result = described_class.new(markdown, source: nil, user: nil).finalize
expect(result).to eq(expected_result)
end
end

context "when markdown has liquid tags that aren't allowed for user" do
let(:markdown) { "hello hey hey hey {% poll 123 %}" }
let(:article) { build(:article) }
let(:user) { instance_double(User) }

before do
allow(user).to receive(:any_admin?).and_return(false)
end

it "raises ContentParsingError" do
expect do
described_class.new(markdown, source: article, user: user).finalize
end.to raise_error(ContentRenderer::ContentParsingError, /User is not permitted to use this liquid tag/)
end
end

context "when markdown has liquid tags that aren't allowed for source" do
let(:markdown) { "hello hey hey hey {% poll 123 %}" }
let(:source) { build(:comment) }
let(:user) { instance_double(User) }

before do
allow(user).to receive(:any_admin?).and_return(true)
end

it "raises ContentParsingError" do
expect do
described_class.new(markdown, source: source, user: user).finalize
end.to raise_error(ContentRenderer::ContentParsingError, /This liquid tag can only be used in Articles/)
end
end

context "when markdown has invalid frontmatter" do
let(:markdown) { "---\ntitle: Title\npublished: false\npublished_at:2022-12-05 18:00 +0300---\n\n" }

it "raises ContentParsingError" do
expect do
described_class.new(markdown, source: nil, user: nil).front_matter
end.to raise_error(ContentRenderer::ContentParsingError, /while scanning a simple key/)
end
end
end

0 comments on commit be63f48

Please sign in to comment.