Skip to content

Commit d469dac

Browse files
committed
Add GovspeakPreviewHelper
1 parent b72b900 commit d469dac

4 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
require "delegate"
2+
3+
module GovspeakPreviewHelper
4+
include Rails.application.routes.url_helpers
5+
6+
def govspeak_to_html(govspeak, options = {})
7+
processed_govspeak = preprocess_govspeak(govspeak, options)
8+
html = markup_to_nokogiri_doc(
9+
processed_govspeak,
10+
locale: options[:locale],
11+
).to_html
12+
13+
"<div class=\"govspeak\">#{html}</div>".html_safe
14+
end
15+
16+
def govspeak_headers(govspeak, level = (2..2))
17+
build_govspeak_document(govspeak).headers.select do |header|
18+
level.cover?(header.level)
19+
end
20+
end
21+
22+
def govspeak_header_hierarchy(govspeak)
23+
headers = []
24+
govspeak_headers(govspeak, 2..3).each do |header|
25+
case header.level
26+
when 2
27+
headers << { header:, children: [] }
28+
when 3
29+
raise Govspeak::OrphanedHeadingError, header.text if headers.none?
30+
31+
headers.last[:children] << header
32+
end
33+
end
34+
headers
35+
end
36+
37+
def preprocess_govspeak(govspeak, options)
38+
govspeak ||= ""
39+
ContentBlockManager::FindAndReplaceEmbedCodesService.call(govspeak) if options[:preview]
40+
govspeak = add_heading_numbers(govspeak) if options[:heading_numbering] == :auto
41+
govspeak
42+
end
43+
44+
private
45+
46+
def add_heading_numbers(govspeak)
47+
h2 = 0
48+
h3 = 0
49+
50+
govspeak.gsub(/^(###|##)\s*(.+)$/) do
51+
hashes = Regexp.last_match(1)
52+
heading_text = Regexp.last_match(2).strip
53+
54+
if hashes == "##"
55+
h2 += 1
56+
h3 = 0
57+
num = "#{h2}."
58+
else # "###"
59+
h2 = 1 if h2.zero?
60+
h3 += 1
61+
num = "#{h2}.#{h3}"
62+
end
63+
64+
# We have to manually derive and append a slug otherwise when Govspeak
65+
# generates the HTML, it includes the <span> and number in the ID. Hence
66+
# the `heading_text.parameterize`
67+
"#{hashes} <span class=\"number\">#{num} </span>#{heading_text} {##{heading_text.parameterize}}"
68+
end
69+
end
70+
71+
def markup_to_nokogiri_doc(govspeak, options = {})
72+
govspeak = build_govspeak_document(govspeak, options)
73+
doc = Nokogiri::HTML::Document.new
74+
doc.encoding = "UTF-8"
75+
doc.fragment(govspeak.to_html)
76+
end
77+
78+
def build_govspeak_document(govspeak, options = {})
79+
locale = options[:locale]
80+
81+
Govspeak::Document.new(
82+
govspeak,
83+
images: [],
84+
attachments: [],
85+
document_domains: [
86+
ContentBlockManager.admin_host,
87+
ContentBlockManager.public_host,
88+
],
89+
locale:,
90+
)
91+
end
92+
end

lib/content_block_manager.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ def self.support_email_address
1313
"feedback-content-modelling@digital.cabinet-office.gov.uk"
1414
end
1515

16+
def self.admin_host
17+
@admin_host ||= URI(admin_root).host
18+
end
19+
20+
def self.internal_admin_host
21+
@internal_admin_host ||=
22+
URI(Plek.find("content-block-manager")).host
23+
end
24+
25+
def self.public_host
26+
@public_host ||= URI(public_root).host
27+
end
28+
29+
def self.admin_root
30+
@admin_root ||= Plek.external_url_for("content-block-manager")
31+
end
32+
1633
def self.public_root
1734
@public_root ||= Plek.website_root
1835
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module Govspeak
2+
class OrphanedHeadingError < StandardError
3+
attr_reader :heading
4+
5+
def initialize(heading)
6+
@heading = heading
7+
super("Parent heading missing for: #{heading}")
8+
end
9+
end
10+
end
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
require "test_helper"
2+
3+
class GovspeakPreviewHelperTest < ActionView::TestCase
4+
extend Minitest::Spec::DSL
5+
6+
it "should not alter urls to other sites" do
7+
html = govspeak_to_html("no [change](http://external.example.com/page.html)")
8+
assert_select_within_html html, "a[href=?]", "http://external.example.com/page.html", text: "change"
9+
end
10+
11+
it "should not alter mailto urls" do
12+
html = govspeak_to_html("no [change](mailto:dave@example.com)")
13+
assert_select_within_html html, "a[href=?]", "mailto:dave@example.com", text: "change"
14+
end
15+
16+
it "should not alter invalid urls" do
17+
html = govspeak_to_html("no [change](not a valid url)")
18+
assert_select_within_html html, "a[href=?]", "not%20a%20valid%20url", text: "change"
19+
end
20+
21+
it "should not alter partial urls" do
22+
html = govspeak_to_html("no [change](http://)")
23+
assert_select_within_html html, "a[href=?]", "http://", text: "change"
24+
end
25+
26+
it "should wrap output with a govspeak class" do
27+
html = govspeak_to_html("govspeak-text")
28+
assert_select_within_html html, ".govspeak", text: "govspeak-text"
29+
end
30+
31+
it "should mark the govspeak output as html safe" do
32+
html = govspeak_to_html("govspeak-text")
33+
assert html.html_safe?
34+
end
35+
36+
it "should produce UTF-8 for HTML entities" do
37+
html = govspeak_to_html("a ['funny'](/url) thing")
38+
assert_select_within_html html, "a", text: "‘funny’"
39+
end
40+
41+
it "does not change css class on buttons" do
42+
html = govspeak_to_html("{button}[Link text](https://www.gov.uk){/button}")
43+
assert_select_within_html html, "a.govuk-button", "Link text"
44+
end
45+
46+
it "should only extract level two headers by default" do
47+
text = "# Heading 1\n\n## Heading 2\n\n### Heading 3"
48+
headers = govspeak_headers(text)
49+
assert_equal [Govspeak::Header.new("Heading 2", 2, "heading-2")], headers
50+
end
51+
52+
it "should extract header hierarchy from level 2+3 headings" do
53+
text = "# Heading 1\n\n## Heading 2a\n\n### Heading 3a\n\n### Heading 3b\n\n#### Ignored heading\n\n## Heading 2b"
54+
headers = govspeak_header_hierarchy(text)
55+
assert_equal [
56+
{
57+
header: Govspeak::Header.new("Heading 2a", 2, "heading-2a"),
58+
children: [
59+
Govspeak::Header.new("Heading 3a", 3, "heading-3a"),
60+
Govspeak::Header.new("Heading 3b", 3, "heading-3b"),
61+
],
62+
},
63+
{
64+
header: Govspeak::Header.new("Heading 2b", 2, "heading-2b"),
65+
children: [],
66+
},
67+
],
68+
headers
69+
end
70+
71+
it "should raise exception when extracting header hierarchy with orphaned level 3 headings" do
72+
e = assert_raise(Govspeak::OrphanedHeadingError) { govspeak_header_hierarchy("### Heading 3") }
73+
assert_equal "Heading 3", e.heading
74+
end
75+
76+
it "adds numbers to h2 headings" do
77+
input = "# main\n\n## first\n\n## second"
78+
output = '<div class="govspeak"><h1 id="main">main</h1> <h2 id="first"> <span class="number">1. </span>first</h2> <h2 id="second"> <span class="number">2. </span>second</h2></div>'
79+
assert_equivalent_html output, govspeak_to_html(input, heading_numbering: :auto).gsub(/\s+/, " ")
80+
end
81+
82+
it "adds sub-numbers to h3 tags" do
83+
input = "## first\n\n### first point one\n\n### first point two\n\n## second\n\n### second point one"
84+
expected_output1 = '<h2 id="first"> <span class="number">1. </span>first</h2>'
85+
expected_output_1a = '<h3 id="first-point-one"> <span class="number">1.1 </span>first point one</h3>'
86+
expected_output_1b = '<h3 id="first-point-two"> <span class="number">1.2 </span>first point two</h3>'
87+
expected_output2 = '<h2 id="second"> <span class="number">2. </span>second</h2>'
88+
expected_output_2a = '<h3 id="second-point-one"> <span class="number">2.1 </span>second point one</h3>'
89+
actual_output = govspeak_to_html(input, heading_numbering: :auto).gsub(/\s+/, " ")
90+
assert_match %r{#{expected_output1}}, actual_output
91+
assert_match %r{#{expected_output_1a}}, actual_output
92+
assert_match %r{#{expected_output_1b}}, actual_output
93+
assert_match %r{#{expected_output2}}, actual_output
94+
assert_match %r{#{expected_output_2a}}, actual_output
95+
end
96+
97+
it "should not corrupt character encoding of numbered headings" do
98+
input = "# café"
99+
actual_output = govspeak_to_html(input, heading_numbering: :auto)
100+
assert actual_output.include?("café</h1>")
101+
end
102+
103+
describe "admin flavour of govspeak" do
104+
it "should wrap admin output with a govspeak class" do
105+
html = govspeak_to_html("govspeak-text", { preview: true })
106+
assert_select_within_html html, ".govspeak", text: "govspeak-text"
107+
end
108+
109+
it "should mark the admin govspeak output as html safe" do
110+
html = govspeak_to_html("govspeak-text", { preview: true })
111+
assert html.html_safe?
112+
end
113+
114+
it "should call the embed codes helper" do
115+
input = "Here is some Govspeak"
116+
expected = "Expected output"
117+
ContentBlockManager::FindAndReplaceEmbedCodesService.expects(:call).with(input).returns(expected)
118+
govspeak_to_html(input, { preview: true })
119+
end
120+
end
121+
122+
private
123+
124+
def collapse_whitespace(string)
125+
string.gsub(/\s+/, " ").strip
126+
end
127+
end

0 commit comments

Comments
 (0)