Documented virtual paths

This commit is contained in:
Armin Ronacher 2016-01-09 10:24:39 +01:00
parent 06ac62c715
commit b3ff365a8a
18 changed files with 501 additions and 141 deletions

View File

@ -29,22 +29,35 @@ Most plugins will not have source objects that actually originate on the
file system. This means that their "source" is entirely virtual. Because
this is a very common situation there is a base class, the
`VirtualSourceObject` which plugins can subclass. The constructor takes one
argument which is the parent source object the virtual source lives below.
argument which is the source record the virtual source lives below. Virtual
sources are separated from the records they belong to with an at sign (`@`)
in the path. The part after the at sign is called the “virtual path”.
For instance in the below example the canonical path for the object would be
the record's path + `@source`. So if the record was `/hello` then the
path would be `/hello@source`. The true base record it belongs to can be
referenced from the [record :ref](record/) property.
```python
from lektor.sourceobj import VirtualSourceObject
from lektor.utils import build_url
class Source(VirtualSourceObject):
@property
def path(self):
return self.record.path + '@source'
@property
def source_content(self):
with open(self.parent.source_filename) as f:
with open(self.record.source_filename) as f:
return f.read().decode('utf-8')
@property
def url_path(self):
return self.parent.url_path + 'source.txt'
return build_url([self.record.url_path, 'source.txt'])
```
For more information see [add-build-program
:ref](../../environment/add-build-program/).
:ref](../../environment/add-build-program/) as well as
[virtualpathresolver :ref](../../environment/virtualpathresolver/).

View File

@ -0,0 +1,28 @@
title: record
---
summary: A reference to the associated base record.
---
type: property
---
version_added: 2.0
---
body:
Most records (other than the root) have a [parent :ref](../parent/). This as
a concept generally makes in most situations. However when virtual sources
come into play the parent often just refers to another virtual source but not
the root record they all belong to.
This property for the most part refers back to the object itself. However
for virtual sources this refers back to the record that was associated with
the virtual source. Most of the time this matches `parent` but for nested
source objects it's useful to be able to refer back to the base in all cases
without having to walk up the parents.
## Example
```html+jinja
{% if this != this.record %}
<a href="{{ this.record|url }}">go back to overview</a>
{% endif %}
```

View File

@ -17,7 +17,8 @@ The path needs to be absolute with folder separated by slashes.
The default behavior is to load the unpaginated version of a record. If you
want to select a specific page for pagination, then you need to pass
`page_num` with a valid page number.
`page_num` with a valid page number or you use the virtual path (`@1` for the
first page for instance).
## Examples
@ -34,3 +35,15 @@ Here another example that loads the current page but in another language:
{% set other_lang = site.get(this._path, alt='ru') %}
<p>This page in Russian: {{ other_lang.title }}
```
## Virtual Paths
This method can also be used to look up [virtual paths
:ref](../../../../content/paths/). For instance to fetch a specific version of a
pagination you can use a virtual path instead of using the `page_num`
parameter:
```pycon
>>> pad.get('/blog@3')
<Page model=u'blog' path='/blog' page_num=3>
```

View File

@ -29,7 +29,8 @@ The following attributes exist on the pagination object:
| `for_page()` | Returns the record for a different page.
The `for_page()` function accepts a page number and returns the record for
the other page.
the other page. For more information also see the virtual path example
below.
!! *Changed in Lektor 2.0:* The `for_page()` method was added.
@ -66,3 +67,23 @@ next page link as well as the number of the current page:
{% endif %}
</div>
```
## Virtual Paths
!! *New in Lektor 2.0:* virtual paths did not exist in earlier Lektor versions.
The pagination is implemented in a way where each page in the pagination is
a virtual path below the record itself. The value of the path is just the
number of the page. So for instance to link to the second page you can just
do this:
```html+jinja
<a href="{{ '@2'|url }}">Go to Page 2</a>
```
Alternatively you can also use the `for_path()` function which returns the
entire pagination for a page:
```html+jinja
<a href="{{ this.pagination.for_page(2)|url }}">Go to Page 2</a>
```

View File

