Merge pull request #8670 from mfo/US/markdown-like

amelioration(a11y): rend du texte saisi (par les admin/instructeur/expert) en html compatible avec les contraintes d'accessibilités
This commit is contained in:
mfo 2023-02-23 14:30:55 +00:00 committed by GitHub
commit d0e05784e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 206 additions and 133 deletions

View file

@ -70,6 +70,7 @@ gem 'rack-attack'
gem 'rails'
gem 'rails-i18n' # Locales par défaut
gem 'rake-progressbar', require: false
gem 'redcarpet'
gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325)
gem 'rgeo-geojson'
gem 'rqrcode'

View file

@ -573,6 +573,7 @@ GEM
rb-fsevent (0.10.4)
rb-inotify (0.10.1)
ffi (~> 1.0)
redcarpet (3.6.0)
regexp_parser (2.6.0)
request_store (1.5.0)
rack (>= 1.4)
@ -909,6 +910,7 @@ DEPENDENCIES
rails-erd
rails-i18n
rake-progressbar
redcarpet
rexml
rgeo-geojson
rqrcode

View file

@ -5,8 +5,6 @@ $procedure-context-breakpoint: $two-columns-breakpoint;
$procedure-description-line-height: 22px;
.procedure-preview {
font-size: 24px;
.paperless-logo {
width: 100%;
margin-bottom: 60px;
@ -74,17 +72,6 @@ $procedure-description-line-height: 22px;
border-bottom: 1px dotted $blue-france-500;
}
.procedure-description {
font-size: 16px;
p:not(:last-of-type) {
// Space the paragraphs by exactly one line height,
// so that the text always is truncated in the middle of a line,
// regarless of the number of paragraphs.
margin-bottom: 3 * $default-spacer;
}
}
.read-more-button {
display: none;
}

View file

@ -55,15 +55,6 @@ class Dossiers::MessageComponent < ApplicationComponent
l(commentaire.created_at, format: is_current_year ? :message_date : :message_date_with_year)
end
def commentaire_body
if commentaire.discarded?
t('.deleted_body')
else
body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body)
sanitize(body_formatted, commentaire.sent_by_system? ? { scrubber: Sanitizers::MailScrubber.new } : {})
end
end
def highlight?
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
end

View file

@ -8,7 +8,14 @@
%span.fr-text--xs.fr-text-mention--grey.font-weight-normal= t('.guest')
%span.date{ class: ["fr-text--xs", "fr-text-mention--grey", "font-weight-normal", highlight_if_unseen_class], data: scroll_to_target }
= commentaire_date
.rich-text= commentaire_body
.rich-text
- if commentaire.discarded?
%p= t('.deleted_body')
- elsif commentaire.sent_by_system?
= sanitize(commentaire.body, scrubber: Sanitizers::MailScrubber.new)
- else
= render SimpleFormatComponent.new(commentaire.body, allow_a: false)
.message-extras.flex.justify-start
- if commentaire.soft_deletable?(connected_user)

View file

@ -1,6 +1,7 @@
# see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/mise-en-avant
class Dsfr::CalloutComponent < ApplicationComponent
renders_one :body
renders_one :html_body
renders_one :bottom
attr_reader :title, :theme, :icon, :extra_class_names

View file

@ -1,5 +1,8 @@
%div{ class: callout_class }
- if title.present?
%h3.fr-callout__title= title
- if html_body?
.fr-callout__text= html_body
- if body?
%p.fr-callout__text= body
= bottom

View file

@ -1,6 +1,4 @@
class EditableChamp::ChampLabelComponent < ApplicationComponent
include StringToHtmlHelper
def initialize(form:, champ:, seen_at: nil)
@form, @champ, @seen_at = form, champ, seen_at
end

View file

@ -7,4 +7,4 @@
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- if @champ.description.present?
.notice{ id: @champ.describedby_id }= string_to_html(@champ.description, allow_a: true)
.notice{ id: @champ.describedby_id }= render SimpleFormatComponent.new(@champ.description, allow_a: true)

View file

@ -1,6 +1,4 @@
class EditableChamp::EditableChampComponent < ApplicationComponent
include StringToHtmlHelper
def initialize(form:, champ:, seen_at: nil)
@form, @champ, @seen_at = form, champ, seen_at
end

View file

