Skip to content
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

Support entry and image links in plain-HTML entries #162

Merged
merged 3 commits into from Feb 14, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 6 additions & 2 deletions publ/entry.py
Expand Up @@ -25,6 +25,7 @@
from . import utils
from . import cards
from . import caching
from . import html_entry
from .utils import CallableProxy, TrueCallableProxy, make_slug

logger = logging.getLogger(__name__) # pylint: disable=invalid-name
Expand Down Expand Up @@ -99,7 +100,7 @@ def type(self):
@cached_property
def status(self):
""" Returns a string version of the entry status """
return Model.PublishStatus(self.status)
return model.PublishStatus(self.status)

@cached_property
def next(self):
Expand Down Expand Up @@ -246,7 +247,10 @@ def _get_markup(self, text, is_markdown, **kwargs):
config=kwargs,
search_path=self.search_path)

return flask.Markup(text)
return html_entry.process(
text,
config=kwargs,
search_path=self.search_path)

def _get_card(self, text, **kwargs):
""" Render out the tags for a Twitter/OpenGraph card for this entry. """
Expand Down
102 changes: 102 additions & 0 deletions publ/html_entry.py
@@ -0,0 +1,102 @@
# html_entry.py
""" HTML entry processing functionality """

import misaka
import flask

from . import utils, links, image


class HTMLEntry(utils.HTMLTransform):
""" An HTML manipulator to fixup src and href attributes """

def __init__(self, config, search_path):
super().__init__()

self._search_path = search_path
self._config = config

def handle_data(self, data):
self.append(data)

def handle_starttag(self, tag, attrs):
""" Handle a start tag """
self._handle_tag(tag, attrs, False)

def handle_endtag(self, tag):
""" Handle an end tag """
self.append('</' + tag + '>')

def handle_startendtag(self, tag, attrs):
""" Handle a self-closing tag """
self._handle_tag(tag, attrs, True)

def _handle_tag(self, tag, attrs, self_closing):
""" Handle a tag.

attrs -- the attributes of the tag
self_closing -- whether this is self-closing
"""

if tag.lower() == 'img':
attrs = self._image_attrs(attrs)

# Remap the attributes
out_attrs = []
for key, val in attrs:
if (key.lower() == 'href'
or (key.lower() == 'src' and not tag.lower() == 'img')):
out_attrs.append((key, links.remap_path(
val, self._search_path, self._config.get('absolute'))))
else:
out_attrs.append((key, val))

self.append(
utils.make_tag(
tag,
out_attrs,
self_closing))

def _image_attrs(self, attrs):
""" Rewrite the SRC attribute on an <img> tag, possibly adding a SRCSET.
"""

path = None
config = {**self._config}

for key, val in attrs:
if key.lower() == 'width' or key.lower() == 'height':
try:
config[key.lower()] = int(val)
except ValueError:
pass
elif key.lower() == 'src':
path = val

img_path, img_args, _ = image.parse_image_spec(path)
img = image.get_image(img_path, self._search_path)

for key, val in img_args.items():
print(key, val)
if val and key not in config:
config[key] = val

try:
img_attrs = img.get_img_attrs(**config)
except FileNotFoundError as error:
return [('data-publ-error', 'file not found: {}'.format(error.filename))]

# return the original attr list with the computed overrides in place
return [(key, val) for key, val in attrs if key not in img_attrs] + [i for i in img_attrs.items()]


def process(text, config, search_path):
""" Process an HTML entry's HTML """
processor = HTMLEntry(config, search_path)
processor.feed(text)
text = processor.get_data()

if not config.get('no_smartquotes'):
text = misaka.smartypants(text)

return flask.Markup(text)
62 changes: 34 additions & 28 deletions publ/image.py
Expand Up @@ -52,13 +52,21 @@ def get_rendition(self, output_scale=1, **kwargs):

Returns: a tuple of (url, size) for the image. """

@abstractmethod
def get_img_attrs(self, style=None, **kwargs):
""" Get an attribute list (src, srcset, style, et al) for the image.

style -- an optional list of CSS style fragments