@ -1,16 +0,0 @@
title: parent
---
summary: Returns the parent record for a record.
---
type: property
---
body:
Because Lektor's database is a tree all records with the exception of the
root record have a parent. This can be accessed with this property.
## Example
```html+jinja
<a href="{{ this.parent|url }}">Up to {{ this.parent.title }}</a>
```

View File

@ -19,17 +19,22 @@ artifacts out of source objects.
```python
from lektor.sourceobj import VirtualSourceObject
from lektor.build_programs import BuildProgram
from lektor.utils import build_url
class Source(VirtualSourceObject):
@property
def path(self):
return self.record.path + '@source'
@property
def source_content(self):
with open(self.parent.source_filename) as f:
with open(self.record.source_filename) as f:
return f.read().decode('utf-8')
@property
def url_path(self):
return self.parent.url_path + 'source.txt'
return build_url([self.record.url_path, 'source.txt'])
class SourceBuildProgram(BuildProgram):
@ -43,6 +48,11 @@ class SourceBuildProgram(BuildProgram):
this=self.source)
env.add_build_program(Source, SourceBuildProgram)
@env.virtualpathresolver('source')
def resolve_virtual_path(record, pieces):
if not pieces:
return Source(record)
```
And here the example `view_source.html` template:

View File

@ -20,12 +20,17 @@ exists for a page.
```python
from lektor.sourceobj import VirtualSourceObject
from lektor.db import Record
from lektor.utils import build_url
class Source(VirtualSourceObject):
@property
def path(self):
return self.record.path + '@source'
@property
def url_path(self):
return self.parent.url_path + 'source.txt'
return build_url([self.record.url_path, 'source.txt'])
@env.generator
def generate_source_file(node):

View File

@ -19,12 +19,17 @@ URL path segments.
```python
from lektor.sourceobj import VirtualSourceObject
from lektor.utils import build_url
class Source(VirtualSourceObject):
@property
def path(self):
return self.record.path + '@source'
@property
def url_path(self):
return self.parent.url_path + 'source.txt'
return build_url([self.record.url_path, 'source.txt'])
@env.urlresolver
def match_source_file(node, url_path):

View File

@ -0,0 +1,63 @@
title: virtualpathresolver
---
signature: prefix
---
summary: Registers a virtual path resolver with the environment.
---
type: method
---
version_added: 2.0
---
body:
When implementing [Virtual Source Objects :ref](../../db/obj/) it's important
that you can locate them. While virtual sources do not appear as children of
pages they can be navigated to through the database pad. This is achieved
through custom virtual path resolvers.
Each source object needs to be a unique prefix which identifies all instances
of it. For instance if you want to implement a special page (like a feed)
that would exist for specific pages, then you can register the virtual path
prefix `feed` with your plugin. Though you should use more descriptive names
for your plugins as these paths are shared.
If a user then resolves the page `/my-page@feed` it would invoke your URL
resolver with the record `my-page` and an empty list (rest of the path
segments). If they would request `/my-page@feed/recent` then it would
pass `['recent']` as path segments.
## Example
Here an example that would implement a virtual source for feeds and an
associated virtual path resolver:
```python
from lektor.sourceobj import VirtualSourceObject
from lektor.utils import build_url
class Feed(VirtualSourceObject):
def __init__(self, record, version='default'):
VirtualSourceObject.__init__(self, record)
self.version = version
@property
def path(self):
return '%s@feed%s' % (
self.record.path,
'/' + self.version if self.version != 'default' else '',
)
@property
def url_path(self):
return build_url([self.record.url_path, 'feed.xml'])
def on_setup_env(self, **extra):
@self.env.virtualpathresolver('feed')
def resolve_virtual_path(record, pieces):
if not pieces:
return Feed(record)
elif pieces == ['recent']:
return Feed(record, version='recent')
```

View File

@ -0,0 +1,32 @@
title: build_url
---
module: lektor.utils
---
signature: pieces, trailing_slash=None
---
summary: Helper function that assists in building URL paths.
---
type: function
---
version_added: 2.0
---
body:
This function assists in creating URL paths from individual segments. This
is particularly useful for building virtual source objects. It takes a bunch
of path segments and returns an absolute path. The default behavior is to
guess the trailing slash based on the presence of a dot in the last path
segment. If you want to override the detection you can explicitly pass
`True` to enforce a trailing slash or `False` to avoid it.
## Example
```pycon
>>> from lektor.utils import build_url
>>> build_url(['foo', 42])
u'/foo/42/'
>>> build_url(['foo', None, 23])
u'/foo/23/'
>>> build_url(['foo', 'hello.html'])
u'/foo/hello.html'
```