@ -2,7 +2,7 @@
- if @champ.block?
%h3.header-subsection= @champ.libelle
- if @champ.description.present?
%p.notice= string_to_html(@champ.description, false, allow_a: true)
.notice= render SimpleFormatComponent.new(@champ.description, allow_a: true)
- elsif has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at

View file

@ -1,3 +1,2 @@
class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent
include StringToHtmlHelper
end

View file

@ -1,7 +1,7 @@
= render Dsfr::CalloutComponent.new(title: @champ.libelle, extra_class_names: ['fr-mb-2w', 'fr-callout--blue-cumulus']) do |c|
- c.with_body do
- c.with_html_body do
= string_to_html(@champ.description, allow_a: true)
= render SimpleFormatComponent.new(@champ.description, allow_a: true)
- if @champ.collapsible_explanation_enabled? && @champ.collapsible_explanation_text.present?
%div

View file

@ -1,3 +1,2 @@
class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent
include StringToHtmlHelper
end

View file

@ -8,7 +8,7 @@
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do
- sanitize((@champ.drop_down_secondary_libelle.presence || "Valeur secondaire dépendant de la première") + (@champ.type_de_champ.mandatory? ? tag.span(' *', class: 'mandatory') : ''))
- if @champ.drop_down_secondary_description.present?
.notice{ id: "#{@champ.describedby_id}-secondary" }= string_to_html(@champ.drop_down_secondary_description, allow_a: true)
.notice{ id: "#{@champ.describedby_id}-secondary" }= render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true)
= @form.select :secondary_value,
@champ.secondary_options[@champ.primary_value],
{},

View file

@ -0,0 +1,51 @@
class SimpleFormatComponent < ApplicationComponent
# see: https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use
REDCARPET_EXTENSIONS = {
no_intra_emphasis: false,
tables: false,
fenced_code_blocks: false,
autolink: false,
disable_indented_code_blocks: false,
strikethrough: false,
lax_spacing: false,
space_after_headers: false,
superscript: false,
underline: false,
highlight: false,
quote: false,
footnotes: false
}
# see: https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch
REDCARPET_RENDERER_OPTS = {
no_images: true
}
def initialize(text, allow_a: true, class_names_map: {})
@text = (text || "").gsub(/\R/, "\n\n") # force double \n otherwise a single one won't split paragraph
.split("\n\n") #
.map(&:lstrip) # this block prevent redcarpet to consider " text" as block code by lstriping
.join("\n\n") #
@allow_a = allow_a
@renderer = Redcarpet::Markdown.new(
Redcarpet::BareRenderer.new(link_attributes: external_link_attributes, class_names_map: class_names_map),
REDCARPET_EXTENSIONS.merge(autolink: @allow_a)
)
end
def external_link_attributes
{ target: '_blank', rel: 'noopener noreferrer' }
end
def tags
if @allow_a
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
else
Rails.configuration.action_view.sanitized_allowed_tags
end
end
def attributes
['target', 'rel', 'href', 'class']
end
end

View file

@ -0,0 +1 @@
= sanitize(@renderer.render(@text), tags:, attributes:)

View file

@ -1,15 +0,0 @@
module StringToHtmlHelper
def string_to_html(str, wrapper_tag = 'p', allow_a: false)
return nil if str.blank?
html_formatted = simple_format(str, {}, { wrapper_tag: wrapper_tag })
with_links = Anchored::Linker.auto_link(html_formatted, target: '_blank', rel: 'noopener')
tags = if allow_a
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
else
Rails.configuration.action_view.sanitized_allowed_tags
end
sanitize(with_links, tags:, attributes: ['target', 'rel', 'href'])
end
end

View file

@ -0,0 +1,21 @@
module Redcarpet
class BareRenderer < Redcarpet::Render::HTML
include ActionView::Helpers::TagHelper
# won't use rubocop tag method because it is missing output buffer
# rubocop:disable Rails/ContentTag
def list(content, list_type)
tag = list_type == :ordered ? :ol : :ul
content_tag(tag, content, { class: @options[:class_names_map].fetch(:list) {} }, false)
end
def list_item(content, list_type)
content_tag(:li, content.strip.gsub(/<\/?p>/, ''), {}, false)
end
def paragraph(text)
content_tag(:p, text, { class: @options[:class_names_map].fetch(:paragraph) {} }, false)
end
# rubocop:enable Rails/ContentTag
end
end

