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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ Files without a language code (`page.md`) are still served, as a last resort, fo

The extensions `toc`, `meta`, `tables`, and a built-in Bootstrap extension are always loaded.

An image that carries a title and stands alone in its paragraph is rendered as a
captioned `<figure>`: the title becomes the caption, and the alt text keeps describing
the image for those who cannot see it. An image without a title, or one sitting inside a
sentence, is rendered as a plain `<img>`.

## Development

### Requirements
Expand Down
62 changes: 60 additions & 2 deletions flask_wiki/markdown_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@

"""Python-Markdown extensions."""

from xml.etree.ElementTree import Element, SubElement

from markdown import Extension
from markdown.treeprocessors import Treeprocessor


class BootstrapExtension(Extension):
"""Python-Markdown extension that adds Bootstrap CSS classes to HTML elements."""
"""Python-Markdown extension that adapts the rendered HTML to the wiki templates."""

def extendMarkdown(self, md): # noqa: N802
"""Register the Bootstrap tree processor with the Markdown instance."""
"""Register the wiki tree processors with the Markdown instance."""
md.registerExtension(self)
self.processor = BootstrapTreeprocessor()
self.processor.md = md
self.processor.config = self.getConfigs()
md.treeprocessors.register(self.processor, "bootstrap", 1)
md.treeprocessors.register(FigureTreeprocessor(md), "figure", 10)


class BootstrapTreeprocessor(Treeprocessor):
Expand All @@ -35,3 +38,58 @@ def run(self, node):
child.set("class", "table table-striped")

return node


class FigureTreeprocessor(Treeprocessor):
"""Tree processor that renders a captioned image as a figure."""

def run(self, node):
"""Replace every paragraph made of a single titled image with a figure.

:param node: the root element tree node
:returns: the modified element tree node
"""
for parent in node.iter():
for index, child in enumerate(parent):
if self.is_lone_titled_image(child):
parent[index] = self.figure(child)

return node

@staticmethod
def is_lone_titled_image(element):
"""Return True if the element is a paragraph holding nothing but a titled image.

A figure is a block element, so an image only becomes one when it stands alone
in its paragraph; and its caption is the image title, so an image without one
is left as it is.

:param element: the element tree node to check
:rtype: bool
"""
return bool(
element.tag == "p"
and not (element.text or "").strip()
and len(element) == 1
and element[0].tag == "img"
and not (element[0].tail or "").strip()
and element[0].get("title")
)

@staticmethod
def figure(paragraph):
"""Return the figure replacing a paragraph made of a single titled image.

The title becomes the caption and is removed from the image, which would
otherwise repeat it as a tooltip. The alt text is left untouched, as it
describes the image for those who cannot see it.

:param paragraph: the paragraph element tree node
:returns: a figure element wrapping the image and its caption
"""
image = paragraph[0]
figure = Element("figure")
figure.tail = paragraph.tail
figure.append(image)
SubElement(figure, "figcaption").text = image.attrib.pop("title")
return figure
25 changes: 23 additions & 2 deletions flask_wiki/static/css/wiki.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ h1 {
max-width: 55rem;
}

/* Figures, as rendered for an image carrying a title. */
.content figure {
max-width: 85%;
margin: 0 auto;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 5px;
}

.content figure img {
border-radius: 5px;
}

.content figcaption {
padding-top: 0.5rem;
text-align: center;
font-size: 90%;
color: #6c757d;
}

.wiki-page .toc ul {
padding-left: 1rem;
}
Expand All @@ -39,10 +59,11 @@ h1 {
height: 400px;
}

