add support for config setup

This commit is contained in:
relikd
2022-03-25 22:32:33 +01:00
parent a3dae4dc21
commit da15fe1304
2 changed files with 96 additions and 13 deletions

View File

@@ -1,9 +1,14 @@
# Lektor Plugin: @groupby # Lektor Plugin: groupby
A generic grouping / clustering plugin. Can be used for tagging and similar tasks. A generic grouping / clustering plugin. Can be used for tagging or similar tasks.
Overview:
- the [basic example](#usage-basic-example) goes into detail how this plugin works.
- the [quick config](#usage-quick-config) example show how you can use the plugin config to setup a quick and easy tagging system.
- the [complex example](#usage-a-slightly-more-complex-example) touches on the potential of what is possible.
## Usage: Simple Example ## Usage: Basic example
Lets start with a simple example: adding a tags field to your model. Lets start with a simple example: adding a tags field to your model.
Assuming you have a `blog-entry.ini` that is used for all children of `/blog` path. Assuming you have a `blog-entry.ini` that is used for all children of `/blog` path.
@@ -107,8 +112,37 @@ This is: <GroupBySource attribute="myvar" group="latest-news" template="myvar.ht
``` ```
## Usage: Quick config
The whole example above can be simplified with a plugin config:
#### `configs/groupby.ini`
```ini
[myvar]
root = /blog/
slug = tag/{group}/index.html
template = myvar.html
split = ' '
```
You still need to add a separate attribute to your model (step 1), but anything else is handled by the config file.
All of these fields are optional and fallback to the default values stated above.
The newly introduced option `split` will be used as string delimiter.
This allows to have a field with `string` type instead of `strings` type.
If you do not provide the `split` option, the whole field value will be used as group key.
Note: split is only used on str fields (`string` type), not lists (`strings` type).
The emitted `extra-info` for the child is the original key value.
E.g., `Latest News,Awesome` with `split = ,` yields `('latest-news', 'Latest News')` and `('awesome', 'Awesome')`.
## Usage: A slightly more complex example ## Usage: A slightly more complex example
There are situations though, where a simple config file is not enough.
The following plugin will find all model fields with attribute `inlinetags` and search for in-text occurrences of `{{Tagname}}` etc.
```python ```python
from lektor.markdown import Markdown from lektor.markdown import Markdown
from lektor.types.formats import MarkdownDescriptor from lektor.types.formats import MarkdownDescriptor
@@ -129,7 +163,6 @@ def on_groupby_init(self, groupby, **extra):
yield slugify(tag), tag yield slugify(tag), tag
``` ```
This will find all model fields with attribute `inlinetags` and search for in-text occurrences of `{{Tagname}}`, etc.
This generic approach does not care what data-type the field value is: This generic approach does not care what data-type the field value is:
`strings` fields will be expanded and enumerated, Markdown will be unpacked. `strings` fields will be expanded and enumerated, Markdown will be unpacked.
You can combine this mere tag-detector with text-replacements to point to the actual tags-page. You can combine this mere tag-detector with text-replacements to point to the actual tags-page.

View File

@@ -7,6 +7,8 @@ from lektor.reporter import reporter
from lektor.sourceobj import SourceObject, VirtualSourceObject from lektor.sourceobj import SourceObject, VirtualSourceObject
from lektor.types.flow import Flow, FlowType from lektor.types.flow import Flow, FlowType
from lektor.utils import bool_from_string, build_url, prune_file_and_folder from lektor.utils import bool_from_string, build_url, prune_file_and_folder
# for quick config
from lektor.utils import slugify
from typing import \ from typing import \
NewType, NamedTuple, Tuple, Dict, Set, List, Optional, Iterator, Callable NewType, NamedTuple, Tuple, Dict, Set, List, Optional, Iterator, Callable
@@ -47,8 +49,9 @@ class GroupProducer(NamedTuple):
attribute: AttributeKey attribute: AttributeKey
func: GroupingCallback func: GroupingCallback
flatten: bool = True flatten: bool = True
template: Optional[str] = None
slug: Optional[str] = None slug: Optional[str] = None
template: Optional[str] = None
dependency: Optional[str] = None
class GroupComponent(NamedTuple): class GroupComponent(NamedTuple):
@@ -84,14 +87,16 @@ class GroupBySource(VirtualSourceObject):
attribute: AttributeKey, attribute: AttributeKey,
group: GroupKey, group: GroupKey,
children: List[GroupComponent] = [], children: List[GroupComponent] = [],
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
template: Optional[str] = None, # default: "groupby-attribute.html" template: Optional[str] = None, # default: "groupby-attribute.html"
slug: Optional[str] = None # default: "{attrib}/{group}/index.html" dependency: Optional[str] = None
): ):
super().__init__(record) super().__init__(record)
self.attribute = attribute self.attribute = attribute
self.group = group self.group = group
self.children = children self.children = children
self.template = template or 'groupby-{}.html'.format(self.attribute) self.template = template or 'groupby-{}.html'.format(self.attribute)
self.dependency = dependency
# custom user path # custom user path
slug = slug or '{attrib}/{group}/index.html' slug = slug or '{attrib}/{group}/index.html'
slug = slug.replace('{attrib}', self.attribute) slug = slug.replace('{attrib}', self.attribute)
@@ -110,6 +115,8 @@ class GroupBySource(VirtualSourceObject):
return build_url([self.record.path, self.slug]) return build_url([self.record.path, self.slug])
def iter_source_filenames(self) -> Iterator[str]: def iter_source_filenames(self) -> Iterator[str]:
if self.dependency:
yield self.dependency
for record, _ in self.children: for record, _ in self.children:
yield from record.iter_source_filenames() yield from record.iter_source_filenames()
@@ -194,6 +201,7 @@ class GroupByCreator:
self._models: Dict[AttributeKey, Dict[str, Dict[str, str]]] = {} self._models: Dict[AttributeKey, Dict[str, Dict[str, str]]] = {}
self._func: Dict[str, Set[GroupProducer]] = {} self._func: Dict[str, Set[GroupProducer]] = {}
self._resolve_map: Dict[str, UrlResolverConf] = {} # only for server self._resolve_map: Dict[str, UrlResolverConf] = {} # only for server
self._watched_once: Set[GroupingCallback] = set()
# -------------- # --------------
# Initialize # Initialize
@@ -243,8 +251,9 @@ class GroupByCreator:
root: str, root: str,
attrib: AttributeKey, *, attrib: AttributeKey, *,
flatten: bool = True, # if False, dont explode FlowType flatten: bool = True, # if False, dont explode FlowType
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
template: Optional[str] = None, # default: "groupby-attrib.html" template: Optional[str] = None, # default: "groupby-attrib.html"
slug: Optional[str] = None # default: "{attrib}/{group}/index.html" dependency: Optional[str] = None
) -> Callable[[GroupingCallback], None]: ) -> Callable[[GroupingCallback], None]:
''' '''
Decorator to subscribe to attrib-elements. Converter for groupby(). Decorator to subscribe to attrib-elements. Converter for groupby().
@@ -256,14 +265,30 @@ class GroupByCreator:
template: "groupby-attrib.html" template: "groupby-attrib.html"
slug: "{attrib}/{group}/index.html" slug: "{attrib}/{group}/index.html"
''' '''
root = root.rstrip('/') + '/'
def _decorator(fn: GroupingCallback): def _decorator(fn: GroupingCallback):
if root not in self._func: if root not in self._func:
self._func[root] = set() self._func[root] = set()
self._func[root].add( self._func[root].add(
GroupProducer(attrib, fn, flatten, template, slug)) GroupProducer(attrib, fn, flatten, template, slug, dependency))
return _decorator return _decorator
def watch_once(self, *args, **kwarg) -> Callable[[GroupingCallback], None]:
''' Same as watch() but listener is auto removed after build. '''
def _decorator(fn: GroupingCallback):
self._watched_once.add(fn)
self.watch(*args, **kwarg)(fn)
return _decorator
def remove_watch_once(self) -> None:
''' Remove all watch-once listeners. '''
for k, v in self._func.items():
not_once = {x for x in v if x.func not in self._watched_once}
self._func[k] = not_once
self._watched_once.clear()
# ---------- # ----------
# Helper # Helper
# ---------- # ----------
@@ -346,11 +371,11 @@ class GroupByCreator:
def make_cluster(self, root: lektor.db.Record) -> Iterator[GroupBySource]: def make_cluster(self, root: lektor.db.Record) -> Iterator[GroupBySource]:
''' Group by attrib and build Artifacts. ''' ''' Group by attrib and build Artifacts. '''
assert isinstance(root, lektor.db.Record) assert isinstance(root, lektor.db.Record)
for attrib, fn, flat, temp, slug in self._func.get(root.url_path, []): for attr, fn, fl, temp, slug, dep in self._func.get(root.url_path, []):
groups = self.groupby(attrib, root, func=fn, flatten=flat) groups = self.groupby(attr, root, func=fn, flatten=fl)
for group_key, children in groups.items(): for group_key, children in groups.items():
obj = GroupBySource(root, attrib, group_key, children, obj = GroupBySource(root, attr, group_key, children,
template=temp, slug=slug) template=temp, slug=slug, dependency=dep)
self.track_dev_server_path(obj) self.track_dev_server_path(obj)
yield obj yield obj
@@ -365,7 +390,7 @@ class GroupByCreator:
if len(pieces) >= 2: if len(pieces) >= 2:
attrib: AttributeKey = pieces[0] # type: ignore[assignment] attrib: AttributeKey = pieces[0] # type: ignore[assignment]
group: GroupKey = pieces[1] # type: ignore[assignment] group: GroupKey = pieces[1] # type: ignore[assignment]
for attr, _, _, _, slug in self._func.get(node.url_path, []): for attr, _, _, _, slug, _ in self._func.get(node.url_path, []):
if attr == attrib: if attr == attrib:
# TODO: do we need to provide the template too? # TODO: do we need to provide the template too?
return GroupBySource(node, attr, group, slug=slug) return GroupBySource(node, attr, group, slug=slug)
@@ -420,10 +445,35 @@ class GroupByPlugin(Plugin):
if self.creator.should_process(node): if self.creator.should_process(node):
yield from self.creator.make_cluster(node) yield from self.creator.make_cluster(node)
def _quick_config(self):
config = self.get_config()
for attrib in config.sections():
sect = config.section_as_dict(attrib)
root = sect.get('root', '/')
slug = sect.get('slug')
temp = sect.get('template')
split = sect.get('split')
@self.creator.watch_once(root, attrib, template=temp, slug=slug,
dependency=self.config_filename)
def _fn(args):
val = args.field
if isinstance(val, str):
val = val.split(split) if split else [val] # make list
if isinstance(val, list):
for tag in val:
yield slugify(tag), tag
def on_before_build_all(self, builder, **extra): def on_before_build_all(self, builder, **extra):
# load config file quick listeners (before initialize!)
self._quick_config()
# parse all models to detect attribs of listeners # parse all models to detect attribs of listeners
self.creator.initialize(builder.pad.db) self.creator.initialize(builder.pad.db)
def on_after_build_all(self, builder, **extra):
# remove all quick listeners (will be added again in the next build)
self.creator.remove_watch_once()
def on_after_prune(self, builder, **extra): def on_after_prune(self, builder, **extra):
# TODO: find better way to prune unreferenced elements # TODO: find better way to prune unreferenced elements
GroupByPruner.prune(builder) GroupByPruner.prune(builder)