View file

@ -35,4 +35,4 @@
- if avis.piece_justificative_file.attached?
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
.answer-body
= simple_format(avis.answer)
= render SimpleFormatComponent.new(avis.answer, allow_a: false)

View file

@ -6,12 +6,12 @@
- if @dossier.etablissement&.as_degraded_mode?
.container
= render Dsfr::CalloutComponent.new(title: "Données de lentreprise non vérifiées", theme: :warning, icon: "fr-icon-feedback-fill") do |c|
- c.with_body do
Les services de lINSEE sont indisponibles, nous ne pouvons pas
vérifier les informations liées à létablissement de ce dossier.
%strong Il nest pas possible daccepter ou de refuser un dossier sans cette étape.
%br
%br
- c.with_html_body do
%p
Les services de lINSEE sont indisponibles, nous ne pouvons pas vérifier les informations liées à létablissement de ce dossier.
%strong
Il nest pas possible daccepter ou de refuser un dossier sans cette étape.
%p
Les informations sur l'entreprise arriveront dici quelques heures.
= render partial: "shared/dossiers/demande", locals: { dossier: @dossier, demande_seen_at: @demande_seen_at, profile: 'instructeur' }

View file

@ -51,4 +51,4 @@
- if avis.piece_justificative_file.attached?
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
.answer-body
= simple_format(avis.answer)
= render SimpleFormatComponent.new(avis.answer, allow_a: false)

View file

@ -1,6 +1,6 @@
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.json_description_title"), theme: :success, icon: "fr-icon-layout-grid-fill") do |c|
- c.with_body do
= t("views.prefill_descriptions.edit.json_description_info")
- c.with_html_body do
%p= t("views.prefill_descriptions.edit.json_description_info")
%pre
%code.code-block
= prefill_json_description_url(procedure.path)

View file

@ -5,8 +5,8 @@
- if prefill_description.prefilled_champs.any?
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.prefill_link_title"), theme: theme, icon: icon) do |c|
- c.with_body do
= body
- c.with_html_body do
%p= body
%pre
%code.code-block
= prefill_description.prefill_link

View file

@ -3,8 +3,8 @@
- if prefill_description.prefilled_champs.any?
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.prefill_query_title"), theme: :success, icon: "fr-icon-code-box-fill") do |c|
- c.with_body do
= t("views.prefill_descriptions.edit.prefill_query_info")
- c.with_html_body do
%p= t("views.prefill_descriptions.edit.prefill_query_info")
%pre
%code.code-block
= prefill_description.prefill_query

View file

@ -23,5 +23,5 @@
.procedure-description
.procedure-description-body.read-more-enabled.read-more-collapsed{ tabindex: "0", role: "region", "aria-label": t('views.users.dossiers.identite.description') }
= h string_to_html(procedure.description, allow_a: true)
= h render SimpleFormatComponent.new(procedure.description, allow_a: true)
= button_tag "Afficher la description complète", class: 'button read-more-button'

View file

@ -6,11 +6,11 @@
Pour poser une question sur ce dossier, contactez :
%p
= service.nom
%br
%p
= service.organisme
%br
- horaires = "Horaires : #{formatted_horaires(service.horaires)}"
= simple_format(horaires)
- if service.horaires.present?
= render SimpleFormatComponent.new("Horaires : #{formatted_horaires(service.horaires)}")
%p
= mail_to service.email,
service.email,

View file

@ -12,11 +12,14 @@
- elsif service.present?
%li
= link_to I18n.t('users.procedure_footer.contact.email.link', service_email: service.email), "mailto:#{service.email}", class: 'fr-footer__top-link'
- if service.telephone.present? || service.horaires.present?
%li
- horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}"
- if service.telephone.present?
= link_to service.telephone_url, class: 'fr-footer__top-link' do
%p
= I18n.t('users.procedure_footer.contact.phone.link', service_telephone: service.telephone)
- if service.horaires.present?
%p
= horaires
%li
@ -65,12 +68,12 @@
- if service.present?
.fr-footer__content
%p.fr-footer__content-desc
= I18n.t('users.procedure_footer.managed_by.header')
%span{ lang: :fr }
= "#{service.nom},"
= "#{service.organisme},"
= string_to_html(service.adresse, 'span')
%span{ lang: :fr }= "#{service.nom}, #{service.organisme},"
%div{ lang: :fr }
= render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'})
= render partial: "shared/footer_content_list"
.fr-footer__bottom

