From d2fd328c2c2ca8b6a9e29b754ddfc5f29b39cff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Reu=C3=9Fe?= Date: Wed, 8 Aug 2018 19:15:20 +0200 Subject: [PATCH] Add alternatives support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While lektor-atom currently supports creating feed variants by relying on specially-crafted item query expressions, this is only useful for item model fields that contain natural language strings. When creating feeds based on structured data, this is not helpful, since field values are identical across two alts. E.g., when we would like to publish a feed based on PDF attachments that have a `volume_number` set, we are currently unable to have the English feed display item titles as »Volume 1« in the English feed resp. »Ausgabe 1« in the German alternative. This commit adds support for such use-cases by adding two new mechanisms: 1. Instead of supplying field names to map records to Atom entries, the user supplies a Jinja template. These expressions are evaluated with `this` bound to the blog resp. the item record. 2. For a feed named `feed`, configuration values are first looked-up in the config file section `[feed.ALT]`, where `ALT` is the alternative currently being generated. This allows settings defaults in `[feed]`, and overriding only those settings that are locale-specific by adding them to `[feed.ALT]`. As a side-effect, this also benefits users that don’t use alternatives, since it enables them to compose item titles, bodies, etc. using multiple fields at the same time. Fixes #3, #13. --- README.md | 66 ++++++-- lektor_atom.py | 152 ++++++++++++------ tests/demo-project/Website.lektorproject | 10 ++ tests/demo-project/configs/atom.ini | 24 ++- .../custom-blog/filtered_post/contents.lr | 2 +- .../content/custom-blog/post1/contents.lr | 2 +- .../content/custom-blog/post2/contents.lr | 2 +- .../content/multilang-blog/contents.lr | 6 + .../multilang-blog/post1/contents+de.lr | 5 + .../content/multilang-blog/post1/contents.lr | 5 + .../content/multilang-blog/post2/contents.lr | 7 + .../demo-project/models/custom-blog-post.ini | 2 +- tests/test_lektor_atom.py | 59 ++++++- 13 files changed, 260 insertions(+), 82 deletions(-) create mode 100644 tests/demo-project/content/multilang-blog/contents.lr create mode 100644 tests/demo-project/content/multilang-blog/post1/contents+de.lr create mode 100644 tests/demo-project/content/multilang-blog/post1/contents.lr create mode 100644 tests/demo-project/content/multilang-blog/post2/contents.lr diff --git a/README.md b/README.md index 3c5b9b5..cfe8064 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,21 @@ The section names, like `blog` and `coffee`, are just used as internal identifie ### Options -|Option | Default | Description -|---------------------|------------|------------------------------------------------------------------------- -|source\_path | / | Where in the content directory to find items' parent source -|name | | Feed name: default is section name -|filename | feed.xml | Name of generated Atom feed file -|url\_path | | Feed's URL on your site: default is source's URL path plus the filename -|blog\_author\_field | author | Name of source's author field -|blog\_summary\_field | summary | Name of source's summary field -|items | None | A query expression: default is the source's children -|limit | 50 | How many recent items to include -|item\_title\_field | title | Name of items' title field -|item\_body\_field | body | Name of items' content body field -|item\_author\_field | author | Name of items' author field -|item\_date\_field | pub\_date | Name of items' publication date field -|item\_model | None | Name of items' model +| Option | Default | Description | +|-------------------|--------------------|-------------------------------------------------------------------------| +| source\_path | / | Where in the content directory to find items' parent source | +| name | | Feed name: default is section name | +| filename | feed.xml | Name of generated Atom feed file | +| url\_path | | Feed's URL on your site: default is source's URL path plus the filename | +| blog\_author | {{ this.author }} | Global blog author or blog editor | +| blog\_summary | {{ this.summary }} | Blog summary | +| items | None | A query expression: default is the source's children | +| limit | 50 | How many recent items to include | +| item\_title | {{ this.title }} | Blog post title | +| item\_body | {{ this.body }} | Blog post body | +| item\_author | {{ this.author }} | Blog post author | +| item\_date\_field | pub\_date | Name of items' publication date field | +| item\_model | None | Name of items' model | ### Customizing the plugin for your models @@ -73,8 +73,8 @@ Then add to atom.ini: ``` [main] -blog_author_field = writer -blog_summary_field = short_description +blog_author = {{ this.writer }} +blog_summary = {{ this.short_description }} ``` See [tests/demo-project/configs/atom.ini](https://github.com/ajdavis/lektor-atom/blob/master/tests/demo-project/configs/atom.ini) for a complete example. @@ -112,6 +112,38 @@ relevant to the current page. When the argument `for_page` is omitted, the function will enumerate all feeds defined in your project. +## Alternatives + +If your site is using Lektor’s alternative system, you can set +alternative-specific configuration values in your `configs/atom.ini`: + +``` +[blog] +name = My Blog +source_path = / +item_model = blog-post + +[blog.de] +name = Mein Blog +``` + +When lektor-atom is trying to retrieve a configuration value, it will first +look-up the config file section `[feed.ALT]`, where `ALT` is replaced by the +name of the alternative that is being generated. When such a value does not +exist, lektor-atom will get the value from the global section (`[feed]`), or, if +this does not succeed, lektor-atom will fall back on the hardcoded default. + +If you are using pybabel and have the Jinja i18n extension enabled, you can +alternatively localize your feeds by using `{% trans %}` blocks inside template +expressions in your `atom.ini`. To extract translation strings using babel, just +add the following to your `babel.cfg`: + +``` +[jinja2: site/configs/atom.ini] +encoding=utf-8 +silent=False +``` + # Changes 2016-06-02: Version 0.2. Python 3 compatibility (thanks to Dan Bauman), diff --git a/lektor_atom.py b/lektor_atom.py index 20f8eec..41c0745 100644 --- a/lektor_atom.py +++ b/lektor_atom.py @@ -8,7 +8,7 @@ import pkg_resources from lektor.build_programs import BuildProgram from lektor.db import F -from lektor.environment import Expression +from lektor.environment import Expression, FormatExpression from lektor.pluginsystem import Plugin from lektor.context import get_ctx, url_to from lektor.sourceobj import VirtualSourceObject @@ -37,21 +37,28 @@ def path(self): @property def url_path(self): - p = self.plugin.get_atom_config(self.feed_id, 'url_path') + p = self.plugin.get_atom_config(self.feed_id, 'url_path', + alt=self.alt) if p: + cfg = self.plugin.env.load_config() + primary_alts = '_primary', cfg.primary_alternative + if self.alt not in primary_alts: + p = "/%s%s" % (self.alt, p) return p return build_url([self.parent.url_path, self.filename]) def __getattr__(self, item): try: - return self.plugin.get_atom_config(self.feed_id, item) + return self.plugin.get_atom_config(self.feed_id, item, + alt=self.alt) except KeyError: raise AttributeError(item) @property def feed_name(self): - return self.plugin.get_atom_config(self.feed_id, 'name') or self.feed_id + return self.plugin.get_atom_config(self.feed_id, 'name', alt=self.alt) \ + or self.feed_id def get(item, field, default=None): @@ -71,13 +78,6 @@ def get_item_title(item, field): return item.record_label -def get_item_body(item, field): - if field not in item: - raise RuntimeError('Body field %r not found in %r' % (field, item)) - with get_ctx().changed_base_url(item.url_path): - return text_type(escape(item[field])) - - def get_item_updated(item, field): if field in item: rv = item[field] @@ -89,6 +89,14 @@ def get_item_updated(item, field): class AtomFeedBuilderProgram(BuildProgram): + def format_expression(self, expression, record, env): + with get_ctx().changed_base_url(record.url_path): + return FormatExpression(env, expression).evaluate( + record.pad, + this=record, + alt=record.alt + ) + def produce_artifacts(self): self.declare_artifact( self.source.url_path, @@ -99,13 +107,17 @@ def build_artifact(self, artifact): feed_source = self.source blog = feed_source.parent - summary = get(blog, feed_source.blog_summary_field) or '' - if hasattr(summary, '__html__'): - subtitle_type = 'html' - summary = text_type(summary.__html__()) - else: - subtitle_type = 'text' - blog_author = text_type(get(blog, feed_source.blog_author_field) or '') + summary = self.format_expression( + feed_source.blog_summary, + blog, + ctx.env) + + blog_author = self.format_expression( + feed_source.blog_author, + blog, + ctx.env + ) + generator = ('Lektor Atom Plugin', 'https://github.com/ajdavis/lektor-atom', pkg_resources.get_distribution('lektor-atom').version) @@ -113,10 +125,10 @@ def build_artifact(self, artifact): feed = AtomFeed( title=feed_source.feed_name, subtitle=summary, - subtitle_type=subtitle_type, + subtitle_type='html', author=blog_author, - feed_url=url_to(feed_source, external=True), - url=url_to(blog, external=True), + feed_url=url_to(feed_source, external=True, alt=feed_source.alt), + url=url_to(blog, external=True, alt=feed_source.alt), id=get_id(ctx.env.project.id), generator=generator) @@ -127,6 +139,10 @@ def build_artifact(self, artifact): else: items = blog.children + # Don’t force the user to think about alt when specifying an items + # query. + items.alt = feed_source.alt + if feed_source.item_model: items = items.filter(F._model == feed_source.item_model) @@ -135,17 +151,29 @@ def build_artifact(self, artifact): for item in items: try: - item_author_field = feed_source.item_author_field - item_author = get(item, item_author_field) or blog_author + item_author = self.format_expression( + feed_source.item_author, + item, + ctx.env + ) or blog_author base_url = url_to( item.parent if item.is_attachment else item, external=True ) - + body = self.format_expression( + feed_source.item_body, + item, + ctx.env + ) + title = self.format_expression( + feed_source.item_title, + item, + ctx.env + ) feed.add( - get_item_title(item, feed_source.item_title_field), - get_item_body(item, feed_source.item_body_field), + title, + body, xml_base=base_url, url=url_to(item, external=True), content_type='html', @@ -171,20 +199,30 @@ class AtomPlugin(Plugin): 'name': None, 'url_path': None, 'filename': 'feed.xml', - 'blog_author_field': 'author', - 'blog_summary_field': 'summary', + 'blog_author': '{{ this.author }}', + 'blog_summary': '{{ this.summary }}', 'items': None, 'limit': 50, - 'item_title_field': 'title', - 'item_body_field': 'body', - 'item_author_field': 'author', + 'item_title': '{{ this.title or this.record_label }}', + 'item_body': '{{ this.body }}', + 'item_author': '{{ this.author }}', 'item_date_field': 'pub_date', 'item_model': None, } - def get_atom_config(self, feed_id, key): + def get_atom_config(self, feed_id, key, alt=None): default_value = self.defaults[key] - return self.get_config().get('%s.%s' % (feed_id, key), default_value) + config = self.get_config() + primary_value = config.get( + "%s.%s" % (feed_id, key), + default_value + ) + localized_value = ( + config.get("%s.%s.%s" % (feed_id, alt, key)) + if alt + else None + ) + return localized_value or primary_value def on_setup_env(self, **extra): self.env.add_build_program(AtomFeedSource, AtomFeedBuilderProgram) @@ -199,46 +237,64 @@ def feed_path_resolver(node, pieces): _id = pieces[0] - config = self.get_config() - if _id not in config.sections(): + if _id not in self._feed_ids(): return - source_path = self.get_atom_config(_id, 'source_path') + source_path = self.get_atom_config(_id, 'source_path', + alt=node.alt) if node.path == source_path: return AtomFeedSource(node, _id, plugin=self) @self.env.generator def generate_feeds(source): - for _id in self.get_config().sections(): - if source.path == self.get_atom_config(_id, 'source_path'): + for _id in self._feed_ids(): + if source.path == self.get_atom_config(_id, 'source_path', + alt=source.alt): yield AtomFeedSource(source, _id, self) - def _all_feeds(self): + def _feed_ids(self): + feed_ids = set() + for section in self.get_config().sections(): + if '.' in section: + feed_id, _alt = section.split(".") + else: + feed_id = section + feed_ids.add(feed_id) + + return feed_ids + + def _all_feeds(self, alt=None): ctx = get_ctx() feeds = [] - for feed_id in self.get_config().sections(): - path = self.get_atom_config(feed_id, 'source_path') - feed = ctx.pad.get('%s@atom/%s' % (path, feed_id)) + for feed_id in self._feed_ids(): + path = self.get_atom_config(feed_id, 'source_path', alt=alt) + feed = ctx.pad.get( + '%s@atom/%s' % (path, feed_id), + alt=alt or ctx.record.alt + ) if feed: feeds.append(feed) return feeds - def _feeds_for(self, page): + def _feeds_for(self, page, alt=None): ctx = get_ctx() record = page.record feeds = [] - for section in self.get_config().sections(): - feed = ctx.pad.get('%s@atom/%s' % (record.path, section)) + for section in self._feed_ids(): + feed = ctx.pad.get( + '%s@atom/%s' % (record.path, section), + alt=alt or ctx.record.alt + ) if feed: feeds.append(feed) return feeds - def atom_feeds(self, for_page=None): + def atom_feeds(self, for_page=None, alt=None): if not for_page: - return self._all_feeds() + return self._all_feeds(alt=alt) else: - return self._feeds_for(for_page) + return self._feeds_for(for_page, alt=alt) diff --git a/tests/demo-project/Website.lektorproject b/tests/demo-project/Website.lektorproject index 2744af8..441693b 100644 --- a/tests/demo-project/Website.lektorproject +++ b/tests/demo-project/Website.lektorproject @@ -4,3 +4,13 @@ url = http://x.com [packages] lektor-atom + +[alternatives.en] +name = Elvish +primary = yes +locale = en_US + +[alternatives.de] +name = Dwarvish +locale = de_DE +url_prefix = /de/ diff --git a/tests/demo-project/configs/atom.ini b/tests/demo-project/configs/atom.ini index 5d596c2..2efe1d2 100644 --- a/tests/demo-project/configs/atom.ini +++ b/tests/demo-project/configs/atom.ini @@ -9,12 +9,12 @@ source_path = /typical-blog2 name = Feed Three source_path = /custom-blog filename = atom.xml -blog_author_field = editor -blog_summary_field = description +blog_author = {{ this.editor }} +blog_summary = {{ this.description }} items = site.query('/custom-blog').filter(F.headline != "I'm filtered out") -item_title_field = headline -item_body_field = contents -item_author_field = writer +item_title = {{ this.headline }} +item_body = {{ this.content }} +item_author = {{ this.writer }} item_date_field = published item_model = custom-blog-post @@ -22,8 +22,16 @@ item_model = custom-blog-post name = Feed Three (uncensored) source_path = /custom-blog filename = nsfw.xml -item_title_field = headline -item_body_field = contents -item_author_field = writer +item_title = {{ this.headline }} +item_body = {{ this.content }} +item_author = {{ this.writer }} item_date_field = published item_model = custom-blog-post + +[feed-five] +name = Feed Five +source_path = /multilang-blog +item_title = {{ this.title }} ({{ this.pub_date | dateformat }}) + +[feed-five.de] +name = Feed Fünf diff --git a/tests/demo-project/content/custom-blog/filtered_post/contents.lr b/tests/demo-project/content/custom-blog/filtered_post/contents.lr index 6cc88df..85b9cc1 100644 --- a/tests/demo-project/content/custom-blog/filtered_post/contents.lr +++ b/tests/demo-project/content/custom-blog/filtered_post/contents.lr @@ -2,4 +2,4 @@ headline: I'm filtered out --- published: 2015-12-12 15:00:00 --- -contents: baz +content: baz diff --git a/tests/demo-project/content/custom-blog/post1/contents.lr b/tests/demo-project/content/custom-blog/post1/contents.lr index 63e0ff0..93464bb 100644 --- a/tests/demo-project/content/custom-blog/post1/contents.lr +++ b/tests/demo-project/content/custom-blog/post1/contents.lr @@ -2,4 +2,4 @@ headline: Post 1 --- published: 2015-12-12 12:34:56 --- -contents: foo +content: foo diff --git a/tests/demo-project/content/custom-blog/post2/contents.lr b/tests/demo-project/content/custom-blog/post2/contents.lr index ebdda08..1a4215b 100644 --- a/tests/demo-project/content/custom-blog/post2/contents.lr +++ b/tests/demo-project/content/custom-blog/post2/contents.lr @@ -4,4 +4,4 @@ writer: Armin Ronacher --- published: 2015-12-13 00:00:00 --- -contents: bar +content: bar diff --git a/tests/demo-project/content/multilang-blog/contents.lr b/tests/demo-project/content/multilang-blog/contents.lr new file mode 100644 index 0000000..2f4e07d --- /dev/null +++ b/tests/demo-project/content/multilang-blog/contents.lr @@ -0,0 +1,6 @@ +_model: blog +--- +author: Guy de Maupassant +--- +summary: High-impact multilingual blog +--- diff --git a/tests/demo-project/content/multilang-blog/post1/contents+de.lr b/tests/demo-project/content/multilang-blog/post1/contents+de.lr new file mode 100644 index 0000000..ba35e97 --- /dev/null +++ b/tests/demo-project/content/multilang-blog/post1/contents+de.lr @@ -0,0 +1,5 @@ +title: Post 1 +--- +pub_date: 2015-12-12 +--- +body: Achtung! diff --git a/tests/demo-project/content/multilang-blog/post1/contents.lr b/tests/demo-project/content/multilang-blog/post1/contents.lr new file mode 100644 index 0000000..b76e7f8 --- /dev/null +++ b/tests/demo-project/content/multilang-blog/post1/contents.lr @@ -0,0 +1,5 @@ +title: Post 1 +--- +pub_date: 2015-12-12 +--- +body: foo diff --git a/tests/demo-project/content/multilang-blog/post2/contents.lr b/tests/demo-project/content/multilang-blog/post2/contents.lr new file mode 100644 index 0000000..5e6a905 --- /dev/null +++ b/tests/demo-project/content/multilang-blog/post2/contents.lr @@ -0,0 +1,7 @@ +title: Post 2 +--- +author: Armin Ronacher +--- +pub_date: 2015-12-13 +--- +body: bar diff --git a/tests/demo-project/models/custom-blog-post.ini b/tests/demo-project/models/custom-blog-post.ini index 8beae0b..a66a692 100644 --- a/tests/demo-project/models/custom-blog-post.ini +++ b/tests/demo-project/models/custom-blog-post.ini @@ -10,5 +10,5 @@ type = string [fields.published] type = datetime -[fields.contents] +[fields.content] type = markdown diff --git a/tests/test_lektor_atom.py b/tests/test_lektor_atom.py index b6f65cb..6d3a39a 100644 --- a/tests/test_lektor_atom.py +++ b/tests/test_lektor_atom.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import os from lektor.context import Context @@ -12,7 +14,7 @@ def test_typical_feed(pad, builder): assert 'Feed One' == feed.title assert 'My Summary' == feed.subtitle - assert 'text' == feed.subtitle.attrib['type'] + assert 'html' == feed.subtitle.attrib['type'] assert 'A. Jesse Jiryu Davis' == feed.author.name assert 'http://x.com/typical-blog/' == feed.link[0].attrib['href'] assert 'http://x.com/typical-blog/feed.xml' == feed.link[1].attrib['href'] @@ -78,6 +80,39 @@ def test_custom_feed(pad, builder): assert 'A. Jesse Jiryu Davis' == post1.author.name +def test_multilang_feed(pad, builder): + failures = builder.build_all() + assert not failures + + feed_path = os.path.join(builder.destination_path, + 'de/multilang-blog/feed.xml') + feed = objectify.parse(open(feed_path)).getroot() + + assert 'Feed Fünf' == feed.title + assert 'http://x.com/de/multilang-blog/' \ + == feed.link[0].attrib['href'] + assert 'http://x.com/de/multilang-blog/feed.xml' \ + == feed.link[1].attrib['href'] + assert feed.entry.title == 'Post 2 (13.12.2015)' + + base = feed.entry.attrib['{http://www.w3.org/XML/1998/namespace}base'] + assert 'http://x.com/de/multilang-blog/post2/' == base + + feed_path = os.path.join(builder.destination_path, + 'multilang-blog/feed.xml') + feed = objectify.parse(open(feed_path)).getroot() + + assert 'Feed Five' == feed.title + assert 'http://x.com/multilang-blog/' \ + == feed.link[0].attrib['href'] + assert 'http://x.com/multilang-blog/feed.xml' \ + == feed.link[1].attrib['href'] + assert feed.entry.title == 'Post 2 (Dec 13, 2015)' + + base = feed.entry.attrib['{http://www.w3.org/XML/1998/namespace}base'] + assert 'http://x.com/multilang-blog/post2/' == base + + def test_virtual_resolver(pad, builder): # Pass a virtual source path to url_to(). feed_path = '/typical-blog@atom/feed-one' @@ -98,6 +133,10 @@ def test_virtual_resolver(pad, builder): url_path = pad.get('custom-blog/post1').url_to(feed_instance) assert url_path == '../../custom-blog/atom.xml' + feed_instance = pad.get('multilang-blog@atom/feed-five', alt='de') + assert feed_instance and feed_instance.feed_name == 'Feed Fünf' + assert feed_instance.url_path == '/de/multilang-blog/feed.xml' + def test_dependencies(pad, builder, reporter): reporter.clear() @@ -126,19 +165,20 @@ def feeds_from_template(pad, template): def test_discover_all(pad): template = r''' - {% for feed in atom_feeds() %} + {% for feed in atom_feeds(alt='_primary') %} {{ feed.feed_id }} {% endfor %} ''' all_feeds = set(['feed-one', 'feed-two', - 'feed-three', 'feed-four']) + 'feed-three', 'feed-four', + 'feed-five']) feeds_discovered = feeds_from_template(pad, template) assert feeds_discovered == all_feeds def test_discover_local(pad): template_blog = r''' - {% for feed in atom_feeds(for_page=site.get('/custom-blog')) %} + {% for feed in atom_feeds(for_page=site.get('/custom-blog'), alt='_primary') %} {{ feed.feed_id }} {% endfor %} ''' @@ -146,9 +186,18 @@ def test_discover_local(pad): assert feeds_blog == set(['feed-three', 'feed-four']) template_noblog = r''' - {% for feed in atom_feeds(for_page=site.get('/no-feed-content')) %} + {% for feed in atom_feeds(for_page=site.get('/no-feed-content'), alt='_primary') %} {{ feed.feed_id }} {% endfor %} ''' feeds_noblog = feeds_from_template(pad, template_noblog) assert len(feeds_noblog) == 0 + + +def test_localized_config(pad): + plugin = pad.env.plugins['atom'] + assert plugin.get_atom_config('feed-five', 'name') \ + == 'Feed Five' + assert plugin.get_atom_config('feed-five', 'name', alt='de') \ + == 'Feed Fünf' +