Files
luxtools-plugin/syntax/grouping.php
2026-02-13 13:14:11 +01:00

283 lines
8.1 KiB
PHP

<?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);
}
}