Skip to content

Commit 085e6d0

Browse files
authored
Merge pull request #19 from alphagov/cm-420-migrate-govspeak-textarea-preview
CM-420 add GovspeakEditor to govspeak-enabled textareas
2 parents 74d736a + 775e11c commit 085e6d0

19 files changed

Lines changed: 990 additions & 41 deletions

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ Lint/MissingSuper:
4848
Rails/SaveBang:
4949
Exclude:
5050
- 'Rakefile'
51+
- 'test/test_helper.rb'

app/assets/javascripts/application.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
//= require govuk_publishing_components/lib/cookie-functions
99
//= require govuk_publishing_components/lib/trigger-event
1010

11+
//= require components/govspeak-editor
12+
1113
//= require ./modules/auto-populate-telephone-number-label
1214
//= require ./modules/copy-embed-code
1315

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict'
2+
window.GOVUK = window.GOVUK || {}
3+
window.GOVUK.Modules = window.GOVUK.Modules || {}
4+
;(function (Modules) {
5+
function GovspeakEditor(module) {
6+
this.module = module
7+
}
8+
9+
GovspeakEditor.prototype.init = function () {
10+
this.previewButton = this.module.querySelector(
11+
'.js-app-c-govspeak-editor__preview-button'
12+
)
13+
this.backButton = this.module.querySelector(
14+
'.js-app-c-govspeak-editor__back-button'
15+
)
16+
this.preview = this.module.querySelector('.app-c-govspeak-editor__preview')
17+
this.error = this.module.querySelector('.app-c-govspeak-editor__error')
18+
this.textareaWrapper = this.module.querySelector(
19+
'.app-c-govspeak-editor__textarea'
20+
)
21+
this.textarea = this.textareaWrapper.querySelector('textarea')
22+
23+
this.previewButton.classList.add(
24+
'app-c-govspeak-editor__preview-button--show'
25+
)
26+
27+
this.previewButton.addEventListener('click', this.showPreview.bind(this))
28+
this.backButton.addEventListener('click', this.hidePreview.bind(this))
29+
}
30+
31+
GovspeakEditor.prototype.getCsrfToken = function () {
32+
return document.querySelector('meta[name="csrf-token"]').content
33+
}
34+
35+
GovspeakEditor.prototype.getRenderedGovspeak = function (body, callback) {
36+
const data = this.generateFormData(body)
37+
38+
const request = new XMLHttpRequest()
39+
request.open('POST', '/admin/preview', false)
40+
request.setRequestHeader('X-CSRF-Token', this.getCsrfToken())
41+
request.onreadystatechange = callback
42+
request.send(data)
43+
}
44+
45+
GovspeakEditor.prototype.generateFormData = function (body) {
46+
const data = new FormData()
47+
data.append('body', body)
48+
data.append('authenticity_token', this.getCsrfToken())
49+
50+
return data
51+
}
52+
53+
GovspeakEditor.prototype.showPreview = function (event) {
54+
event.preventDefault()
55+
56+
this.backButton.classList.add('app-c-govspeak-editor__back-button--show')
57+
this.previewButton.classList.remove(
58+
'app-c-govspeak-editor__preview-button--show'
59+
)
60+
61+
this.preview.classList.add('app-c-govspeak-editor__preview--show')
62+
this.textareaWrapper.classList.add(
63+
'app-c-govspeak-editor__textarea--hidden'
64+
)
65+
66+
this.backButton.focus()
67+
68+
this.getRenderedGovspeak(this.textarea.value, (event) => {
69+
const response = event.currentTarget
70+
71+
if (response.readyState !== 4) {
72+
return
73+
}
74+
75+
switch (response.status) {
76+
case 200:
77+
this.preview.innerHTML = response.responseText
78+
break
79+
case 403:
80+
this.preview.classList.remove('app-c-govspeak-editor__preview--show')
81+
this.error.classList.add('app-c-govspeak-editor__error--show')
82+
}
83+
})
84+
}
85+
86+
GovspeakEditor.prototype.hidePreview = function (event) {
87+
event.preventDefault()
88+
89+
this.backButton.classList.remove('app-c-govspeak-editor__back-button--show')
90+
this.previewButton.classList.add(
91+
'app-c-govspeak-editor__preview-button--show'
92+
)
93+
94+
this.preview.classList.remove('app-c-govspeak-editor__preview--show')
95+
this.error.classList.remove('app-c-govspeak-editor__error--show')
96+
this.textareaWrapper.classList.remove(
97+
'app-c-govspeak-editor__textarea--hidden'
98+
)
99+
100+
this.textarea.focus()
101+
}
102+
103+
Modules.GovspeakEditor = GovspeakEditor
104+
})(window.GOVUK.Modules)

app/assets/stylesheets/application.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ $govuk-page-width: 1140px;
22

33
@import "govuk_publishing_components/all_components";
44

