Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ Lint/MissingSuper:
Rails/SaveBang:
Exclude:
- 'Rakefile'
- 'test/test_helper.rb'
2 changes: 2 additions & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
//= require govuk_publishing_components/lib/cookie-functions
//= require govuk_publishing_components/lib/trigger-event

//= require components/govspeak-editor

//= require ./modules/auto-populate-telephone-number-label
//= require ./modules/copy-embed-code

Expand Down
104 changes: 104 additions & 0 deletions app/assets/javascripts/components/govspeak-editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict'
window.GOVUK = window.GOVUK || {}
window.GOVUK.Modules = window.GOVUK.Modules || {}
;(function (Modules) {
function GovspeakEditor(module) {
this.module = module
}

GovspeakEditor.prototype.init = function () {
this.previewButton = this.module.querySelector(
'.js-app-c-govspeak-editor__preview-button'
)
this.backButton = this.module.querySelector(
'.js-app-c-govspeak-editor__back-button'
)
this.preview = this.module.querySelector('.app-c-govspeak-editor__preview')
this.error = this.module.querySelector('.app-c-govspeak-editor__error')
this.textareaWrapper = this.module.querySelector(
'.app-c-govspeak-editor__textarea'
)
this.textarea = this.textareaWrapper.querySelector('textarea')

this.previewButton.classList.add(
'app-c-govspeak-editor__preview-button--show'
)

this.previewButton.addEventListener('click', this.showPreview.bind(this))
this.backButton.addEventListener('click', this.hidePreview.bind(this))
}

GovspeakEditor.prototype.getCsrfToken = function () {
return document.querySelector('meta[name="csrf-token"]').content
}

GovspeakEditor.prototype.getRenderedGovspeak = function (body, callback) {
const data = this.generateFormData(body)

const request = new XMLHttpRequest()
request.open('POST', '/admin/preview', false)
request.setRequestHeader('X-CSRF-Token', this.getCsrfToken())
request.onreadystatechange = callback
request.send(data)
}

GovspeakEditor.prototype.generateFormData = function (body) {
const data = new FormData()
data.append('body', body)
data.append('authenticity_token', this.getCsrfToken())

return data
}

GovspeakEditor.prototype.showPreview = function (event) {
event.preventDefault()

this.backButton.classList.add('app-c-govspeak-editor__back-button--show')
this.previewButton.classList.remove(
'app-c-govspeak-editor__preview-button--show'
)

this.preview.classList.add('app-c-govspeak-editor__preview--show')
this.textareaWrapper.classList.add(
'app-c-govspeak-editor__textarea--hidden'
)

this.backButton.focus()

this.getRenderedGovspeak(this.textarea.value, (event) => {
const response = event.currentTarget

if (response.readyState !== 4) {
return
}

switch (response.status) {
case 200:
this.preview.innerHTML = response.responseText
break
case 403:
this.preview.classList.remove('app-c-govspeak-editor__preview--show')
this.error.classList.add('app-c-govspeak-editor__error--show')
}
})
}

GovspeakEditor.prototype.hidePreview = function (event) {
event.preventDefault()

this.backButton.classList.remove('app-c-govspeak-editor__back-button--show')
this.previewButton.classList.add(
'app-c-govspeak-editor__preview-button--show'
)

this.preview.classList.remove('app-c-govspeak-editor__preview--show')
this.error.classList.remove('app-c-govspeak-editor__error--show')
this.textareaWrapper.classList.remove(
'app-c-govspeak-editor__textarea--hidden'
)

this.textarea.focus()
}

Modules.GovspeakEditor = GovspeakEditor
})(window.GOVUK.Modules)
2 changes: 2 additions & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ $govuk-page-width: 1140px;

@import "govuk_publishing_components/all_components";

@import "./components/govspeak-editor";