Returns: a dict of attributes e.g. {'src':'foo.jpg','srcset':'foo.jpg 1x, bar.jpg 2x']
"""

def get_img_tag(self, title='', alt_text='', **kwargs):
""" Build a <img> tag for the image with the specified options.

Returns: an HTML fragment. """

try:

style = []

for key in ('img_style', 'style'):
Expand All @@ -76,15 +84,25 @@ def get_img_tag(self, title='', alt_text='', **kwargs):
if shape:
style.append("shape-outside: url('{}')".format(shape))

attrs = {
'alt_text': alt_text,
'title': title,
**self.get_img_attrs(style, **kwargs)
}

return flask.Markup(
self._wrap_link_target(
kwargs,
self._img_tag(title, alt_text, style, **kwargs),
utils.make_tag(
'img', attrs, start_end=kwargs.get('xhtml')),
title))
except FileNotFoundError as error:
return flask.Markup(
'<span class="error">File not found: {}</span>'.format(
error.filename))
text = '<span class="error">Image not found: <code>{}</code>'.format(
html.escape(error.filename))
if ' ' in error.filename:
text += ' (Did you forget a <code>|</code>?)'
text += '</span>'
return flask.Markup(text)

def _get_shape_style(self, **kwargs):
shape = kwargs['shape']
Expand All @@ -104,10 +122,6 @@ def _get_shape_style(self, **kwargs):
url, _ = other_image.get_rendition(1, **size_args)
return url

@abstractmethod
def _img_tag(self, title, alt_text, style, **kwargs):
""" Implemented by the subclasses for actually emitting the HTML """

def get_css_background(self, uncomment=False, **kwargs):
""" Get the CSS background attributes for an element.

Expand Down Expand Up @@ -520,23 +534,21 @@ def _get_renditions(self, kwargs):

return (img_1x, img_2x, size)

def _img_tag(self, title='', alt_text='', style=None, **kwargs):
def get_img_attrs(self, style=None, **kwargs):
""" Get an <img> tag for this image, hidpi-aware """

# Get the 1x and 2x renditions
img_1x, img_2x, size = self._get_renditions(kwargs)

return utils.make_tag('img', {
return {
'src': img_1x,
'width': size[0],
'height': size[1],
'srcset': "{} 1x, {} 2x".format(img_1x, img_2x) if img_1x != img_2x else None,
'style': ';'.join(style) if style else None,
'class': kwargs.get('class', kwargs.get('img_class')),
'id': kwargs.get('img_id'),
'title': title,
'alt': alt_text
}, start_end=kwargs.get('xhtml'))
'id': kwargs.get('img_id')
}

def _css_background(self, **kwargs):
""" Get the CSS specifiers for this as a hidpi-capable background image """
Expand Down Expand Up @@ -599,10 +611,8 @@ def get_rendition(self, output_scale=1, **kwargs):
# pylint: disable=unused-argument
return self.url, None

def _img_tag(self, title='', alt_text='', style=None, **kwargs):
def get_img_attrs(self, style=None, **kwargs):
attrs = {
'title': title,
'alt': alt_text,
'class': kwargs.get('class', kwargs.get('img_class')),
'id': kwargs.get('img_id'),
}
Expand Down Expand Up @@ -645,7 +655,7 @@ def _img_tag(self, title='', alt_text='', style=None, **kwargs):
if style_parts:
attrs['style'] = ';'.join(style_parts)

return utils.make_tag('img', attrs, start_end=kwargs.get('xhtml'))
return attrs

def _css_background(self, **kwargs):
""" Get the CSS background-image for the remote image """
Expand All @@ -667,9 +677,9 @@ def get_rendition(self, output_scale=1, **kwargs):
url = utils.static_url(self.path, absolute=kwargs.get('absolute'))
return RemoteImage(url, self.search_path).get_rendition(output_scale, **kwargs)

def _img_tag(self, title='', alt_text='', style=None, **kwargs):
def get_img_attrs(self, style=None, **kwargs):
url = utils.static_url(self.path, absolute=kwargs.get('absolute'))
return RemoteImage(url, self.search_path)._img_tag(title, alt_text, style, **kwargs)
return RemoteImage(url, self.search_path).get_img_attrs(style, **kwargs)

