diff --git a/README.md b/README.md
index a33db50..73f7b44 100644
--- a/README.md
+++ b/README.md
@@ -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 ` ... ` to arrange multiple `{{image>...}}` entries in less vertical space.
+
+```text
+
+{{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}}
+
+
+
+{{image>/Scape/photos/1.jpg|One|220}}
+{{image>/Scape/photos/2.jpg|Two|220}}
+{{image>/Scape/photos/3.jpg|Three|220}}
+
+
+
+{{image>/Scape/photos/1.jpg|One|260}}
+{{image>/Scape/photos/2.jpg|Two|260}}
+{{image>/Scape/photos/3.jpg|Three|260}}
+
+
+
+{{image>/Scape/photos/1.jpg|One|220}}
+{{image>/Scape/photos/2.jpg|Two|220}}
+{{image>/Scape/photos/3.jpg|Three|220}}
+
+```
+
+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 ``.
+
+### 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:
diff --git a/_test/SyntaxTest.php b/_test/SyntaxTest.php
index de05207..ceee6d4 100644
--- a/_test/SyntaxTest.php
+++ b/_test/SyntaxTest.php
@@ -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 = ''
+ . '{{image>' . $imagePath . '|One|120}}'
+ . '{{image>' . $imagePath . '|Two|120}}'
+ . '';
+
+ $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 = ''
+ . '{{image>' . $imagePath . '|One|120}}'
+ . '{{image>' . $imagePath . '|Two|120}}'
+ . '';
+
+ $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 = ''
+ . '{{image>' . $imagePath . '|One|120}}'
+ . '{{image>' . $imagePath . '|Two|120}}'
+ . '';
+
+ $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 = ''
+ . '{{image>' . $imagePath . '|One|120}}'
+ . '';
+
+ $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.
diff --git a/style.css b/style.css
index dc84705..4cf5502 100644
--- a/style.css
+++ b/style.css
@@ -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)
* ======================================================================== */
diff --git a/syntax/grouping.php b/syntax/grouping.php
new file mode 100644
index 0000000..f8db942
--- /dev/null
+++ b/syntax/grouping.php
@@ -0,0 +1,282 @@
+...}}) and applies compact layout
+ * without adding visual box styling of its own.
+ *
+ * Syntax:
+ *
+ * {{image>...}}
+ * {{image>...}}
+ *
+ */
+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(']*)?>(?=.*)', $mode, 'plugin_luxtools_grouping');
+ }
+
+ /** @inheritdoc */
+ public function postConnect()
+ {
+ $this->Lexer->addExitPattern('', '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 .= '
';
+
+ if ($unknown !== []) {
+ $renderer->doc .= ''
+ . hsc('[grouping: unknown option(s): ' . implode(', ', $unknown) . ']')
+ . '';
+ }
+ 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 .= '
';
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse opening 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}
+ */
+ protected function parseOpeningTag(string $match): array
+ {
+ $params = $this->getDefaultParams();
+ $unknown = [];
+
+ if (!preg_match('/^$/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);
+ }
+}