diff --git a/assets/css/content/admonition.css b/assets/css/content/admonition.css index 863b91e44..a7ae3aee0 100644 --- a/assets/css/content/admonition.css +++ b/assets/css/content/admonition.css @@ -1,36 +1,36 @@ -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) { +.content-inner section.admonition { border-radius: 10px; border-left: 0; } -.content-inner blockquote.warning { +.content-inner section.admonition.warning { background-color: var(--warningBackground); } -.content-inner blockquote.error { +.content-inner section.admonition.error { background-color: var(--errorBackground); } -.content-inner blockquote.info { +.content-inner section.admonition.info { background-color: var(--infoBackground); } -.content-inner blockquote.neutral { +.content-inner section.admonition.neutral { background-color: var(--neutralBackground); } -.content-inner blockquote.tip { +.content-inner section.admonition.tip { background-color: var(--tipBackground); } -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) { +.content-inner section.admonition > .admonition-title { color: var(--contrast); margin: 0 -1.2rem; padding: .7rem 1.2rem .7rem 3.3rem; font-weight: 700; font-style: normal; } -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip)::before { +.content-inner section.admonition > .admonition-title::before { color: var(--contrast); position: absolute; left: 1rem; @@ -41,74 +41,74 @@ -moz-osx-font-smoothing: grayscale; } -.content-inner blockquote :is(h3, h4).warning { +.content-inner section.admonition > .admonition-title.warning { background-color: var(--warningHeadingBackground); color: var(--warningHeading); } -.content-inner blockquote :is(h3, h4).warning::before { +.content-inner section.admonition > .admonition-title.warning::before { content: var(--icon-error-warning); color: var(--warningHeading); } -.content-inner blockquote :is(h3, h4).error { +.content-inner section.admonition > .admonition-title.error { background-color: var(--errorHeadingBackground); color: var(--errorHeading); } -.content-inner blockquote :is(h3, h4).error::before { +.content-inner section.admonition > .admonition-title.error::before { content: var(--icon-error-warning); color: var(--errorHeading); } -.content-inner blockquote :is(h3, h4).info { +.content-inner section.admonition > .admonition-title.info { background-color: var(--infoHeadingBackground); color: var(--infoHeading); } -.content-inner blockquote :is(h3, h4).info::before { +.content-inner section.admonition > .admonition-title.info::before { content: var(--icon-information); color: var(--infoHeading); } -.content-inner blockquote :is(h3, h4).neutral { +.content-inner section.admonition > .admonition-title.neutral { background-color: var(--neutralHeadingBackground); color: var(--neutralHeading); } -.content-inner blockquote :is(h3, h4).neutral::before { +.content-inner section.admonition > .admonition-title.neutral::before { content: var(--icon-double-quotes-l); color: var(--neutralHeading); } -.content-inner blockquote :is(h3, h4).tip { +.content-inner section.admonition > .admonition-title.tip { background-color: var(--tipHeadingBackground); color: var(--tipHeading); } -.content-inner blockquote :is(h3, h4).tip::before { +.content-inner section.admonition > .admonition-title.tip::before { content: var(--icon-information); color: var(--tipHeading); } -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) code { +.content-inner section.admonition > .admonition-title code { margin: 0 0.5ch; } -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) code { +.content-inner section.admonition code { background-color: var(--admInlineCodeBackground); border: 1px solid var(--admInlineCodeBorder); color: var(--admInlineCodeColor); } -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) pre code { +.content-inner section.admonition pre code { background-color: var(--admCodeBackground); border: 1px solid var(--admCodeBorder); color: var(--admCodeColor); } -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) :is(a, a:visited) { +.content-inner section.admonition > .admonition-title :is(a, a:visited) { color: inherit; text-decoration-color: currentColor; } @media screen and (max-width: 768px) { - .content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) { + .content-inner section.admonition { margin-left: calc(-1 * var(--content-gutter)); margin-right: calc(-1 * var(--content-gutter)); padding-left: var(--content-gutter); @@ -116,7 +116,7 @@ border-radius: 0; } - .content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) { + .content-inner section.admonition > .admonition-title { margin: 0 calc(-1 * var(--content-gutter)); } } diff --git a/assets/css/content/epub-admonition.css b/assets/css/content/epub-admonition.css index 0e26d2b9e..be7cefe62 100755 --- a/assets/css/content/epub-admonition.css +++ b/assets/css/content/epub-admonition.css @@ -1,4 +1,4 @@ -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) { +.content-inner section.admonition { border-left: solid 4px; color: var(--black); font-size: 0.9em; @@ -9,68 +9,65 @@ page-break-inside: avoid; } -.content-inner blockquote.warning { +.content-inner section.admonition.warning { background-color: var(--warningBackground); border-left-color: var(--warningHeadingBackground); } -.content-inner blockquote.error { +.content-inner section.admonition.error { background-color: var(--errorBackground); border-left-color: var(--errorHeadingBackground); } -.content-inner blockquote.info { +.content-inner section.admonition.info { background-color: var(--infoBackground); border-left-color: var(--infoHeadingBackground); } -.content-inner blockquote.neutral { +.content-inner section.admonition.neutral { background-color: var(--neutralBackground); border-left-color: var(--neutralHeadingBackground); } -.content-inner blockquote.tip { +.content-inner section.admonition.tip { background-color: var(--tipBackground); border-left-color: var(--tipHeadingBackground); } -.content-inner blockquote :is(h3, h4) { +.content-inner section.admonition > .admonition-title { font-weight: bold; margin: 0px 10px 5px 0px; -} - -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) { font-style: normal; font-weight: 700; } -.content-inner blockquote :is(h3, h4).warning { +.content-inner section.admonition > .admonition-title.warning { color: var(--warningHeadingBackground); } -.content-inner blockquote :is(h3, h4).error { +.content-inner section.admonition > .admonition-title.error { color: var(--errorHeadingBackground); } -.content-inner blockquote :is(h3, h4).info { +.content-inner section.admonition > .admonition-title.info { color: var(--infoHeadingBackground); } -.content-inner blockquote :is(h3, h4).neutral { +.content-inner section.admonition > .admonition-title.neutral { color: var(--neutralHeadingBackground); } -.content-inner blockquote :is(h3, h4).tip { +.content-inner section.admonition > .admonition-title.tip { color: var(--tipHeadingBackground); } -.content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) code { +.content-inner section.admonition > .admonition-title code { margin: 0 0.5ch; } -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) code { +.content-inner section.admonition code { background-color: var(--admInlineCodeBackground); border: 1px solid var(--admInlineCodeBorder); color: var(--admInlineCodeColor); } -.content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) pre code { +.content-inner section.admonition pre code { background-color: var(--admCodeBackground); border: 1px solid var(--admCodeBorder); color: var(--admCodeColor); diff --git a/assets/css/content/general.css b/assets/css/content/general.css index bf64ab3f2..5f727446a 100644 --- a/assets/css/content/general.css +++ b/assets/css/content/general.css @@ -97,7 +97,7 @@ font-weight: normal; } -.content-inner blockquote { +.content-inner blockquote, .content-inner section.admonition { border-left: 3px solid var(--blockquoteBorder); position: relative; margin: 1.5625em 0; @@ -106,7 +106,7 @@ background-color: var(--blockquoteBackground); border-radius: var(--borderRadius); } -.content-inner blockquote p:last-child { +.content-inner blockquote p:last-child, .content-inner section.admonition p:last-child { padding-bottom: 1em; margin-bottom: 0; } @@ -178,7 +178,7 @@ } } -.content-inner blockquote .section-heading i { +.content-inner :is(blockquote, section.admonition) .section-heading i { display: none; } diff --git a/assets/css/print.css b/assets/css/print.css index 94a8c522b..cc579c3f3 100644 --- a/assets/css/print.css +++ b/assets/css/print.css @@ -52,11 +52,11 @@ display: none; } - .content-inner blockquote:is(.warning, .error, .info, .neutral, .tip) { + .content-inner section.admonition { border: 2px solid var(--gray400); } - .content-inner blockquote :is(h3, h4):is(.warning, .error, .info, .neutral, .tip) { + .content-inner section.admonition > .admonition-title { color: var(--textHeaders); border-bottom: 2px solid var(--gray400); } diff --git a/assets/css/tabset.css b/assets/css/tabset.css index 63cf54067..697e0ef16 100644 --- a/assets/css/tabset.css +++ b/assets/css/tabset.css @@ -58,7 +58,8 @@ } .tabset-panel pre, - .tabset-panel blockquote { + .tabset-panel blockquote, + .tabset-panel section.admonition { margin-left: calc(-1 * var(--tabsetPadding)) !important; margin-right: calc(-1 * var(--tabsetPadding)) !important; } diff --git a/assets/js/content.js b/assets/js/content.js index 6e20f4c3a..49d14af28 100644 --- a/assets/js/content.js +++ b/assets/js/content.js @@ -16,7 +16,6 @@ export function initialize (isPreview) { setLivebookBadgeUrl() fixLinks() - fixBlockquotes() } /** @@ -30,20 +29,6 @@ function fixLinks () { }) } -/** - * Add CSS classes to `blockquote` elements when those are used to - * support admonition text blocks - */ -export function fixBlockquotes () { - const classes = ['warning', 'info', 'error', 'neutral', 'tip'] - - classes.forEach(element => { - qsAll(`blockquote h3.${element}, blockquote h4.${element}`).forEach(header => { - header.closest('blockquote').classList.add(element) - }) - }) -} - /** * Focuses the content element. * diff --git a/assets/js/entry/epub.js b/assets/js/entry/epub.js index abf37e456..e48454c9c 100644 --- a/assets/js/entry/epub.js +++ b/assets/js/entry/epub.js @@ -1,8 +1,6 @@ import { onDocumentReady } from '../helpers' -import { fixBlockquotes } from '../content' import { initialize as initMakeup } from '../makeup' onDocumentReady(() => { initMakeup() - fixBlockquotes() }) diff --git a/lib/ex_doc/markdown/earmark.ex b/lib/ex_doc/markdown/earmark.ex index 20e80a51e..2e04bffcd 100644 --- a/lib/ex_doc/markdown/earmark.ex +++ b/lib/ex_doc/markdown/earmark.ex @@ -5,6 +5,8 @@ defmodule ExDoc.Markdown.Earmark do @behaviour ExDoc.Markdown + @admonition_classes ~w(warning error info tip neutral) + @impl true def available? do match?({:ok, _}, Application.ensure_all_started(:earmark_parser)) and @@ -74,6 +76,47 @@ defmodule ExDoc.Markdown.Earmark do "$$\n#{content}\n$$" end + # Convert admonition blockquotes to sections for screen reader accessibility + defp fixup( + {"blockquote", blockquote_attrs, [{tag, h_attrs, h_content, h_meta} | rest] = ast, + blockquote_meta} + ) + when tag in ["h3", "h4"] do + h_admonition = + with {{"class", classes}, attrs} <- List.keytake(h_attrs, "class", 0), + class_list <- String.split(classes, " "), + adm_classes = [_ | _] <- Enum.filter(class_list, &(&1 in @admonition_classes)) do + {"admonition " <> Enum.join(adm_classes, " "), + [{"class", "admonition-title #{classes}"} | attrs]} + else + _ -> nil + end + + section_attrs_fn = fn admonition_classes -> + {classes, attrs} = + case List.keytake(blockquote_attrs, "class", 0) do + nil -> + {admonition_classes, blockquote_attrs} + + {{"class", classes}, attrs} -> + {"#{admonition_classes} #{classes}", attrs} + end + + [{"role", "note"}, {"class", classes} | attrs] + end + + if h_admonition do + {admonition_classes, h_attrs} = h_admonition + section_attrs = section_attrs_fn.(admonition_classes) + h_elem = {tag, h_attrs, h_content, h_meta} + + fixup({"section", section_attrs, [h_elem | rest], blockquote_meta}) + else + # regular blockquote, copied fixup/1 here to avoid infinite loop + {:blockquote, Enum.map(blockquote_attrs, &fixup_attr/1), fixup(ast), blockquote_meta} + end + end + defp fixup({tag, attrs, ast, meta}) when is_binary(tag) and is_list(attrs) and is_map(meta) do {fixup_tag(tag), Enum.map(attrs, &fixup_attr/1), fixup(ast), meta} end diff --git a/test/ex_doc/markdown/earmark_test.exs b/test/ex_doc/markdown/earmark_test.exs index ae3f271b6..31649241e 100644 --- a/test/ex_doc/markdown/earmark_test.exs +++ b/test/ex_doc/markdown/earmark_test.exs @@ -75,6 +75,96 @@ defmodule ExDoc.Markdown.EarmarkTest do ] end + test "leaves blockquotes with the wrong markup as is" do + not_admonition = """ + > ### H3 {: .xyz} + > This is NOT an admonition! + """ + + assert Markdown.to_ast(not_admonition, []) == [ + {:blockquote, [], + [ + {:h3, [class: "xyz"], ["H3"], %{}}, + {:p, [], ["This is NOT an admonition!"], %{}} + ], %{}} + ] + + no_h_tag_beginning = """ + > {: .warning} + > #### Warning {: .warning} + > This blockquote didn't start with the h4 tag, so it wasn't converted. + """ + + assert Markdown.to_ast(no_h_tag_beginning, []) == [ + {:blockquote, [], + [ + {:p, [], ["{: .warning}"], %{}}, + {:h4, [class: "warning"], ["Warning"], %{}}, + {:p, [], + ["This blockquote didn't start with the h4 tag, so it wasn't converted."], %{}} + ], %{}} + ] + end + + test "converts blockquotes with the appropriate markup to admonition sections" do + info = """ + > #### Info {: .info .ignore} + > This is info. + """ + + assert [ + {:section, section_attrs, + [ + {:h4, h_attrs, ["Info"], %{}}, + {:p, [], ["This is info."], %{}} + ], %{}} + ] = Markdown.to_ast(info, []) + + assert section_attrs[:role] == "note" + assert section_attrs[:class] == "admonition info" + + assert h_attrs[:class] == "admonition-title ignore info" + + warning_error = """ + > ### Warning! Error! {: .warning .error} + > A warning and an error. + """ + + assert [ + {:section, section_attrs, + [ + {:h3, h_attrs, ["Warning! Error!"], %{}}, + {:p, [], ["A warning and an error."], %{}} + ], %{}} + ] = Markdown.to_ast(warning_error, []) + + assert section_attrs[:role] == "note" + assert section_attrs[:class] == "admonition error warning" + + assert h_attrs[:class] == "admonition-title error warning" + + with_blockquote_level_attrs = """ + > ### Eggs and baskets {: .tip} + > Don't put all your eggs in one basket, especially if they're golden. + {: .egg-basket-bg #egg-basket-tip} + """ + + assert [ + {:section, section_attrs, + [ + {:h3, h_attrs, ["Eggs and baskets"], %{}}, + {:p, [], + ["Don't put all your eggs in one basket, especially if they're golden."], %{}} + ], %{}} + ] = Markdown.to_ast(with_blockquote_level_attrs, []) + + assert section_attrs[:role] == "note" + assert section_attrs[:class] == "admonition tip egg-basket-bg" + assert section_attrs[:id] == "egg-basket-tip" + + assert h_attrs[:class] == "admonition-title tip" + end + test "keeps math syntax without interpreting math as markdown" do assert Markdown.to_ast("Math $x *y* y$", []) == [ {:p, [], ["Math ", "$x *y* y$"], %{}}