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.