5+
@import "./components/govspeak-editor";
6+
57
@import "content_block_manager/components/array-component";
68
@import "content_block_manager/components/embedded-objects-blocks-component";
79
@import "content_block_manager/components/embedded-objects-metadata-component";
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.app-c-govspeak-editor {
2+
margin-bottom: govuk-spacing(6);
3+
}
4+
5+
.app-c-govspeak-editor__preview-button-wrapper {
6+
text-align: right;
7+
display: flex;
8+
justify-content: end;
9+
gap: govuk-spacing(2);
10+
}
11+
12+
.app-c-govspeak-editor__textarea--hidden {
13+
display: none;
14+
}
15+
16+
.app-c-govspeak-editor__preview {
17+
border: 1px solid $govuk-border-colour;
18+
padding: govuk-spacing(4);
19+
}
20+
21+
.app-c-govspeak-editor__preview,
22+
.app-c-govspeak-editor__error,
23+
.js-app-c-govspeak-editor__preview-button,
24+
.js-app-c-govspeak-editor__back-button {
25+
display: none;
26+
}
27+
28+
.app-c-govspeak-editor__preview--show,
29+
.app-c-govspeak-editor__error--show,
30+
.app-c-govspeak-editor__preview-button--show,
31+
.app-c-govspeak-editor__back-button--show {
32+
display: block;
33+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Admin::PreviewController < Admin::BaseController
2+
include GovspeakPreviewHelper
3+
4+
def preview
5+
if Govspeak::HtmlValidator.new(params[:body]).valid?
6+
render layout: false
7+
else
8+
render plain: "Content contains possible XSS exploits", status: :forbidden
9+
end
10+
end
11+
end
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
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<section>
2+
<article class="document">
3+
<div class="body">
4+
<%= govspeak_to_html(
5+
params[:body],
6+
preview: true,
7+
) %>
8+
</div>
9+
</article>
10+
</section>

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
get "/healthcheck/live", to: proc { [200, {}, %w[OK]] }
33
get "/healthcheck/ready", to: GovukHealthcheck.rack_response
44

5+
namespace :admin do
6+
post "preview" => "preview#preview"
7+
end
8+
59
namespace :content_block_manager, path: "/" do
610
root to: "content_block/documents#index", via: :get
711

docs/use_of_govspeak.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Use of Govspeak
2+
3+
September 3, 2025.
4+
5+
Govspeak is the GOV.UK flavour of markdown which is offered to content creators
6+
to format their work.
7+
8+
This is a summary of how, at this point in time, we are converting Govspeak into
9+
HTML.
10+
11+
## 1) GovspeakEditor: when editing content
12+
13+
When composing or editing a govspeak-enabled `<textarea>` we offer users the
14+
facility to toggle between "Preview" and "Edit" modes.
15+
16+
When choosing to "Preview" the content of the textarea, we use Ajax to send a
17+
`POST` request to the `/admin/preview` endpoint. The `Admin::PreviewController`
18+
uses the `GovspeakPreviewHelper` which ultimately calls `#to_haml` on a new
19+
`Govspeak::Document`, defined in the `govspeak` gem, a dependency of the
20+
`content_block_tools` gem.
21+
22+
## 2) render_govspeak: when displaying saved content
23+
24+
When displaying govspeak-enabled fields which have been saved, on workflow pages
25+
such as:
26+
27+
- `editions/1/workflow/group_contact_methods#telephones` or
28+
- `editions/1/workflow/review`
29+
30+
The `EmbeddedObjects::SummaryCard::NestedItemComponent` uses the
31+
`ContentBlockManager::ContentBlock::GovspeakHelper` and calls out to
32+
`render_govspeak` in the content_block_tools gem:
33+
34+
```rb
35+
module ContentBlockTools
36+
module Govspeak
37+
def render_govspeak(body, root_class: nil)
38+
html = ::Govspeak::Document.new(body).to_html
39+
Nokogiri::HTML.fragment(html).tap { |fragment|
40+
fragment.children[0].add_class(root_class) if root_class
41+
}.to_s.html_safe
42+
end
43+
end
44+
end
45+
```
46+
47+
## 3) DefaultBlockComponent: rendering entire block
48+
49+
Once a content block is published and can be viewed on the "show" block page:
50+
e.g. `/content-block/1`, we use the `DefaultBlockComponent` which renders the
51+
block using the `content_block_tools` gem:
52+
53+
```rb
54+
ContentBlockTools::ContentBlock.new(
55+
document_type: "content_block_#{block_type}",
56+
content_id: document.content_id,
57+
title:,
58+
details:,
59+
embed_code:,
60+
).render
61+
```
62+
63+
When rendering the `ContactComponent`, for example, the `content_block_tools`
64+
gem uses `ContentBlockTools::Govspeak::render_govspeak` (see [2]) on the
65+
govspeak-enabled fields. See the `telephone_component.html.erb`:
66+
67+
```rb
68+
<% if show_video_relay_service? %>
69+
<%= render_govspeak(video_relay_service_content) %>
70+
<% end %>
71+
72+
<% if show_bsl_guidance? %>
73+
<%= render_govspeak(bsl_guidance[:value], root_class: "content-block__body") %>
74+
<% end %>
75+
76+
<% if show_opening_hours? %>
77+
<%= render_govspeak(opening_hours[:opening_hours], root_class: "content-block__body") %>
78+
<% end %>
79+
```
80+
81+
## Notes
82+
83+
- Currently the only fields which can be govspeak-enabled are on embedded objects
84+
(aka "nested objects", "nested items"). It will take a bit of work to make
85+
`GovspeakEnabledTextareaComponent` general-purpose, as currently `govspeak-enabled?`
86+
is defined only on `EmbeddedSchema`.
87+
88+
- We have two similarly named helpers which ought to be combined:
89+
90+
1. `GovspeakPreviewHelper` used by GovspeakEditor
91+
2. `GovspeakHelper` used by `NestedItemComponent`
92+
93+
- We also had (copied over from Whitehall but now deleted) a
94+
`Whitehall::GovspeakRenderer` which inherits from `ActionController::Renderer`
95+
and provides the following `helpers` methods:
96+
97+
- `govspeak_edition_to_html`
98+
- `govspeak_to_html`
99+
- `govspeak_with_attachments_to_html`
100+
- `govspeak_html_attachment_to_html`
101+
- `block_attachments`

0 commit comments

Comments
 (0)