.toast {
position: absolute;
.wiki-toasts {
position: fixed;
top: 50px;
left: 15px;
z-index: 1090;
}

.wiki-files,
Expand Down
28 changes: 15 additions & 13 deletions flask_wiki/templates/wiki/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,22 @@
{{ render_messages(dismissible=True, dismiss_animate=True) }}
{% block navigation %} {%- include "wiki/navigation.html" %} {% endblock %}
{% block content %}
<div data-delay="2500" id="copy-success" class="toast hide">
<div class="toast-header text-primary font-weight-bold">
Markdown
<div class="wiki-toasts">
<div data-delay="2500" id="copy-success" class="toast hide">
<div class="toast-header text-primary font-weight-bold">
Markdown
</div>
<div class="toast-body">
{{ _('Link copied to your clipboard') }}
</div>
</div>
<div class="toast-body">
{{ _('Link copied to your clipboard') }}
</div>
</div>
<div data-delay="2500" id="copy-error" class="toast hide">
<div class="toast-header text-primary font-weight-bold">
Markdown
</div>
<div class="toast-body">
{{ _('Failed to copy link to your clipboard') }}
<div data-delay="2500" id="copy-error" class="toast hide">
<div class="toast-header text-primary font-weight-bold">
Markdown
</div>
<div class="toast-body">
{{ _('Failed to copy link to your clipboard') }}
</div>
</div>
</div>
{% endblock %}
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ dependencies = [
"beautifulsoup4",
"click",
"whoosh-reloaded>=2.7.5",
"markdown-captions>=2.1.2",
"poethepoet",
]

Expand Down
30 changes: 30 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ def test_processor_table_bootstrap(app):
assert "table-striped" in html


def test_processor_figure(app):
"""Test that a titled image standing alone in its paragraph becomes a figure."""
with app.test_request_context():
text = 'title: T\n\n![Alt text](image.png "A caption")'
processor = Processor(text)
html, _body, _meta, _toc = processor.process()

assert "<figure>" in html
assert "<figcaption>A caption</figcaption>" in html
# the figure is a block of its own, not nested in a paragraph
assert "<p>" not in html
# the alt text keeps describing the image, the title is consumed
assert 'alt="Alt text"' in html
assert "title=" not in html


def test_processor_image_left_alone(app):
"""Test that an image is only made a figure when it can be a captioned block."""
with app.test_request_context():
# without a title there is nothing to caption the image with
html, _body, _meta, _toc = Processor("title: T\n\n![Alt text](image.png)").process()
assert "<figure>" not in html

# an image inside a sentence stays inline, a figure being a block
text = 'title: T\n\nSee ![Alt text](image.png "A caption") here.'
html, _body, _meta, _toc = Processor(text).process()
assert "<figure>" not in html
assert 'title="A caption"' in html


def test_page_load_and_render(app):
"""Test loading and rendering a page from disk."""
with app.test_request_context():
Expand Down
20 changes: 20 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import io
import os
import re
from html.parser import HTMLParser
from pathlib import Path

from tests.conftest import TINY_PNG

Expand Down Expand Up @@ -267,6 +269,24 @@ def test_pages_do_not_reference_external_assets(client):
assert not asset.startswith(("http://", "https://", "//")), f"{url}: {asset}"


def test_toasts_are_placed_by_their_own_wrapper(client, app):
"""Test that the toasts are positioned by a wiki class instead of `.toast`.

Styling the Bootstrap component itself leaks into the host application, which
places the wiki toasts with its own rules.
"""
assert 'class="wiki-toasts"' in client.get("/help/home/").data.decode()

stylesheet = Path(app.blueprints["wiki"].static_folder, "css", "wiki.css")
css = re.sub(r"/\*.*?\*/", "", stylesheet.read_text(), flags=re.DOTALL)
selectors = {
selector.strip() for rule in css.split("}") if "{" in rule for selector in rule.rsplit("{", 1)[0].split(",")
}
assert ".wiki-toasts" in selectors
for selector in selectors:
assert "toast" not in re.findall(r"\.([-\w]+)", selector), selector


def test_icons_come_from_the_bootstrap_sprite(client):
"""Test that template icons are inline SVG from the Bootstrap Icons sprite."""
html = client.get("/help/home/").data.decode()
Expand Down
11 changes: 0 additions & 11 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.