diff --git a/README.md b/README.md index fa707b3..e377d81 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Optionally, enable a basic config: ```ini [tags] root = / -slug = tag/{group}.html +slug = tag/{key}.html template = tag.html split = ' ' ``` diff --git a/examples/README.md b/examples/README.md index 7d3f270..dde9ed4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,7 +13,7 @@ After reading this tutorial, have a look at other plugins that use `lektor-group ## About To use the groupby plugin you have to add an attribute to your model file. -In our case you can refer to the [`models/page.ini`](./models/page.ini) model: +For this tutorial you can refer to the [`models/page.ini`](./models/page.ini) model: ```ini [fields.tags] @@ -28,7 +28,7 @@ type = markdown testC = true ``` -We did define three custom attributes `testA`, `testB`, and `testC`. +We define three custom attributes `testA`, `testB`, and `testC`. You may add custom attributes to all of the fields. It is crucial that the value of the custom attribute is set to true. The attribute name is later used for grouping. @@ -52,12 +52,19 @@ slug = config/{key}.html template = example-config.html split = ' ' enabled = True +key_obj_fn = (X.upper() ~ ARGS.key.fieldKey) if X else 'empty' +replace_none_key = unknown [testA.children] order_by = -title, body +[testA.pagination] +enabled = true +per_page = 5 +url_suffix = .page. + [testA.fields] -title = "Tagged: " ~ this.group +title = "Tagged: " ~ this.key_obj [testA.key_map] Blog = News @@ -84,33 +91,41 @@ The configuration parameter are: These single-line fields are then expanded to lists as well. If you do not provide the `split` option, the whole field value will be used as tagname. 6. The `enabled` parameter allows you to quickly disable the grouping. +7. The `key_obj_fn` parameter (jinja2) accepts any function-like snippet or function call. + The context provides two variables, `X` and `ARGS`. + The former is the raw value of the grouping, this may be a text field, markdown, or whatever custom type you have provided. + The latter is a named tuple with `record`, `key`, and `field` values (see [simple example](#simple-example)). +8. The `replace_none_key` parameter (string) is applied after `key_obj_fn` (if provided) and maps empty values to a default value. You can have multiple listeners, e.g., one for `/blog/` and another for `/projects/`. -Just create as many custom attributes as you like, each having its own section. +Just create as many custom attributes as you like, each having its own section (and subsections). The `.children` subsection currently has a single config field: `order_by`. The usual [order-by](https://www.getlektor.com/docs/guides/page-order/) rules apply (comma separated list of keys with `-` for reversed order). -The order-by key can be anything of the page attributes of the children (the same model where you added the grouping key). +The order-by key can be anything of the page attributes of the children. -There are two additional config subsections, `.fields` and `.key_map`. -Key-value pairs in `.fields` will be added as attributes to your grouping. +The `.pagination` subsection accepts the same configuration options as the Lektor pagination [model](https://www.getlektor.com/docs/models/children/#pagination) and [guide](https://www.getlektor.com/docs/guides/pagination/). +Plus, an additional `url_suffix` parameter if you would like to customize the URL scheme. + +The `.fields` subsection is a list of key-value pairs which will be added as attributes to your grouping. You can access them in your template (e.g., `{{this.title}}`). -All of the `.fields` values are evaluted in a jinja context, so be cautious when using plain strings. +All of the `.fields` values are evaluted in a jinja context, so be cautious when using plain strings. +Further, they are evaluated on access and not on define. The built-in field attributes are: -- `group`: returned group name, e.g., "A Title?" -- `key`: slugified group value, e.g., "a-title" -- `slug`: url path after root node, e.g. "config/a-title.html" (can be `None`) +- `key_obj`: model returned object, e.g., "A Title?" +- `key`: slugified value of `key_obj`, e.g., "a-title" - `record`: parent node, e.g., `Page(path="/")` +- `slug`: url path under parent node, e.g. "config/a-title.html" (can be `None`) - `children`: the elements of the grouping (a `Query` of `Record` type) - `config`: configuration object (see below) -Without any changes, the `key` value will just be `slugify(group)`. -However, the other mapping `.key_map` will replace `group` with whatever replacement value is provided in the `.key_map` and then slugify. +Without any changes, the `key` value will just be `slugify(key_obj)`. +However, the `.key_map` subsection will replace `key_obj` with whatever replacement value is provided in the `.key_map` and then slugify. You could, for example, add a `C# = c-sharp` mapping, which would otherwise just be slugified to `c`. -This is equivalent to `slugify(key_map.get(group))`. +This is equivalent to `slugify(key_map.get(key_obj))`. The `config` attribute contains the values that created the group: @@ -118,13 +133,16 @@ The `config` attribute contains the values that created the group: - `root`: as provided by init, e.g., `/` - `slug`: the raw value, e.g., `config/{key}.html` - `template`: as provided by init, e.g., `example-config.html` +- `key_obj_fn`: as provided by init, e.g., `X.upper() if X else 'empty'` +- `replace_none_key`: as provided by init, e.g., `unknown` - `enabled`: boolean - `dependencies`: path to config file (if initialized from config) - `fields`: raw values from `TestA.fields` - `key_map`: raw values from `TestA.key_map` +- `pagination`: raw values from `TestA.pagination` - `order_by`: list of key-strings from `TestA.children.order_by` -In your template file you have access to the attributes, config, and children (pages): +In your template file you have access to the config, attributes, fields, and children (Pages): ```jinja2
Custom field date: {{this.date}}
@@ -231,29 +250,43 @@ def on_groupby_before_build_all(self, groupby, builder, **extra): return # load config directly (which also tracks dependency) - watcher = groupby.add_watcher('testC', config) + watcher = groupby.add_watcher('testC', config, pre_build=True) @watcher.grouping() def convert_replace_example(args): # args.field assumed to be Markdown obj = args.field.source - slugify_map = {} # type Dict[str, str] + url_map = {} # type Dict[str, str] for match in regex.finditer(obj): tag = match.group(1) - key = yield tag - print('[advanced] slugify:', tag, '->', key) - slugify_map[tag] = key + vobj = yield tag + if not hasattr(vobj, 'custom_attr'): + vobj.custom_attr = [] + vobj.custom_attr.append(tag) + url_map[tag] = vobj.url_path + print('[advanced] slugify:', tag, '->', vobj.key) def _fn(match: re.Match) -> str: tag = match.group(1) - return f'{tag}' + return f'{tag}' args.field.source = regex.sub(_fn, obj) ``` Notice, `add_watcher` accepts a config file as parameter which keeps also track of dependencies and rebuilds pages when you edit the config file. -Further, the `yield` call returns the slugified group-key. -First, you do not need to slugify it yourself and second, potential replacements from `key_map` are already handled. +Further, the `yield` call returns a `GroupBySource` virtual object. +You can use this object to add custom static attributes (similar to dynamic attributes with the `.fields` subsection config). +Not all attributes are available at this time, as the grouping is still in progress. +But you can use `vobj.url_path` to get the target URL or `vobj.key` to get the slugified object-key (substitutions from `key_map` are already applied). + +Usually, the grouping is postponed until the very end of the build process. +However, in this case we want to modify the source before it is build by Lektor. +For this situation we need to set `pre_build=True` in our `groupby.add_watcher()` call. +All watcher with this flag will be processed before any Page is built. +**Note:** If you can, avoid this performance regression. +The grouping for these watchers will be performed each time you navigate from one page to another. + +This example uses a Markdown model type as source. For Markdown fields, we can modify the `source` attribute directly. All other field types need to be accessed via `args.record` key indirection (see [simple example](#simple-example)). @@ -268,23 +301,26 @@ match = {{([^}]{1,32})}} ``` The config file takes the same parameters as the [config example](#quick-config). -As you can see, `slug` is evaluated in jinja context. - We introduced a new config option `testC.pattern.match`. This regular expression matches `{{` + any string less than 32 characters + `}}`. -Notice, the parenthesis (`()`) will match only the inner part but the replace function (`re.sub`) will remove the `{{}}`. +Notice, the parenthesis (`()`) will match only the inner part, thus the replace function (`re.sub`) removes the `{{}}`. ## Misc -It was shortly mentioned above that slugs can be `None` (only if manually set to `slug = None`). +### Omit output with empty slugs + +It was shortly mentioned above that slugs can be `None` (e.g., manually set to `slug = None`). This is useful if you do not want to create subpages but rather an index page containing all groups. -This can be done in combination with the next use-case: +You can combine this with the next use-case. + + +### Index pages & Group query + filter ```jinja2 -{%- for x in this|vgroups(keys=['TestA', 'TestB'], fields=[], flows=[], recursive=True, order_by='group') %} -({{ x.group }}) +{%- for x in this|vgroups(keys=['TestA', 'TestB'], fields=[], flows=[], recursive=True, order_by='key_obj') %} +({{ x.key_obj }}) {%- endfor %} ``` @@ -293,8 +329,21 @@ You can query the groups of any parent node (including those without slug). The keys (`'TestA', 'TestB'`) can be omitted which will return all groups of all attributes (you can still filter them with `x.config.key == 'TestC'`). The `fields` and `flows` params are also optional. With these you can match groups in `args.key.fieldKey` and `args.key.flowKey`. -For example, if you have a “tags” field and an “additional-tags” field and you only want to show one in a preview. +For example, if you have a “main-tags” field and an “additional-tags” field and you want to show the main-tags in a preview but both tags on a detail page. -`order_by` can be either a comma-separated string or a list of keys. + +### Sorting groups + +Sorting is supported for the `vgroups` filter as well as for the children of each group (via config subsection `.children.order_by`). +Coming back to the previous example, `order_by` can be either a comma-separated string of keys or a list of strings. Again, same [order-by](https://www.getlektor.com/docs/guides/page-order/) rules apply as for any other Lektor `Record`. Only this time, the attributes of the `GroupBy` object are used for sorting (including those you defined in the `.fields` subsection). + + +### Pagination + +You may use the `.pagination` subsection or `watcher.config.set_pagination()` to configure a pagination controller. +The `url_path` of a paginated Page depends on your `slug` value. +If the slug ends on `/` or `/index.html`, Lektor will append `page/2/index.html` to the second page. +If the slug contains a `.` (e.g. `/a/{key}.html`), Lektor will insert `page2` in front of the extension (e.g., `/a/{key}page2.html`). +If you supply a different `url_suffix`, for example “.X.”, those same two urls will become `.X./2/index.html` and `/a/{key}.X.2.html` respectively. diff --git a/examples/configs/advanced.ini b/examples/configs/advanced.ini index b620693..48dfa4f 100644 --- a/examples/configs/advanced.ini +++ b/examples/configs/advanced.ini @@ -7,7 +7,7 @@ template = example-advanced.html match = {{([^}]{1,32})}} [testC.fields] -desc = "Tag: " ~ this.group ~ ", Key: " ~ this.key +desc = "Input object: {}, output key: {}".format(this.key_obj, this.key) [testC.key_map] Blog = case-sensitive diff --git a/examples/configs/groupby.ini b/examples/configs/groupby.ini index eca3f29..515ab2d 100644 --- a/examples/configs/groupby.ini +++ b/examples/configs/groupby.ini @@ -4,12 +4,19 @@ root = / slug = config/{key}.html template = example-config.html split = ' ' +key_obj_fn = '{}-z-{}'.format(X.upper(), ARGS.key.fieldKey) if X else None +replace_none_key = unknown [testA.children] order_by = -title, body +[testA.pagination] +enabled = true +per_page = 1 +url_suffix = .page. + [testA.fields] -title = "Tagged: " ~ this.group +title = "Tagged: " ~ this.key_obj [testA.key_map] Blog = News diff --git a/examples/packages/advanced-example/lektor_advanced.py b/examples/packages/advanced-example/lektor_advanced.py index 58ad4de..9734e87 100644 --- a/examples/packages/advanced-example/lektor_advanced.py +++ b/examples/packages/advanced-example/lektor_advanced.py @@ -17,20 +17,24 @@ class AdvancedGroupByPlugin(Plugin): return # load config directly (which also tracks dependency) - watcher = groupby.add_watcher('testC', config) + watcher = groupby.add_watcher('testC', config, pre_build=True) @watcher.grouping() def _replace(args: GroupByCallbackArgs) -> Generator[str, str, None]: # args.field assumed to be Markdown obj = args.field.source - slugify_map = {} # type Dict[str, str] + url_map = {} # type Dict[str, str] for match in regex.finditer(obj): tag = match.group(1) - key = yield tag - print('[advanced] slugify:', tag, '->', key) - slugify_map[tag] = key + vobj = yield tag + if not hasattr(vobj, 'custom_attr'): + vobj.custom_attr = [] + # update static custom attribute + vobj.custom_attr.append(tag) + url_map[tag] = vobj.url_path + print('[advanced] slugify:', tag, '->', vobj.key) def _fn(match: re.Match) -> str: tag = match.group(1) - return f'{tag}' + return f'{tag}' args.field.source = regex.sub(_fn, obj) diff --git a/examples/packages/simple-example/lektor_simple.py b/examples/packages/simple-example/lektor_simple.py index f047b64..6bdf7f3 100644 --- a/examples/packages/simple-example/lektor_simple.py +++ b/examples/packages/simple-example/lektor_simple.py @@ -11,9 +11,17 @@ class SimpleGroupByPlugin(Plugin): 'root': '/blog', 'slug': 'simple/{key}/index.html', 'template': 'example-simple.html', + 'key_obj_fn': 'X.upper() if X else "empty"', + 'replace_none_key': 'unknown', }) watcher.config.set_key_map({'Foo': 'bar'}) watcher.config.set_fields({'date': datetime.now()}) + watcher.config.set_order_by('-title,body') + watcher.config.set_pagination( + enabled=True, + per_page=1, + url_suffix='p', + ) @watcher.grouping(flatten=True) def fn_simple(args: GroupByCallbackArgs) -> Iterator[Tuple[str, dict]]: diff --git a/examples/templates/example-advanced.html b/examples/templates/example-advanced.html index 458c81d..da8ae5c 100644 --- a/examples/templates/example-advanced.html +++ b/examples/templates/example-advanced.html @@ -1,4 +1,5 @@This is: {{this}}
Custom field, desc: "{{this.desc}}"
+Custom static, seen objects: {{this.custom_attr}}
Children: {{this.children.all()}}
diff --git a/examples/templates/example-config.html b/examples/templates/example-config.html index db9547e..8b71791 100644 --- a/examples/templates/example-config.html +++ b/examples/templates/example-config.html @@ -1,7 +1,7 @@This is: {{this}}
-Group: "{{this.group}}", Key: "{{this.key}}"
-Custom field title: {{this.title}}
+Object: "{{this.key_obj}}", Key: "{{this.key}}"
+Custom field title: "{{this.title}}"
This is: {{this}}
+Key: {{this.key}}
+Object: {{this.key_obj}}
Custom field date: {{this.date}}