From c091ed1371588ed0d1c871a10582830468f50aa8 Mon Sep 17 00:00:00 2001 From: luxick Date: Fri, 13 Feb 2026 13:14:11 +0100 Subject: [PATCH] Add grouping feature --- README.md | 52 +++++++- _test/SyntaxTest.php | 95 +++++++++++++++ style.css | 25 ++++ syntax/grouping.php | 282 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 syntax/grouping.php 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); + } +}