From 03098d7a42f9bbe59d35cd5873cc6a3e84be07fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Tue, 6 Jun 2017 23:39:13 +0200 Subject: [PATCH] Introduce Jinja2 filters for converting links in additional fields. Until now, the link replacing worked only on article and page contents or summaries. With this patch, if one needes to replace links in custom fields, there are two new Jinja2 filters that can do that. For fields that are referenced in the `FORMATTED_FIELDS` setting, one can use the `expand_links` Jinja2 filter in the template, passing the field name as a parameter: {{ article|expand_links('legal') }} If the custom field consists of just one link (for example a link to article cover image for a social meta tag), one can use the `expand_link` Jinja2 filter: {{ article|expand_link('cover') }} With the above being in a template and `FORMATTED_FIELDS` setting containing the `'legal'` field, a RST article making use of both fields could look like this: An article ########## :date: 2017-06-22 :legal: This article is released under `CC0 {filename}/license.rst`. :cover: {filename}/img/article-cover.jpg --- docs/changelog.rst | 2 + docs/content.rst | 23 +++++++ pelican/contents.py | 126 ++++++++++++++++++------------------ pelican/generators.py | 9 ++- pelican/tests/test_utils.py | 43 ++++++++++++ pelican/utils.py | 34 ++++++++++ 6 files changed, 172 insertions(+), 65 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index aa594a2c99..aa036cd8b9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,8 @@ Next release ============ * New signal: ``feed_generated`` +* Introduced ``expand_link`` and ``expand_links`` Jinja2 filters to allow URL + replacement in user-defined metadata fields. 3.7.1 (2017-01-10) ================== diff --git a/docs/content.rst b/docs/content.rst index 507593bf43..9c5a6742e8 100644 --- a/docs/content.rst +++ b/docs/content.rst @@ -202,6 +202,29 @@ and ``article2.md``:: [a link relative to the current file]({filename}category/article1.rst) [a link relative to the content root]({filename}/category/article1.rst) +The link replacing works by default on article and page contents as well as +summaries. If you need to replace links in custom formatted fields that are +referenced in the ``FORMATTED_FIELDS`` setting, use the ``expand_links`` +Jinja2 filter in your template, passing the field name as a parameter:: + + {{ article|expand_links('legal') }} + +If your custom field consists of just one link (for example a link to article +cover image for a social meta tag), use the ``expand_link`` Jinja2 filter:: + + {{ article|expand_link('cover') }} + +With the above being in a template and ``FORMATTED_FIELDS`` setting containing +the ``'legal'`` field, a RST article making use of both fields could look like +this:: + + An article + ########## + + :date: 2017-06-22 + :legal: This article is released under `CC0 {filename}/license.rst`. + :cover: {filename}/img/article-cover.jpg + Linking to static files ----------------------- diff --git a/pelican/contents.py b/pelican/contents.py index 3d1128c9bf..f29783eebd 100644 --- a/pelican/contents.py +++ b/pelican/contents.py @@ -204,6 +204,68 @@ def get_url_setting(self, key): key = key if self.in_default_lang else 'lang_%s' % key return self._expand_settings(key) + def _link_replacer(self, siteurl, m): + what = m.group('what') + value = urlparse(m.group('value')) + path = value.path + origin = m.group('path') + + # XXX Put this in a different location. + if what in {'filename', 'attach'}: + if path.startswith('/'): + path = path[1:] + else: + # relative to the source path of this content + path = self.get_relative_source_path( + os.path.join(self.relative_dir, path) + ) + + if path not in self._context['filenames']: + unquoted_path = path.replace('%20', ' ') + + if unquoted_path in self._context['filenames']: + path = unquoted_path + + linked_content = self._context['filenames'].get(path) + if linked_content: + if what == 'attach': + if isinstance(linked_content, Static): + linked_content.attach_to(self) + else: + logger.warning( + "%s used {attach} link syntax on a " + "non-static file. Use {filename} instead.", + self.get_relative_source_path()) + origin = '/'.join((siteurl, linked_content.url)) + origin = origin.replace('\\', '/') # for Windows paths. + else: + logger.warning( + "Unable to find '%s', skipping url replacement.", + value.geturl(), extra={ + 'limit_msg': ("Other resources were not found " + "and their urls not replaced")}) + elif what == 'category': + origin = '/'.join((siteurl, Category(path, self.settings).url)) + elif what == 'tag': + origin = '/'.join((siteurl, Tag(path, self.settings).url)) + elif what == 'index': + origin = '/'.join((siteurl, self.settings['INDEX_SAVE_AS'])) + elif what == 'author': + origin = '/'.join((siteurl, Author(path, self.settings).url)) + else: + logger.warning( + "Replacement Indicator '%s' not recognized, " + "skipping replacement", + what) + + # keep all other parts, such as query, fragment, etc. + parts = list(value) + parts[2] = origin + origin = urlunparse(parts) + + return ''.join((m.group('markup'), m.group('quote'), origin, + m.group('quote'))) + def _update_content(self, content, siteurl): """Update the content attribute. @@ -227,69 +289,7 @@ def _update_content(self, content, siteurl): \2""".format(instrasite_link_regex) hrefs = re.compile(regex, re.X) - def replacer(m): - what = m.group('what') - value = urlparse(m.group('value')) - path = value.path - origin = m.group('path') - - # XXX Put this in a different location. - if what in {'filename', 'attach'}: - if path.startswith('/'): - path = path[1:] - else: - # relative to the source path of this content - path = self.get_relative_source_path( - os.path.join(self.relative_dir, path) - ) - - if path not in self._context['filenames']: - unquoted_path = path.replace('%20', ' ') - - if unquoted_path in self._context['filenames']: - path = unquoted_path - - linked_content = self._context['filenames'].get(path) - if linked_content: - if what == 'attach': - if isinstance(linked_content, Static): - linked_content.attach_to(self) - else: - logger.warning( - "%s used {attach} link syntax on a " - "non-static file. Use {filename} instead.", - self.get_relative_source_path()) - origin = '/'.join((siteurl, linked_content.url)) - origin = origin.replace('\\', '/') # for Windows paths. - else: - logger.warning( - "Unable to find '%s', skipping url replacement.", - value.geturl(), extra={ - 'limit_msg': ("Other resources were not found " - "and their urls not replaced")}) - elif what == 'category': - origin = '/'.join((siteurl, Category(path, self.settings).url)) - elif what == 'tag': - origin = '/'.join((siteurl, Tag(path, self.settings).url)) - elif what == 'index': - origin = '/'.join((siteurl, self.settings['INDEX_SAVE_AS'])) - elif what == 'author': - origin = '/'.join((siteurl, Author(path, self.settings).url)) - else: - logger.warning( - "Replacement Indicator '%s' not recognized, " - "skipping replacement", - what) - - # keep all other parts, such as query, fragment, etc. - parts = list(value) - parts[2] = origin - origin = urlunparse(parts) - - return ''.join((m.group('markup'), m.group('quote'), origin, - m.group('quote'))) - - return hrefs.sub(replacer, content) + return hrefs.sub(lambda m: self._link_replacer(siteurl, m), content) def get_siteurl(self): return self._context.get('localsiteurl', '') diff --git a/pelican/generators.py b/pelican/generators.py index f3590155fc..ec5c520be3 100644 --- a/pelican/generators.py +++ b/pelican/generators.py @@ -21,8 +21,9 @@ from pelican.cache import FileStampDataCacher from pelican.contents import Article, Draft, Page, Static, is_valid_content from pelican.readers import Readers -from pelican.utils import (DateFormatter, copy, mkdir_p, posixize_path, - process_translations, python_2_unicode_compatible) +from pelican.utils import (DateFormatter, HtmlLinkExpander, LinkExpander, copy, + mkdir_p, posixize_path, process_translations, + python_2_unicode_compatible) logger = logging.getLogger(__name__) @@ -74,6 +75,10 @@ def __init__(self, context, settings, path, theme, output_path, # provide utils.strftime as a jinja filter self.env.filters.update({'strftime': DateFormatter()}) + # provide link expansion as a jinja filter + self.env.filters.update({'expand_link': LinkExpander(settings)}) + self.env.filters.update({'expand_links': HtmlLinkExpander()}) + # get custom Jinja filters from user settings custom_filters = self.settings['JINJA_FILTERS'] self.env.filters.update(custom_filters) diff --git a/pelican/tests/test_utils.py b/pelican/tests/test_utils.py index 9a7109d6d2..5c4bcd4aea 100644 --- a/pelican/tests/test_utils.py +++ b/pelican/tests/test_utils.py @@ -13,7 +13,10 @@ import six +from jinja2 import DictLoader, Environment + from pelican import utils +from pelican.contents import Article, Page, Static from pelican.generators import TemplatePagesGenerator from pelican.settings import read_settings from pelican.tests.support import (LoggedTestCase, get_article, @@ -670,6 +673,46 @@ def test_turkish_locale(self): utils.strftime(self.date, 'date = %A, %d %B %Y')) +class TestLinkExpanders(unittest.TestCase): + """Tests Jinja2 expand_link() and expand_links() filters.""" + + def test_expand_link(self): + settings = read_settings() + env = Environment( + loader=DictLoader({'a.html': "{{article|expand_link('cover')}}"}) + ) + env.filters.update({'expand_link': utils.LinkExpander(settings)}) + + linked_image = Static('', source_path='image.png') + context = {'filenames': {'image.png': linked_image}, + 'localsiteurl': 'https://my.cool.site'} + content_mock = Article('', metadata={ + 'title': 'Article', + 'cover': "{filename}/image.png"}, context=context) + result = env.get_template('a.html').render(article=content_mock) + self.assertEqual('https://my.cool.site/image.png', result) + + def test_expand_links(self): + env = Environment( + loader=DictLoader({'a.html': "{{article|expand_links('legal')}}"}) + ) + env.filters.update({'expand_links': utils.HtmlLinkExpander()}) + + linked_page = Page('', source_path='legal.rst', + metadata={'slug': 'license'}) + context = {'filenames': {'legal.rst': linked_page}, + 'localsiteurl': 'https://my.cool.site'} + content_mock = Article( + '', metadata={ + 'title': 'Article', + 'legal': "License" + }, context=context) + result = env.get_template('a.html').render(article=content_mock) + self.assertEqual( + 'License', + result) + + class TestSanitisedJoin(unittest.TestCase): def test_detect_parent_breakout(self): with six.assertRaisesRegex( diff --git a/pelican/utils.py b/pelican/utils.py index ef9da23b38..95d4c2244e 100644 --- a/pelican/utils.py +++ b/pelican/utils.py @@ -147,6 +147,40 @@ def __call__(self, date, date_format): return formatted +class LinkExpander(object): + """Link expander object used as a jinja filter + + Expands a custom field that contains just a link to internal content. The + same rules as when links are expanded in article/page contents and + summaries apply. + """ + + def __init__(self, settings): + self.intrasite_link_regex = settings['INTRASITE_LINK_REGEX'] + + def __call__(self, content, attr): + link_regex = r"""^ + (?P)(?P) + (?P{0}(?P.*)) + $""".format(self.intrasite_link_regex) + links = re.compile(link_regex, re.X) + return links.sub( + lambda m: content._link_replacer(content.get_siteurl(), m), + getattr(content, attr)) + + +class HtmlLinkExpander(object): + """HTML link expander object used as a jinja filter + + Expands links to internal contents in a custom HTML field. The same rules + as when links are expanded in article/page contents and summaries apply. + """ + + def __call__(self, content, attr): + return content._update_content(getattr(content, attr), + content.get_siteurl()) + + def python_2_unicode_compatible(klass): """ A decorator that defines __unicode__ and __str__ methods under Python 2.