Add grouping feature

This commit is contained in:
2026-02-13 13:14:11 +01:00
parent 164df2f770
commit c091ed1371
4 changed files with 451 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ luxtools provides DokuWiki syntax that:
- Lists a directory's direct children (files + folders) or files matching a glob pattern
- Renders an image thumbnail gallery (with lightbox)
- Groups multiple `{{image>...}}` blocks in compact grid/flex layouts
- Provides "open this folder/path" links for local workflows
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
- Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias
@@ -310,7 +311,52 @@ The image links to the full-size version when clicked.
Remote images (HTTP/HTTPS URLs) are linked directly without proxying or thumbnailing.
### 5) Open a local path/folder (best-effort)
### 5) Group multiple image boxes compactly
Use `<grouping> ... </grouping>` to arrange multiple `{{image>...}}` entries in less vertical space.
```text
<grouping>
{{image>/Scape/photos/1.jpg|One|300}}
{{image>/Scape/photos/2.jpg|Two|300}}
{{image>/Scape/photos/3.jpg|Three|300}}
{{image>/Scape/photos/4.jpg|Four|300}}
</grouping>
<grouping layout="flex" gap="0" justify="start" align="start">
{{image>/Scape/photos/1.jpg|One|220}}
{{image>/Scape/photos/2.jpg|Two|220}}
{{image>/Scape/photos/3.jpg|Three|220}}
</grouping>
<grouping layout="grid" cols="3" gap="0.4rem">
{{image>/Scape/photos/1.jpg|One|260}}
{{image>/Scape/photos/2.jpg|Two|260}}
{{image>/Scape/photos/3.jpg|Three|260}}
</grouping>
<grouping layout="flex" gap="0.5rem" justify="space-between" align="center">
{{image>/Scape/photos/1.jpg|One|220}}
{{image>/Scape/photos/2.jpg|Two|220}}
{{image>/Scape/photos/3.jpg|Three|220}}
</grouping>
```
Supported attributes on the opening tag:
- `layout`: `flex` (default) or `grid`
- `cols`: integer >= 1 (default `2`, used by `grid`)
- `gap`: CSS length token such as `0`, `0.6rem`, `8px` (default `0`)
- `justify`: `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` (default `start`)
- `align`: `start`, `center`, `end`, `stretch`, `baseline` (default `start`)
Notes:
- The wrapper only controls layout. It adds no own border/background/frame.
- Invalid values silently fall back to defaults.
- Unknown attributes render a small warning string, e.g. `[grouping: unknown option(s): gpa]`.
- Existing standalone `{{image>...}}` behavior is unchanged outside `<grouping>`.
### 6) Open a local path/folder (best-effort)
```
{{open>/Scape/projects|Open projects folder}}
@@ -322,7 +368,7 @@ Behaviour:
- Prefer calling the configured local client service (open_service_url).
- Fall back to opening a file:// URL in a new tab (often blocked by browsers).
### 6) Scratchpads (shared, file-backed, no page revisions)
### 7) Scratchpads (shared, file-backed, no page revisions)
```
{{scratchpad>start}}
@@ -330,7 +376,7 @@ Behaviour:
Scratchpads render the referenced file as wikitext and (when you have edit rights on the host page) provide an inline editor that saves directly to the backing file.
### 7) Link Favicons (automatic)
### 8) Link Favicons (automatic)
External links automatically display the favicon of the linked website. This feature:

View File

