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.