View File

@ -0,0 +1,45 @@
title: join_path
---
module: lektor.utils
---
signature: a, b
---
summary: Joins two Lektor paths together correctly.
---
template_var:
---
type: function
---
version_added: 2.0
---
body:
Given two Lektor paths this joins them together with the rules that Lektor
set up for this. In particular this is important in the presence of
virtual paths which have their own join semantics.
## Example
```pycon
>>> from lektor.utils import join_path
>>> join_path('/blog', 'hello-world')
'/blog/hello-world'
>>> join_path('/blog', '@archive')
'/blog@archive'
>>> join_path('/blog@archive', '2015')
'/blog@archive/2015'
>>> join_path('/blog@archive/2015', '..')
'/blog@archive'
>>> join_path('/blog@archive', '..')
'/blog'
```
Special note should be taken for the numeric virtual path of paginations. It
is considered to be on the same level as the actual record:
```pycon
>>> join_path('/blog@2', '..')
'/'
>>> join_path('/blog@2', '.')
'/blog@2'
```

View File

@ -0,0 +1,25 @@
title: parse_path
---
module: lektor.utils
---
summary: Parses a path into components.
---
type: function
---
version_added: 2.0
---
body:
This function parses a path into the individual components it's made from.
The path is always assumed to be absolute and made absolute if it's not yet
so. The root path is the empty list.
## Example
```pycon
>>> from lektor.utils import parse_path
>>> parse_path('/foo/bar')
['foo', 'bar']
>>> parse_path('/')
[]
```

View File