@@ -239,6 +239,101 @@ class plugin_luxtools_test extends DokuWikiTest
$this->assertStringContainsString('height="150"', $xhtml);
}
/**
* Grouping wrapper should use default flex mode with zero gap.
*/
public function test_grouping_default_flex()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping>'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$structure = [
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
'div.luxtools-grouping .luxtools-imagebox' => 2,
];
$this->structureCheck($doc, $structure);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-cols: 2', $style);
$this->assertStringContainsString('--luxtools-grouping-gap: 0', $style);
$this->assertStringContainsString('--luxtools-grouping-justify: start', $style);
$this->assertStringContainsString('--luxtools-grouping-align: start', $style);
}
/**
* Grouping wrapper should accept custom flex layout and gap.
*/
public function test_grouping_custom_flex()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping layout="flex" gap="8px">'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$structure = [
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
'div.luxtools-grouping .luxtools-imagebox' => 2,
];
$this->structureCheck($doc, $structure);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-gap: 8px', $style);
}
/**
* Grouping wrapper should accept justify and align controls.
*/
public function test_grouping_justify_and_align()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping layout="flex" justify="space-between" align="center">'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-justify: space-between', $style);
$this->assertStringContainsString('--luxtools-grouping-align: center', $style);
}
/**
* Unknown grouping attributes should render a warning string.
*/
public function test_grouping_unknown_option_warning()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping gpa="0.5rem">'
. '{{image>' . $imagePath . '|One|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$this->assertStringContainsString('[grouping: unknown option(s): gpa]', $xhtml);
}
/**
* Ensure the built-in file endpoint includes the host page id so file.php can
* enforce per-page ACL.

View File

@@ -422,6 +422,31 @@ html.luxtools-noscroll body {
}
}
/* ========================================================================
* Grouping wrapper (compact image layout container)
* ======================================================================== */
.luxtools-grouping {
display: grid;
grid-template-columns: repeat(var(--luxtools-grouping-cols, 2), minmax(0, 1fr));
gap: var(--luxtools-grouping-gap, 0);
justify-content: var(--luxtools-grouping-justify, start);
align-items: var(--luxtools-grouping-align, start);
}
.luxtools-grouping.luxtools-grouping--flex {
display: flex;
flex-wrap: wrap;
gap: var(--luxtools-grouping-gap, 0);
}
/* Let the grouping layout fully control item placement. */
.luxtools-grouping .luxtools-imagebox {
float: none;
clear: none;
margin: 0;
}
/* ========================================================================
* Imagebox (Wikipedia-style image with caption)
* ======================================================================== */

282
syntax/grouping.php Normal file
View File

