-
Notifications
You must be signed in to change notification settings - Fork 2
CM-420 add GovspeakEditor to govspeak-enabled textareas #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
bcc4d5a
Add POST '/admin/preview' route for Govspeak
edavey 2c57a1d
Add styling for govspeak-editor
edavey e1985d5
Add govspeak-editor JS
edavey 72d0c34
Add GovspeakPreviewHelper
edavey 735503d
Return fragment of HTML from govspeak POSTed to /preview
edavey 7de1567
Remove unused Whitehall::GovspeakRenderer
edavey 775e11c
Add documentation on current use of Govspeak
edavey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,3 +48,4 @@ Lint/MissingSuper: | |
| Rails/SaveBang: | ||
| Exclude: | ||
| - 'Rakefile' | ||
| - 'test/test_helper.rb' | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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` |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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_taghelper hereThere was a problem hiding this comment.
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.