@ -72,3 +72,11 @@ targeted, different files will be used. This table visualizes this:
| | ✓ | | fr | contents+fr.lr
| | | ✓ | en | contents+en.lr
| | | ✓ | fr | *missing*
## Alternatives and Paths
Alternatives have a special behavior with regards to paths. They alternative
code does not exist in the path! This can be confusing at first, but has the
advantage that they automatically work in most places as the paths are the
same for differnent alternatives. For more information see
[Alternatives and Paths :ref](../paths/#alternatives-and-paths).

View File

@ -0,0 +1,96 @@
title: Paths
---
summary: An explanation about how paths in Lektor work.
---
body:
!! This refers mostly to Lektor 2.0. If you are using an older version of
Lektor the virtual path feature is not implemented yet and a lot of this just
does not apply.
Lektor attempts to map the paths from the content folder as closely as possible
to the final URLs (at least in the default configuration). There are various
ways in which this can be customized (see [URLs and Slugs :ref](../urls/)) and
there are situations in which Lektor needs to render out content that does not
actually directly correspond to a path on the file system.
Here we try to explain how the path system in Lektor works and what it means.
## What is a Path
A path in Lektor is a string that uniquely identifies a [Source Object
:ref](../../api/db/obj/). For the most part these directly point to
`content.lr` files or attachments therein. These paths always use forward
slashes, no matter on which platform Lektor runs. So for instance if you
have a file named `projects/my-project/contents.lr` then the path for this
record will be `/projects/my-project`. Thereby it does not matter if the
slug or final URL was changed, the path is always the same.
Lektor uses paths to refer to records by default in all cases. This means that
if you use the [url filter :ref](../../api/templates/filters/url/) for instance
it will operate on paths and not URLs!
But what about sources that do not directly correspond to something on the
file system? This is where virtual paths come in. Virtual paths are
separated from physical paths with an at (`@`) sign. Virtual paths are always
attached to a record and point to things that are not actually coming from the
content folder but something else.
Virtual paths are for instance used to refer to paginated pages or to some
resources that plugins add.
## Virtual Paths
Virtual paths are added behind physical paths and are separated by an at
sign (`@`). The only virtual path that is supported by Lektor out of the box
is the special numeric virtual path which can be used to refer to specific
pages for a pagination. For instance `/blog@1` refers to the first page of
the `blog` page, `/blog@2` to the second etc. There are however plugins that
add virtual paths to refer to their own resources. For instance a plugin can
register `/blog@feed` to refer to a RSS/Atom feed for the blog.
## Relative Paths
Now that we talked a bit about paths, we should probably cover how relative
paths work. Relative paths work similar to how you expect them to work on
most operating systems but they can operate on the virtual as well as the
physical path. There is also some special behavior with regards to the numeric
virtual path for pagination.
For the most part `.` refers to the same page and `..` refers to the page one
level up. If you use a path that just contains of a virtual path, then it's
attached to the current page to replace the active virtual path.
| Current | Relative | Result
| --------------- | ------------ | ----------------
| `/blog` | `..` | `/`
| `/blog` | `.` | `/blog`
| `/blog/post` | `..` | `/blog`
| `/blog` | `@2` | `/blog@2`
| `/blog@2` | `@1` | `/blog@1`
| `/blog@feed` | `recent` | `/blog@feed/recent`
| `/blog@feed` | `..` | `/blog@feed`
However! The numeric path is special with regards because it's considered to
belong to the current page:
| Current | Relative | Result
| --------------- | ------------ | ----------------
| `/blog@2` | `.` | `/blog@2`
| `/blog@2` | `..` | `/`
## Alternatives and Paths
If you have used Lektor a bit you might be wondering how [Alternatives
:ref](../alts/) work with paths. The answer might be surprising but it's
basically that they don't really work with paths. Alternatives are
implemented on a level higher than paths. If you have a page that exists
in both German and English alternative then they will have the same path.
The alternative code is supplied separately.
This is done so that you can later start introducing alternatives to sites
that were never aware of them without having to go everywhere and start
passing alternative information around. While there are indeed some places
where you might have to perform some changes (especially if you perform
manual queries in the templates) for the most part adding alternatives to an
existing site later is a trivial matter.

View File

@ -1,72 +1,45 @@
# -*- coding: utf-8 -*-
import posixpath
from datetime import date
from itertools import chain
from werkzeug.utils import cached_property
from lektor.pluginsystem import Plugin
from lektor.sourceobj import VirtualSourceObject
from lektor.build_programs import BuildProgram
from lektor.context import get_ctx
def get_path_segments(str):
pieces = str.split('/')
if pieces == ['']:
return []
return pieces
def push_path(pieces, item):
if item:
pieces.append(unicode(item))
from lektor.utils import build_url, parse_path
class BlogArchive(VirtualSourceObject):
def __init__(self, parent, plugin, items=None, year=None, month=None):
VirtualSourceObject.__init__(self, parent)
def __init__(self, record, plugin, items=None):
VirtualSourceObject.__init__(self, record)
self.plugin = plugin
self._items = items
self.year = year
self.month = month
@property
def date(self):
if self.year is None:
raise AttributeError()
return date(self.year, self.month or 1, 1)
def path(self):
return self.record.path + '@blog-archive'
template_name = 'blog-archive/index.html'
@property
def year_archive(self):
if self.year is None:
raise AttributeError()
if self.month is None:
return self
return BlogArchive(self.parent, self.plugin, year=self.year)
@property
def archive_index(self):
if self.year is None:
return self
return BlogArchive(self.parent, self.plugin)
def parent(self):
return self.record
@cached_property
def year_archives(self):
if self.year is not None:
return []
years = set()
for item in self.parent.children:
for item in self.record.children:
pub_date = self.plugin.get_pub_date(item)
if pub_date:
years.add(pub_date.year)
return [BlogArchive(self.parent, self.plugin,
year=year) for year in sorted(years)]
return [BlogYearArchive(self.record, self.plugin,
year=year) for year in sorted(years)]
@property
def items(self):
if self.year is None:
return []
if self._items is not None:
return self._items
rv = list(self._iter_items())
@ -74,13 +47,7 @@ class BlogArchive(VirtualSourceObject):
return rv
def _iter_items(self):
for item in self.parent.children:
pub_date = self.plugin.get_pub_date(item)
if pub_date is None:
continue
if pub_date.year == self.year and \
(self.month is None or pub_date.month == self.month):
yield item
return iter(())
@property
def has_any_items(self):
@ -97,34 +64,87 @@ class BlogArchive(VirtualSourceObject):
pub_date = self.plugin.get_pub_date(item)
months.setdefault(date(pub_date.year, pub_date.month, 1),
[]).append(item)
return [(BlogArchive(self.parent, self.plugin,
year=d.year, month=d.month), i)
return [(BlogMonthArchive(self.record, self.plugin,
year=d.year, month=d.month), i)
for d, i in sorted(months.items())]
def get_archive_url_path(self):
return self.plugin.get_url_path('archive_path')
@property
def url_path(self):
prefix = self.parent.url_path.strip('/')
pieces = []
if prefix:
pieces.append(prefix)
if self.year is None:
push_path(pieces, self.plugin.get_archive_index_path())
elif self.month is None:
push_path(pieces, self.plugin.get_month_archive_prefix())
push_path(pieces, self.year)
else:
push_path(pieces, self.plugin.get_year_archive_prefix())
push_path(pieces, self.year)
push_path(pieces, self.month)
return '/%s/' % '/'.join(pieces)
return build_url(chain([self.record.url_path.strip('/')],
self.get_archive_url_path() or ()))
class BlogYearArchive(BlogArchive):
template_name = 'blog-archive/year.html'
def __init__(self, record, plugin, items=None, year=None):
BlogArchive.__init__(self, record, plugin, items)
self.year = year
def _iter_items(self):
for item in self.record.children:
pub_date = self.plugin.get_pub_date(item)
if pub_date is not None and \
pub_date.year == self.year:
yield item
@property
def template_name(self):
if self.year is None:
return 'blog-archive/index.html'
if self.month is None:
return 'blog-archive/year.html'
return 'blog-archive/month.html'
def path(self):
return '%s@blog-archive/%s' % (
self.record.path,
self.year,
)
@property
def parent(self):
return BlogArchive(self.record, self.plugin)
@property
def date(self):
return date(self.year, 1, 1)
def get_archive_url_path(self):
return self.plugin.get_url_path('year_archive_prefix') + [self.year]
class BlogMonthArchive(BlogArchive):
template_name = 'blog-archive/year.html'
def __init__(self, record, plugin, items=None, year=None, month=None):
BlogArchive.__init__(self, record, plugin, items)
self.year = year
self.month = month
def _iter_items(self):
for item in self.record.children:
pub_date = self.plugin.get_pub_date(item)
if pub_date is not None and \
pub_date.year == self.year and \
pub_date.month == self.month:
yield item
@property
def path(self):
return '%s@blog-archive/%s/%s' % (
self.record.path,
self.year,
self.month
)
@property
def parent(self):
return BlogYearArchive(self.record, self.plugin, year=self.year)
@property
def date(self):
return date(self.year, self.month, 1)
def get_archive_url_path(self):
return self.plugin.get_url_path('month_archive_prefix') + [
self.year, self.month]
class BlogArchiveBuildProgram(BuildProgram):
@ -150,77 +170,70 @@ class BlogArchivePlugin(Plugin):
def get_blog_path(self):
return self.get_config().get('blog_path', '/blog')
def get_archive_index_path(self):
return self.get_config().get('archive_path', 'archive').strip('/')
def get_year_archive_prefix(self):
return self.get_config().get('year_archive_prefix', 'archive').strip('/')
def get_month_archive_prefix(self):
return self.get_config().get('month_archive_prefix', 'archive').strip('/')
def get_url_path(self, name, default='archive'):
return parse_path(self.get_config().get(name, default))
def on_setup_env(self, **extra):
blog_path = self.get_blog_path()
self.env.add_build_program(BlogArchive, BlogArchiveBuildProgram)
def get_blog_archive():
pad = get_ctx().pad
blog = pad.get(blog_path)
if blog is not None:
return BlogArchive(blog, self)
self.env.jinja_env.globals['get_blog_archive'] = get_blog_archive
@self.env.virtualpathresolver('blog-archive')
def blog_archive_resolver(node, pieces):
if node.path == self.get_blog_path():
if not pieces:
return BlogArchive(node, self)
elif len(pieces) == 1 and pieces[0].isdigit():
return BlogYearArchive(node, self, year=int(pieces[0]))
elif len(pieces) == 2 and pieces[0].isdigit() \
and pieces[1].isdigit():
return BlogMonthArchive(node, self, year=int(pieces[0]),
month=int(pieces[1]))
@self.env.urlresolver
def archive_resolver(node, url_path):
if node.path != blog_path:
def archive_urlresolver(node, url_path):
if node.path != self.get_blog_path():
return
archive_index = get_path_segments(self.get_archive_index_path())
archive_index = self.get_url_path('archive_path')
if url_path == archive_index:
return BlogArchive(node, self)
year_prefix = get_path_segments(self.get_year_archive_prefix())
month_prefix = get_path_segments(self.get_month_archive_prefix())
year = None
month = None
year_prefix = self.get_url_path('year_archive_prefix')
if url_path[:len(year_prefix)] == year_prefix and \
url_path[len(year_prefix)].isdigit() and \
len(url_path) == len(year_prefix) + 1:
year = int(url_path[len(year_prefix)])
elif (url_path[:len(month_prefix)] == month_prefix and
len(url_path) == len(month_prefix) + 2 and
url_path[len(month_prefix)].isdigit() and
url_path[len(month_prefix) + 1].isdigit()):
rv = BlogYearArchive(node, self, year=year)
if rv.has_any_items:
return rv
month_prefix = self.get_url_path('month_archive_prefix')
if url_path[:len(month_prefix)] == month_prefix and \
len(url_path) == len(month_prefix) + 2 and \
url_path[len(month_prefix)].isdigit() and \
url_path[len(month_prefix) + 1].isdigit():
year = int(url_path[len(month_prefix)])
month = int(url_path[len(month_prefix) + 1])
else:
return None
rv = BlogArchive(node, self, year=year, month=month)
if rv.has_any_items:
return rv
rv = BlogMonthArchive(node, self, year=year, month=month)
if rv.has_any_items:
return rv
@self.env.generator
def genererate_blog_archive_pages(source):
if source.path != blog_path:
if source.path != self.get_blog_path():
return
blog = source
years = {}
months = {}
for post in blog.children:
for post in source.children:
pub_date = self.get_pub_date(post)
if pub_date:
years.setdefault(pub_date.year, []).append(post)
months.setdefault((pub_date.year,
pub_date.month), []).append(post)
yield BlogArchive(blog, self)
yield BlogArchive(source, self)
for year, items in sorted(years.items()):
yield BlogArchive(blog, self, year=year, items=items)
yield BlogYearArchive(source, self, year=year, items=items)
for (year, month), items in sorted(months.items()):
yield BlogArchive(blog, self, year=year, month=month,
items=items)
yield BlogMonthArchive(source, self, year=year, month=month,
items=items)

View File

@ -3,7 +3,7 @@
{% block blog_body %}
<h1>The Transcript, {{ this.date|dateformat('MMMM yyyy') }}</h1>
<p>
<a href="{{ this.year_archive|url }}">&laquo; Back to {{ this.year }}</a>
<a href="{{ '..'|url }}">&laquo; Back to {{ this.year }}</a>
<ul>
{% for post in this.items %}
<li><a href="{{ post|url }}">{{ post.title }}</a> by {{ post.author }} on {{ post.pub_date|dateformat }}

View File

@ -3,7 +3,7 @@
{% block blog_body %}
<h1>The Transcript in {{ this.year }}</h1>
<p>
<a href="{{ this.archive_index|url }}">&laquo; Back to the archive</a>
<a href="{{ '..'|url }}">&laquo; Back to the archive</a>
<ul>
{% for archive, items in this.items_by_months %}
<li><a href="{{ archive|url }}">{{ archive.date|dateformat('MMMM') }}</a>:

View File

@ -33,8 +33,7 @@
</ul>
<h3>Missed a Post?</h3>
<ul>
{% set archive = get_blog_archive() %}
<li><a href="{{ archive|url }}">Blog Archives</a>
<li><a href="{{ '$blog-archive'|url }}">Blog Archives</a>
</ul>
</div>
</div>