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'
gem 'rails-i18n' # Locales par défaut gem 'rails-i18n' # Locales par défaut
gem 'rake-progressbar', require: false gem 'rake-progressbar', require: false
gem 'redcarpet'
gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325) gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325)
gem 'rgeo-geojson' gem 'rgeo-geojson'
gem 'rqrcode' gem 'rqrcode'

View file

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

View file

@ -5,8 +5,6 @@ $procedure-context-breakpoint: $two-columns-breakpoint;
$procedure-description-line-height: 22px; $procedure-description-line-height: 22px;
.procedure-preview { .procedure-preview {
font-size: 24px;
.paperless-logo { .paperless-logo {
width: 100%; width: 100%;
margin-bottom: 60px; margin-bottom: 60px;
@ -74,17 +72,6 @@ $procedure-description-line-height: 22px;
border-bottom: 1px dotted $blue-france-500; 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 { .read-more-button {
display: none; 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) l(commentaire.created_at, format: is_current_year ? :message_date : :message_date_with_year)
end 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? def highlight?
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at) commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
end end

View file

@ -8,7 +8,14 @@
%span.fr-text--xs.fr-text-mention--grey.font-weight-normal= t('.guest') %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 } %span.date{ class: ["fr-text--xs", "fr-text-mention--grey", "font-weight-normal", highlight_if_unseen_class], data: scroll_to_target }
= commentaire_date = 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 .message-extras.flex.justify-start
- if commentaire.soft_deletable?(connected_user) - 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 # see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/mise-en-avant
class Dsfr::CalloutComponent < ApplicationComponent class Dsfr::CalloutComponent < ApplicationComponent
renders_one :body renders_one :body
renders_one :html_body
renders_one :bottom renders_one :bottom
attr_reader :title, :theme, :icon, :extra_class_names attr_reader :title, :theme, :icon, :extra_class_names

View file

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

View file

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

View file

@ -7,4 +7,4 @@
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at = render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- if @champ.description.present? - 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 class EditableChamp::EditableChampComponent < ApplicationComponent
include StringToHtmlHelper
def initialize(form:, champ:, seen_at: nil) def initialize(form:, champ:, seen_at: nil)
@form, @champ, @seen_at = form, champ, seen_at @form, @champ, @seen_at = form, champ, seen_at
end end

View file

@ -2,7 +2,7 @@
- if @champ.block? - if @champ.block?
%h3.header-subsection= @champ.libelle %h3.header-subsection= @champ.libelle
- if @champ.description.present? - 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) - elsif has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at = render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at

View file

@ -1,3 +1,2 @@
class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent
include StringToHtmlHelper
end 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| = 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? - if @champ.collapsible_explanation_enabled? && @champ.collapsible_explanation_text.present?
%div %div

View file

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

View file

@ -8,7 +8,7 @@
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do = @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') : '')) - 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? - 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, = @form.select :secondary_value,
@champ.secondary_options[@champ.primary_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? - if avis.piece_justificative_file.attached?
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment) = render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
.answer-body .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? - if @dossier.etablissement&.as_degraded_mode?
.container .container
= render Dsfr::CalloutComponent.new(title: "Données de lentreprise non vérifiées", theme: :warning, icon: "fr-icon-feedback-fill") do |c| = 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 - c.with_html_body do
Les services de lINSEE sont indisponibles, nous ne pouvons pas %p
vérifier les informations liées à létablissement de ce dossier. 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. %strong
%br Il nest pas possible daccepter ou de refuser un dossier sans cette étape.
%br %p
Les informations sur l'entreprise arriveront dici quelques heures. 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' } = 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? - if avis.piece_justificative_file.attached?
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment) = render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
.answer-body .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| = 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 - c.with_html_body do
= t("views.prefill_descriptions.edit.json_description_info") %p= t("views.prefill_descriptions.edit.json_description_info")
%pre %pre
%code.code-block %code.code-block
= prefill_json_description_url(procedure.path) = prefill_json_description_url(procedure.path)

View file

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

View file

@ -3,8 +3,8 @@
- if prefill_description.prefilled_champs.any? - 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| = 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 - c.with_html_body do
= t("views.prefill_descriptions.edit.prefill_query_info") %p= t("views.prefill_descriptions.edit.prefill_query_info")
%pre %pre
%code.code-block %code.code-block
= prefill_description.prefill_query = prefill_description.prefill_query

View file

@ -23,5 +23,5 @@
.procedure-description .procedure-description
.procedure-description-body.read-more-enabled.read-more-collapsed{ tabindex: "0", role: "region", "aria-label": t('views.users.dossiers.identite.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' = 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 : Pour poser une question sur ce dossier, contactez :
%p %p
= service.nom = service.nom
%br %p
= service.organisme = service.organisme
%br
- horaires = "Horaires : #{formatted_horaires(service.horaires)}" - if service.horaires.present?
= simple_format(horaires) = render SimpleFormatComponent.new("Horaires : #{formatted_horaires(service.horaires)}")
%p %p
= mail_to service.email, = mail_to service.email,
service.email, service.email,

View file

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

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' click_on 'Avis externes'
expect(page).to have_content(answered_avis.expert.email) 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) expect(page).to have_content(answer_line)
end end
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)] } let(:avis) { [create(:avis, :with_answer, claimant: instructeur, experts_procedure: experts_procedure)] }
it 'renders the answer formatted with newlines' do 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
end end