Add grouping feature
This commit is contained in:
282
syntax/grouping.php
Normal file
282
syntax/grouping.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user