@@ -0,0 +1,282 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Grouping wrapper syntax.
*
* Wraps multiple blocks (typically {{image>...}}) and applies compact layout
* without adding visual box styling of its own.
*
* Syntax:
* <grouping layout="flex" gap="0" justify="start" align="start">
* {{image>...}}
* {{image>...}}
* </grouping>
*/
class syntax_plugin_luxtools_grouping extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'container';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
// Slightly after image syntax
return 316;
}
/** @inheritdoc */
public function getAllowedTypes()
{
return ['container', 'substition', 'protected', 'disabled', 'formatting'];
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addEntryPattern('<grouping(?:\s+[^>]*)?>(?=.*</grouping>)', $mode, 'plugin_luxtools_grouping');
}
/** @inheritdoc */
public function postConnect()
{
$this->Lexer->addExitPattern('</grouping>', 'plugin_luxtools_grouping');
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
if ($state === DOKU_LEXER_ENTER) {
$parsed = $this->parseOpeningTag($match);
return [
'state' => $state,
'params' => $parsed['params'],
'unknown' => $parsed['unknown'],
];
}
if ($state === DOKU_LEXER_UNMATCHED) {
return [
'state' => $state,
'text' => $match,
];
}
return [
'state' => $state,
];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if (!is_array($data) || !isset($data['state'])) {
return false;
}
$state = (int)$data['state'];
if ($format !== 'xhtml') {
if ($state === DOKU_LEXER_UNMATCHED && isset($data['text'])) {
$renderer->cdata((string)$data['text']);
}
return true;
}
if (!($renderer instanceof Doku_Renderer_xhtml)) {
return true;
}
if ($state === DOKU_LEXER_ENTER) {
$params = isset($data['params']) && is_array($data['params']) ? $data['params'] : $this->getDefaultParams();
$unknown = isset($data['unknown']) && is_array($data['unknown']) ? $data['unknown'] : [];
$layout = ($params['layout'] === 'flex') ? 'flex' : 'grid';
$cols = (int)$params['cols'];
if ($cols < 1) {
$cols = 2;
}
$gap = (string)$params['gap'];
if (!$this->isValidCssLength($gap)) {
$gap = '0';
}
$justify = (string)$params['justify'];
if (!$this->isValidJustify($justify)) {
$justify = 'start';
}
$align = (string)$params['align'];
if (!$this->isValidAlign($align)) {
$align = 'start';
}
$renderer->doc .= '<div class="luxtools-grouping luxtools-grouping--' . hsc($layout) . '"'
. ' style="--luxtools-grouping-cols: ' . $cols
. '; --luxtools-grouping-gap: ' . hsc($gap)
. '; --luxtools-grouping-justify: ' . hsc($justify)
. '; --luxtools-grouping-align: ' . hsc($align)
. ';">';
if ($unknown !== []) {
$renderer->doc .= '<span class="luxtools-grouping-warning">'
. hsc('[grouping: unknown option(s): ' . implode(', ', $unknown) . ']')
. '</span>';
}
return true;
}
if ($state === DOKU_LEXER_UNMATCHED) {
if (isset($data['text'])) {
$renderer->cdata((string)$data['text']);
}
return true;
}
if ($state === DOKU_LEXER_EXIT) {
$renderer->doc .= '</div>';
return true;
}
return true;
}
/**
* Parse opening <grouping ...> tag attributes.
*
* Supports attribute style only, e.g.
* layout="grid" cols="3" gap="8px" justify="center" align="stretch".
* Unknown or invalid values are ignored and defaults are used.
*
* @param string $match
* @return array{params:array{layout:string,cols:int,gap:string,justify:string,align:string},unknown:array<int,string>}
*/
protected function parseOpeningTag(string $match): array
{
$params = $this->getDefaultParams();
$unknown = [];
if (!preg_match('/^<grouping\b(.*?)>$/is', $match, $tagMatch)) {
return ['params' => $params, 'unknown' => $unknown];
}
$attrPart = (string)$tagMatch[1];
if ($attrPart === '') {
return ['params' => $params, 'unknown' => $unknown];
}
if (preg_match_all('/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*(["\'])(.*?)\2/s', $attrPart, $attrMatches, PREG_SET_ORDER)) {
foreach ($attrMatches as $item) {
$name = strtolower(trim((string)$item[1]));
$value = trim((string)$item[3]);
if ($name === 'layout') {
$value = strtolower($value);
if (in_array($value, ['grid', 'flex'], true)) {
$params['layout'] = $value;
}
continue;
}
if ($name === 'cols') {
if (preg_match('/^\d+$/', $value)) {
$cols = (int)$value;
if ($cols > 0) {
$params['cols'] = min($cols, 12);
}
}
continue;
}
if ($name === 'gap') {
if ($this->isValidCssLength($value)) {
$params['gap'] = $value;
}
continue;
}
if ($name === 'justify') {
$value = strtolower($value);
if ($this->isValidJustify($value)) {
$params['justify'] = $value;
}
continue;
}
if ($name === 'align') {
$value = strtolower($value);
if ($this->isValidAlign($value)) {
$params['align'] = $value;
}
continue;
}
$unknown[] = $name;
}
}
if ($unknown !== []) {
$unknown = array_values(array_unique($unknown));
}
return ['params' => $params, 'unknown' => $unknown];
}
/**
* @return array{layout:string,cols:int,gap:string,justify:string,align:string}
*/
protected function getDefaultParams(): array
{
return [
'layout' => 'flex',
'cols' => 2,
'gap' => '0',
'justify' => 'start',
'align' => 'start',
];
}
/**
* Validate a simple CSS length token.
*
* Allows "0" and common explicit units used in docs/examples.
*/
protected function isValidCssLength(string $value): bool
{
$value = trim($value);
if ($value === '0') {
return true;
}
return (bool)preg_match('/^(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|%|vw|vh)$/', $value);
}
/**
* Validate justify-content compatible values.
*/
protected function isValidJustify(string $value): bool
{
return in_array($value, ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], true);
}
/**
* Validate align-items compatible values.
*/
protected function isValidAlign(string $value): bool
{
return in_array($value, ['start', 'center', 'end', 'stretch', 'baseline'], true);
}
}