@import "content_block_manager/components/array-component";
@import "content_block_manager/components/embedded-objects-blocks-component";
@import "content_block_manager/components/embedded-objects-metadata-component";
Expand Down
33 changes: 33 additions & 0 deletions app/assets/stylesheets/components/_govspeak-editor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.app-c-govspeak-editor {
margin-bottom: govuk-spacing(6);
}

.app-c-govspeak-editor__preview-button-wrapper {
text-align: right;
display: flex;
justify-content: end;
gap: govuk-spacing(2);
}

.app-c-govspeak-editor__textarea--hidden {
display: none;
}

.app-c-govspeak-editor__preview {
border: 1px solid $govuk-border-colour;
padding: govuk-spacing(4);
}

.app-c-govspeak-editor__preview,
.app-c-govspeak-editor__error,
.js-app-c-govspeak-editor__preview-button,
.js-app-c-govspeak-editor__back-button {
display: none;
}

.app-c-govspeak-editor__preview--show,
.app-c-govspeak-editor__error--show,
.app-c-govspeak-editor__preview-button--show,
.app-c-govspeak-editor__back-button--show {
display: block;
}
11 changes: 11 additions & 0 deletions app/controllers/admin/preview_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Admin::PreviewController < Admin::BaseController
include GovspeakPreviewHelper

def preview
if Govspeak::HtmlValidator.new(params[:body]).valid?
render layout: false
else
render plain: "Content contains possible XSS exploits", status: :forbidden
end
end
end
92 changes: 92 additions & 0 deletions app/helpers/govspeak_preview_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
require "delegate"

module GovspeakPreviewHelper
include Rails.application.routes.url_helpers

def govspeak_to_html(govspeak, options = {})
processed_govspeak = preprocess_govspeak(govspeak, options)
html = markup_to_nokogiri_doc(
processed_govspeak,
locale: options[:locale],
).to_html

"<div class=\"govspeak\">#{html}</div>".html_safe

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to use the content_tag helper here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but for now I'm just lifting and shifting selected code from Whitehall, so I'd prefer to do refactoring later to avoid any unintended side-effects.

end

def govspeak_headers(govspeak, level = (2..2))
build_govspeak_document(govspeak).headers.select do |header|
level.cover?(header.level)
end
end

def govspeak_header_hierarchy(govspeak)
headers = []
govspeak_headers(govspeak, 2..3).each do |header|
case header.level
when 2
headers << { header:, children: [] }
when 3
raise Govspeak::OrphanedHeadingError, header.text if headers.none?

headers.last[:children] << header
end
end
headers
end

def preprocess_govspeak(govspeak, options)
govspeak ||= ""
ContentBlockManager::FindAndReplaceEmbedCodesService.call(govspeak) if options[:preview]
govspeak = add_heading_numbers(govspeak) if options[:heading_numbering] == :auto
govspeak
end

private

def add_heading_numbers(govspeak)
h2 = 0
h3 = 0