View file

@ -0,0 +1,73 @@
describe SimpleFormatComponent, type: :component do
let(:allow_a) { false }
before { render_inline(described_class.new(text, allow_a: allow_a)) }
context 'one line' do
let(:text) do
"1er paragraphe"
end
it { expect(page).to have_selector("p", count: 1, text: text) }
end
context 'one with leading spaces' do
let(:text) do
<<-TEXT
1er paragraphe
TEXT
end
it { expect(page).to have_selector("p", count: 1, text: text.strip) }
end
context 'two lines' do
let(:text) do
<<~TEXT
1er paragraphe
2eme paragraphe
TEXT
end
it { expect(page).to have_selector("p", count: 2) }
it { text.split("\n").map(&:strip).map { expect(page).to have_text(_1) } }
end
context 'unordered list items' do
let(:text) do
<<~TEXT
- 1er paragraphe
- paragraphe
TEXT
end
it { expect(page).to have_selector("ul", count: 1) }
it { expect(page).to have_selector("li", count: 2) }
end
context 'ordered list items' do
let(:text) do
<<~TEXT
1. 1er paragraphe
2. paragraphe
TEXT
end
it { expect(page).to have_selector("ol", count: 1) }
it { expect(page).to have_selector("li", count: 2) }
end
context 'auto-link' do
let(:text) do
<<~TEXT
bonjour https://www.demarches-simplifiees.fr
TEXT
end
context 'enabled' do
let(:allow_a) { true }
it { expect(page).to have_selector("a") }
end
context 'disabled' do
it { expect(page).not_to have_selector("a") }
end
end
end

View file

@ -1,49 +0,0 @@
RSpec.describe StringToHtmlHelper, type: :helper do
describe "#string_to_html" do
let(:allow_a) { false }
subject { string_to_html(description, allow_a:) }
context "with some simple texte" do
let(:description) { "1er ligne \n 2ieme ligne" }
it { is_expected.to eq("<p>1er ligne \n<br> 2ieme ligne</p>") }
end
context "with a link" do
context "using an authorized scheme" do
let(:description) { "Cliquez sur https://d-s.fr pour continuer." }
context 'with a tag authorized' do
let(:allow_a) { true }
it { is_expected.to eq("<p>Cliquez sur <a href=\"https://d-s.fr\" target=\"_blank\" rel=\"noopener\">https://d-s.fr</a> pour continuer.</p>") }
end
context 'without a tag' do
it { is_expected.to eq("<p>Cliquez sur https://d-s.fr pour continuer.</p>") }
end
end
context "using a non-authorized scheme" do
let(:description) { "Cliquez sur file://etc/password pour continuer." }
it { is_expected.to eq("<p>Cliquez sur file://etc/password pour continuer.</p>") }
end
context "not actually an URL" do
let(:description) { "Pour info: il ne devrait y avoir aucun lien." }
it { is_expected.to eq("<p>Pour info: il ne devrait y avoir aucun lien.</p>") }
end
end
context "with empty decription" do
let(:description) { nil }
it { is_expected.to eq nil }
end
context "with a bad script" do
let(:description) { '<script>bad</script>' }
it { is_expected.to eq('<p>bad</p>') }
end
end
end

View file

@ -63,7 +63,7 @@ describe 'Inviting an expert:', js: true do
click_on 'Avis externes'
expect(page).to have_content(answered_avis.expert.email)
answered_avis.answer.split("\n").each do |answer_line|
answered_avis.answer.split("\n").map { |line| line.gsub("- ", "") }.map do |answer_line|
expect(page).to have_content(answer_line)
end
end

View file

@ -26,7 +26,9 @@ describe 'instructeurs/shared/avis/_list.html.haml', type: :view do
let(:avis) { [create(:avis, :with_answer, claimant: instructeur, experts_procedure: experts_procedure)] }
it 'renders the answer formatted with newlines' do
expect(subject).to include(simple_format(avis.first.answer))
expect(subject).to have_selector(".answer-body p", text: avis.first.answer.split("\n").first)
expect(subject).to have_selector(".answer-body ul", count: 1) # avis.answer has two list item
expect(subject).to have_selector(".answer-body ul li", count: 2)
end
end