def _css_background(self, **kwargs):
# pylint: disable=arguments-differ
Expand All @@ -692,14 +702,10 @@ def get_rendition(self, output_scale=1, **kwargs):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), self.path)

def _img_tag(self, title='', alt_text='', style=None, **kwargs):
def get_img_attrs(self, style=None, **kwargs):
# pylint:disable=unused-argument
text = '<span class="error">Image not found: <code>{}</code>'.format(
html.escape(self.path))
if ' ' in self.path:
text += ' (Did you forget a <code>|</code>?)'
text += '</span>'
return flask.Markup(text)
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), self.path)

def _css_background(self, **kwargs):
return '/* not found: {} */'.format(self.path)
Expand Down
24 changes: 24 additions & 0 deletions publ/links.py
@@ -0,0 +1,24 @@
# links.py
""" Functions for manipulating outgoing HTML links """

from . import image
from . import utils


def remap_path(path, search_path, absolute=False):
""" Remap a link or source target to an appropriate entry or image rendition """

# Remote or static URL: do the thing
if not path.startswith('//') and not '://' in path:
path, sep, anchor = path.partition('#')
entry = utils.find_entry(path, search_path)
if entry:
return entry.link(absolute=absolute) + sep + anchor

# Image URL: do the thing
img_path, img_args, _ = image.parse_image_spec(path)
img = image.get_image(img_path, search_path)
if isinstance(img, image.LocalImage):
path, _ = img.get_rendition(**img_args)

return utils.remap_link_target(path, absolute)
52 changes: 7 additions & 45 deletions publ/markdown.py
@@ -1,10 +1,7 @@
# markdown.py
""" handler for markdown formatting """

from __future__ import absolute_import
""" markdown formatting functionality """

import logging
import html.parser
import re

import misaka
Expand All @@ -14,7 +11,7 @@
import pygments.formatters
import pygments.lexers

from . import image, utils
from . import image, utils, links

TITLE_EXTENSIONS = (
'strikethrough', 'math',
Expand Down Expand Up @@ -121,7 +118,8 @@ def blockcode(self, text, lang):
def link(self, content, link, title=''):
""" Emit a link, potentially remapped based on our embed or static rules """

link = self._remap_path(link)
link = links.remap_path(link, self._search_path,
self._config.get('absolute'))

return '{}{}</a>'.format(
utils.make_tag('a', {
Expand All @@ -143,24 +141,6 @@ def paragraph(content):
text = re.sub(r'<p>\s*</p>', r'', text)
return text or ' '

def _remap_path(self, path):
""" Remap a path to an appropriate URL """

# Remote or static URL: do the thing
if not path.startswith('//') and not '://' in path:
path, sep, anchor = path.partition('#')
entry = utils.find_entry(path, self._search_path)
if entry:
return entry.link(self._config) + sep + anchor

# Image URL: do the thing
img_path, img_args, _ = image.parse_image_spec(path)
img = image.get_image(img_path, self._search_path)
if isinstance(img, image.LocalImage):
path, _ = img.get_rendition(**img_args)

return utils.remap_link_target(path, self._config.get('absolute'))

def _render_image(self, spec, container_args, alt_text=None):
""" Render an image specification into an <img> tag """

Expand Down Expand Up @@ -228,29 +208,11 @@ def header(content, level):
return content


class HTMLStripper(html.parser.HTMLParser):
""" A utility class to strip HTML from a string; based on
https://stackoverflow.com/a/925630/318857 """

def __init__(self):
super().__init__()

self.reset()
self.strict = False
self.convert_charrefs = True
self.fed = []
class HTMLStripper(utils.HTMLTransform):
""" Strip all HTML tags from a document """

def handle_data(self, data):
""" Append the text data """
self.fed.append(data)

def get_data(self):
""" Concatenate the output """
return ''.join(self.fed)

def error(self, message):
""" Deprecated, per https://bugs.python.org/issue31844 """
return message
self.append(data)


def render_title(text, markup=True, no_smartquotes=False):
Expand Down