govspeak.gsub(/^(###|##)\s*(.+)$/) do
hashes = Regexp.last_match(1)
heading_text = Regexp.last_match(2).strip

if hashes == "##"
h2 += 1
h3 = 0
num = "#{h2}."
else # "###"
h2 = 1 if h2.zero?
h3 += 1
num = "#{h2}.#{h3}"
end

# We have to manually derive and append a slug otherwise when Govspeak
# generates the HTML, it includes the <span> and number in the ID. Hence
# the `heading_text.parameterize`
"#{hashes} <span class=\"number\">#{num} </span>#{heading_text} {##{heading_text.parameterize}}"
end
end

def markup_to_nokogiri_doc(govspeak, options = {})
govspeak = build_govspeak_document(govspeak, options)
doc = Nokogiri::HTML::Document.new
doc.encoding = "UTF-8"
doc.fragment(govspeak.to_html)
end

def build_govspeak_document(govspeak, options = {})
locale = options[:locale]

Govspeak::Document.new(
govspeak,
images: [],
attachments: [],
document_domains: [
ContentBlockManager.admin_host,
ContentBlockManager.public_host,
],
locale:,
)
end
end
10 changes: 10 additions & 0 deletions app/views/admin/preview/preview.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<section>
<article class="document">
<div class="body">
<%= govspeak_to_html(
params[:body],
preview: true,
) %>
</div>
</article>
</section>
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
get "/healthcheck/live", to: proc { [200, {}, %w[OK]] }
get "/healthcheck/ready", to: GovukHealthcheck.rack_response

namespace :admin do
post "preview" => "preview#preview"
end

namespace :content_block_manager, path: "/" do
root to: "content_block/documents#index", via: :get

Expand Down
101 changes: 101 additions & 0 deletions docs/use_of_govspeak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Use of Govspeak

September 3, 2025.

Govspeak is the GOV.UK flavour of markdown which is offered to content creators
to format their work.

This is a summary of how, at this point in time, we are converting Govspeak into
HTML.

## 1) GovspeakEditor: when editing content

When composing or editing a govspeak-enabled `<textarea>` we offer users the
facility to toggle between "Preview" and "Edit" modes.

When choosing to "Preview" the content of the textarea, we use Ajax to send a
`POST` request to the `/admin/preview` endpoint. The `Admin::PreviewController`
uses the `GovspeakPreviewHelper` which ultimately calls `#to_haml` on a new
`Govspeak::Document`, defined in the `govspeak` gem, a dependency of the
`content_block_tools` gem.

## 2) render_govspeak: when displaying saved content

When displaying govspeak-enabled fields which have been saved, on workflow pages
such as:

- `editions/1/workflow/group_contact_methods#telephones` or
- `editions/1/workflow/review`

The `EmbeddedObjects::SummaryCard::NestedItemComponent` uses the
`ContentBlockManager::ContentBlock::GovspeakHelper` and calls out to
`render_govspeak` in the content_block_tools gem:

```rb
module ContentBlockTools
module Govspeak
def render_govspeak(body, root_class: nil)
html = ::Govspeak::Document.new(body).to_html
Nokogiri::HTML.fragment(html).tap { |fragment|
fragment.children[0].add_class(root_class) if root_class
}.to_s.html_safe
end
end
end
```

## 3) DefaultBlockComponent: rendering entire block

Once a content block is published and can be viewed on the "show" block page:
e.g. `/content-block/1`, we use the `DefaultBlockComponent` which renders the
block using the `content_block_tools` gem:

```rb
ContentBlockTools::ContentBlock.new(
document_type: "content_block_#{block_type}",
content_id: document.content_id,
title:,
details:,
embed_code:,
).render
```

When rendering the `ContactComponent`, for example, the `content_block_tools`
gem uses `ContentBlockTools::Govspeak::render_govspeak` (see [2]) on the
govspeak-enabled fields. See the `telephone_component.html.erb`:

```rb
<% if show_video_relay_service? %>
<%= render_govspeak(video_relay_service_content) %>
<% end %>

<% if show_bsl_guidance? %>
<%= render_govspeak(bsl_guidance[:value], root_class: "content-block__body") %>
<% end %>

<% if show_opening_hours? %>
<%= render_govspeak(opening_hours[:opening_hours], root_class: "content-block__body") %>
<% end %>
```

## Notes

- Currently the only fields which can be govspeak-enabled are on embedded objects
(aka "nested objects", "nested items"). It will take a bit of work to make
`GovspeakEnabledTextareaComponent` general-purpose, as currently `govspeak-enabled?`
is defined only on `EmbeddedSchema`.

- We have two similarly named helpers which ought to be combined:

1. `GovspeakPreviewHelper` used by GovspeakEditor
2. `GovspeakHelper` used by `NestedItemComponent`

- We also had (copied over from Whitehall but now deleted) a
`Whitehall::GovspeakRenderer` which inherits from `ActionController::Renderer`
and provides the following `helpers` methods:

- `govspeak_edition_to_html`
- `govspeak_to_html`
- `govspeak_with_attachments_to_html`
- `govspeak_html_attachment_to_html`
- `block_attachments`
Loading