first commit.
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
use Libs\Sanitizer\SanitizerProxy;
|
||||
|
||||
/**
|
||||
* HTMLComponent - DuckBrain
|
||||
*
|
||||
* Duckbrain Library for HTMX using components.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class HTMLComponent
|
||||
{
|
||||
/**
|
||||
* __construct
|
||||
*
|
||||
* @param array $properties
|
||||
* @param string $content
|
||||
* @param string $viewPath
|
||||
* @param string $extension
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $properties = [],
|
||||
protected string $content = '',
|
||||
protected ?string $viewPath = null,
|
||||
protected string $extension = '.php',
|
||||
) {
|
||||
if (is_null($this->viewPath)) {
|
||||
if (defined('VIEWS_DIR')) {
|
||||
$this->viewPath = rtrim(VIEWS_DIR, '/') . '/';
|
||||
} else {
|
||||
$this->viewPath = ROOT_CORE . '/Views/';
|
||||
}
|
||||
} else {
|
||||
$this->viewPath = rtrim($this->viewPath, '/') . '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the component from a file.
|
||||
*
|
||||
* @param string $component
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function load(string $component): static
|
||||
{
|
||||
$componentRealName = trim(str_replace(':', '/', $component), ' :');
|
||||
if (file_exists($this->viewPath . $componentRealName . $this->extension)) {
|
||||
ob_start();
|
||||
include($this->viewPath . $componentRealName . $this->extension);
|
||||
$this->content = ob_get_clean();
|
||||
$this->parse();
|
||||
} else {
|
||||
throw new \Exception(
|
||||
'"' . $component . '" component not exists.'
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component tag token grammar. Matches an opening tag `<Name attrs>` or a
|
||||
* closing tag `</Name>` where Name is an optional leading-colon namespace
|
||||
* followed by an uppercase letter and at least one word/digit/colon char
|
||||
* (so plain lowercase HTML and single-letter tags are never treated as
|
||||
* components). Mirrors the discovery grammar the previous regex used.
|
||||
*/
|
||||
private const TAG_RE = '/<(\/)?((?::+)?[A-Z][\w1-9:]+)([^>]*)>/';
|
||||
|
||||
/**
|
||||
* Parses and processes the content in search of more components.
|
||||
*
|
||||
* Uses a single left-to-right pass over all component tags with an
|
||||
* explicit stack, so it correctly resolves balanced, same-type nested
|
||||
* components at any depth (which the previous per-name lazy regex could
|
||||
* not) and costs one pass regardless of how many distinct component
|
||||
* types the document uses.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parse(): void
|
||||
{
|
||||
$this->content = $this->parseComponents($this->content, $this->properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every component tag found in $content, rendering each with the
|
||||
* properties inherited from its ancestors merged with its own, and returns
|
||||
* the fully rendered string. Nested components are rendered bottom-up (the
|
||||
* stack assembles each frame's inner content before the frame is rendered).
|
||||
*
|
||||
* @param string $content Content that may contain component tags.
|
||||
* @param array $inherited Properties inherited from ancestor components.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function parseComponents(string $content, array $inherited): string
|
||||
{
|
||||
preg_match_all(self::TAG_RE, $content, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
if (!$tokens) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$cursor = 0;
|
||||
/** @var array<int, array{name: string, props: array, seg: array<int,string>, rawStart: int}> $stack */
|
||||
$stack = [];
|
||||
$top = -1;
|
||||
$length = strlen($content);
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$offset = $token[0][1];
|
||||
$raw = $token[0][0];
|
||||
|
||||
if ($offset > $cursor) {
|
||||
$literal = substr($content, $cursor, $offset - $cursor);
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $literal;
|
||||
} else {
|
||||
$segments[] = $literal;
|
||||
}
|
||||
}
|
||||
$cursor = $offset + strlen($raw);
|
||||
|
||||
$isClose = $token[1][0] === '/';
|
||||
$name = $token[2][0];
|
||||
|
||||
if (!$isClose) {
|
||||
$parentProperties = $top >= 0 ? $stack[$top]['props'] : $inherited;
|
||||
$ownProperties = static::parseProperties($token[3][0]);
|
||||
$stack[] = [
|
||||
'name' => $name,
|
||||
'props' => array_merge($parentProperties, $ownProperties),
|
||||
'seg' => [],
|
||||
'rawStart' => $offset,
|
||||
];
|
||||
$top++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($top >= 0 && $stack[$top]['name'] === $name) {
|
||||
$frame = array_pop($stack);
|
||||
$top--;
|
||||
$inner = implode('', $frame['seg']);
|
||||
$rendered = $this->renderComponent($name, $frame['props'], $inner);
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $rendered;
|
||||
} else {
|
||||
$segments[] = $rendered;
|
||||
}
|
||||
} else {
|
||||
// stray or mis-nested closing tag: keep it verbatim (malformed
|
||||
// markup is left unparsed rather than crashing).
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $raw;
|
||||
} else {
|
||||
$segments[] = $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($top >= 0) {
|
||||
// unbalanced opening tags: emit the remainder verbatim from the
|
||||
// first unclosed component onward.
|
||||
$segments[] = substr($content, $stack[0]['rawStart']);
|
||||
} elseif ($cursor < $length) {
|
||||
$segments[] = substr($content, $cursor);
|
||||
}
|
||||
|
||||
return implode('', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single component with the given (already inherited+merged)
|
||||
* properties and inner content. The inner content is fully resolved before
|
||||
* it reaches here; load() re-parses the view's own output so components
|
||||
* declared inside a view file keep working.
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
* @param string $inner
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function renderComponent(string $component, array $properties, string $inner): string
|
||||
{
|
||||
$instance = new static($properties);
|
||||
$instance->content = $inner;
|
||||
$instance->load($component);
|
||||
|
||||
return $instance->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string in "key='value' key2='value2'" format into an array.
|
||||
*
|
||||
* @param string $propertiesString
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseProperties(string $propertiesString): array
|
||||
{
|
||||
preg_match_all('/([\w]+)=[\'"](.+)?[\'"]/sU', $propertiesString, $matches, PREG_PATTERN_ORDER);
|
||||
$result = [];
|
||||
foreach ($matches[1] as $index => $property) {
|
||||
$result[$property] = $matches[2][$index];
|
||||
}
|
||||
preg_match_all('/([\w]+) /si', $propertiesString . ' ', $matches, PREG_PATTERN_ORDER);
|
||||
foreach ($matches[1] as $property) {
|
||||
$result[$property] = $property;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function print(): void
|
||||
{
|
||||
echo $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component.
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(string $component, array $properties = []): void
|
||||
{
|
||||
$instance = new static($properties);
|
||||
$instance->load($component);
|
||||
$instance->print();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the component as a string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a property.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function property(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->properties[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a property escaped with htmlspecialchars.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $default
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function escapedProperty(string $key, string $default = ''): string
|
||||
{
|
||||
if (isset($this->properties[$key])) {
|
||||
return SanitizerProxy::apply($this->properties[$key] ?? null);
|
||||
} else {
|
||||
return htmlspecialchars($default);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias function for the property method.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function p(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->property($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias function for the escapedProperty method.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $default
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function e(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->escapedProperty($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* with their values as an associative array.
|
||||
* If no properties are received as arguments, all existing ones are returned.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function propsToArray(...$properties): array
|
||||
{
|
||||
if (empty($properties)) {
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($properties as $property) {
|
||||
$result[$property] = $this->properties[$property] ?? null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* as HTML attributes in property="value" format.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function propsToAtts(...$properties): string
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ($properties as $property) {
|
||||
$attributes[] = $property . '="' .
|
||||
htmlspecialchars($this->property($property, '')) . '"';
|
||||
}
|
||||
|
||||
return ' ' . implode(' ', $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* as unary HTML attributes such as checked, required, etc.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function propsToUnary(...$properties): string
|
||||
{
|
||||
$result = ' ';
|
||||
foreach ($properties as $property) {
|
||||
if (isset($this->properties[$property])) {
|
||||
$result .= $property . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treats a property as a definition of a unary HTML property.
|
||||
*
|
||||
* @param string $property Unary property to look for.
|
||||
* @param mixed $result Value to return if the property is defined. If null, returns $property.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function unary(string $property, mixed $result = null): mixed
|
||||
{
|
||||
if (isset($this->properties[$property])) {
|
||||
if (isset($result)) {
|
||||
return $result;
|
||||
} else {
|
||||
return $property;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new property/attribute.
|
||||
* If a property already exists, it replaces it.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addProperty(string $key, mixed $value): void
|
||||
{
|
||||
$this->properties = array_merge($this->properties, [$key => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new properties in bulk.
|
||||
* If a property already exists, it replaces it.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addProperties(array $properties): void
|
||||
{
|
||||
$this->properties = array_merge($this->properties, $properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to return the absolute URL from a relative path.
|
||||
*
|
||||
* @param string $path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function route(string $path = '/'): string
|
||||
{
|
||||
if (defined('SITE_URL') && !empty(SITE_URL)) {
|
||||
return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function path(): string
|
||||
{
|
||||
return Router::currentPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* __get
|
||||
*
|
||||
* @param string $index
|
||||
* @return mixed
|
||||
*/
|
||||
public function __get(string $index): mixed
|
||||
{
|
||||
return SanitizerProxy::apply($this->property($index));
|
||||
}
|
||||
|
||||
/**
|
||||
* __isset
|
||||
*
|
||||
* @param string $key
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset(string $key): bool
|
||||
{
|
||||
return isset($this->properties[$key]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* HTMX - DuckBrain
|
||||
*
|
||||
* HTMX component library for DuckBrain.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class HTMX extends HTMLComponent
|
||||
{
|
||||
/**
|
||||
* Valid HTMX swap styles.
|
||||
*/
|
||||
public const SWAP_STYLES = [
|
||||
'innerHTML', 'outerHTML', 'outerSync',
|
||||
'beforebegin', 'before',
|
||||
'afterbegin', 'prepend',
|
||||
'beforeend', 'append',
|
||||
'afterend', 'after',
|
||||
'delete', 'none',
|
||||
'innerMorph', 'outerMorph',
|
||||
'textContent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid HTMX swap option keys (used as key:value after the style).
|
||||
*/
|
||||
public const SWAP_OPTIONS = [
|
||||
'swap', 'settle', 'transition', 'ignoreTitle', 'strip',
|
||||
'focusScroll', 'swapEmpty', 'scroll', 'show', 'showTarget',
|
||||
'scrollTarget', 'target',
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a component
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(
|
||||
string $component,
|
||||
array $properties = []
|
||||
): void {
|
||||
$instance = new static($properties);
|
||||
$instance->load($component);
|
||||
$instance->print();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and processes the content in search of more components
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parse(): void
|
||||
{
|
||||
$this->parseHTMX();
|
||||
parent::parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes or displays sections based on comment tags and
|
||||
* HTMX request states.
|
||||
*
|
||||
* Possible comments are:
|
||||
*
|
||||
* | Block | normal | full | partial | Shown if the request is |
|
||||
* |--------------|--------|------+---------+--------------------------------------|
|
||||
* | hx | - | + | + | any HTMX request |
|
||||
* | !hx | + | - | - | not HTMX (plain navigation) |
|
||||
* | hx-full | - | + | - | full page HTMX request |
|
||||
* | !hx-full | + | - | + | not a full HTMX request (plain + swap)|
|
||||
* | hx-partial | - | - | + | partial (targeted) HTMX request |
|
||||
* | !hx-partial | + | + | - | not a partial request (plain + full) |
|
||||
*
|
||||
* "full" includes boosted requests, which are no longer a separate
|
||||
* state (legacy): distinguish them with HTMX::isBoosted() in PHP if
|
||||
* needed.
|
||||
*
|
||||
* "!" negates a block. Nest blocks for AND, and use sibling blocks
|
||||
* with the same content for OR. Nesting two blocks with the same
|
||||
* name is not supported.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parseHTMX(): void
|
||||
{
|
||||
if (!static::isHtmx()) {
|
||||
$replacement = ['$3', '$3', '$3', '', '', ''];
|
||||
} elseif (static::isPartialRequest()) {
|
||||
$replacement = ['', '$3', '', '$3', '', '$3'];
|
||||
} else {
|
||||
$replacement = ['', '$3', '$3', '$3', '$3', ''];
|
||||
}
|
||||
|
||||
$this->content = trim(preg_replace(
|
||||
[
|
||||
'/([\t \r\n]+)?<!-- !hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- !hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-full -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- !hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-partial -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-full -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-partial -->([\t \r\n]+)?/siU',
|
||||
],
|
||||
$replacement,
|
||||
$this->content
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes empty lines.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteEmptyLines(): void
|
||||
{
|
||||
$this->content = trim(preg_replace(
|
||||
'/^[ \t]*[\r\n]+/m',
|
||||
'',
|
||||
$this->content
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is htmx or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isHtmx(): bool
|
||||
{
|
||||
return isset($_SERVER['HTTP_HX_REQUEST']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request type of the HTMX request.
|
||||
*
|
||||
* "partial" for targeted swaps, "full" for body-level or
|
||||
* hx-select requests. Empty string if the request is not HTMX.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function requestType(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_REQUEST_TYPE'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is a full page HTMX request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isFullRequest(): bool
|
||||
{
|
||||
return static::requestType() === 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is a partial (targeted) HTMX request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPartialRequest(): bool
|
||||
{
|
||||
return static::requestType() === 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to an internal relative path by sending the appropriate header
|
||||
* if it's a normal or HTMX request.
|
||||
*
|
||||
* @param string $path
|
||||
* The path relative to the base path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function redirect(string $path): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Redirect: ' . Router::basePath() . ltrim($path, '/'));
|
||||
} else {
|
||||
Router::redirect($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is Boosted or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isBoosted(): bool
|
||||
{
|
||||
return isset($_SERVER['HTTP_HX_BOOSTED']) && boolval($_SERVER['HTTP_HX_BOOSTED']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current browser URL when the HTMX request is made.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function currentURL(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_CURRENT_URL'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTMX target (if it exists).
|
||||
*
|
||||
* The HX-Target header uses the "tagName#id" format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function target(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_TARGET'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HX-Source header: the element that triggered the request,
|
||||
* in "tagName#id" format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function source(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_SOURCE'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag name of the element that triggered the request (if it exists).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sourceTag(): string
|
||||
{
|
||||
$source = static::source();
|
||||
$hash = strpos($source, '#');
|
||||
|
||||
return $hash === false ? $source : substr($source, 0, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the element that triggered the request (if it exists).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sourceId(): string
|
||||
{
|
||||
$source = static::source();
|
||||
$hash = strpos($source, '#');
|
||||
|
||||
return $hash === false ? '' : substr($source, $hash + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when it is an HTMX request, it returns the HTMX hx-swap-oob property.
|
||||
*
|
||||
* @param string $value Swap value: "true" for the default swap,
|
||||
* or a swap style with optional options
|
||||
* (e.g. "beforeend" or "outerHTML").
|
||||
* @param bool $excludeBoosted Exclude if the request is boosted (default: false)
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
|
||||
{
|
||||
if ($excludeBoosted && static::isBoosted()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($value !== 'true') {
|
||||
$value = static::validateSwap($value);
|
||||
}
|
||||
|
||||
return static::isHtmx() ? 'hx-swap-oob="' . $value . '"' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when it is an HTMX request, it returns the HTMX hx-select-oob property.
|
||||
*
|
||||
* @param string $selector
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function selectOob(string $selector): string
|
||||
{
|
||||
return static::isHtmx() ? 'hx-select-oob="' . $selector . '"' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the given content in an HTMX <hx-partial> element, which is
|
||||
* swapped into its own target alongside the main response.
|
||||
*
|
||||
* Returns an empty string on non-HTMX requests, since partials are only
|
||||
* meaningful in a swap response. Note that, if the response contains
|
||||
* only partials, the main swap is skipped unless the triggering element
|
||||
* uses the "swapEmpty:true" swap option.
|
||||
*
|
||||
* @param string $content HTML content to deliver to the target.
|
||||
* @param string $target CSS selector of the target element.
|
||||
* @param string $swap Swap style (default: innerHTML).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function partial(
|
||||
string $content,
|
||||
string $target,
|
||||
string $swap = 'innerHTML'
|
||||
): string {
|
||||
if (!static::isHtmx()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$swap = static::validateSwap($swap);
|
||||
|
||||
return '<hx-partial hx-target="' . htmlspecialchars($target)
|
||||
. '" hx-swap="' . htmlspecialchars($swap) . '">'
|
||||
. $content
|
||||
. '</hx-partial>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the browser's URL to change and adds it to the history.
|
||||
*
|
||||
* Equivalent to using the htmx hx-push-url property. When the user
|
||||
* navigates back, htmx re-requests the URL from the server and swaps it
|
||||
* into <body> (or the [hx-history-elt] element if present).
|
||||
*
|
||||
* @param string $url Relative path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function pushUrl(string $url): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Push-Url: ' . static::route($url));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the browser's URL to change without adding it to the history.
|
||||
*
|
||||
* Equivalent to using the htmx hx-replace-url property.
|
||||
*
|
||||
* @param string $url Relative path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function replaceUrl(string $url): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Replace-Url: ' . static::route($url));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the HTMX swap target.
|
||||
*
|
||||
* @param string $selector
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function retarget(string $selector): void
|
||||
{
|
||||
header('HX-Retarget: ' . $selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the HTMX swap rule.
|
||||
*
|
||||
* The value is a swap style optionally followed by swap options,
|
||||
* e.g. "outerHTML transition:true" or "delete".
|
||||
*
|
||||
* @param string $rule
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function reswap(string $rule = 'innerHTML'): void
|
||||
{
|
||||
header('HX-Reswap: ' . static::validateSwap($rule));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a HTMX swap value ("style [key:value]...").
|
||||
*
|
||||
* @param string $value
|
||||
*
|
||||
* @return string The normalized value.
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function validateSwap(string $value): string
|
||||
{
|
||||
$parts = preg_split(
|
||||
'/\s+/',
|
||||
trim($value),
|
||||
-1,
|
||||
PREG_SPLIT_NO_EMPTY
|
||||
);
|
||||
|
||||
if (empty($parts)) {
|
||||
throw new \InvalidArgumentException('Empty swap value.');
|
||||
}
|
||||
|
||||
if (!in_array($parts[0], static::SWAP_STYLES, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid swap style: ' . $parts[0]
|
||||
);
|
||||
}
|
||||
|
||||
foreach (array_slice($parts, 1) as $option) {
|
||||
$key = explode(':', $option)[0];
|
||||
if (!in_array($key, static::SWAP_OPTIONS, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid swap option: ' . $option
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an HTMX event on the frontend upon response.
|
||||
*
|
||||
* @param Neuron|string $event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function trigger(Neuron|string $event): void
|
||||
{
|
||||
if (is_string($event)) {
|
||||
header('HX-Trigger: ' . $event);
|
||||
} else {
|
||||
header('HX-Trigger: ' . json_encode($event));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Libs\Sanitizer;
|
||||
|
||||
use ArrayAccess;
|
||||
use Countable;
|
||||
use Iterator;
|
||||
|
||||
/**
|
||||
* Library for HTML sanitization of arrays.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class SanitizerArrayProxy implements ArrayAccess, Iterator, Countable
|
||||
{
|
||||
public function __construct(
|
||||
protected array &$data
|
||||
) {
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
return isset($this->data[$offset]);
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
return SanitizerProxy::apply($this->data[$offset] ?? null);
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
$this->data[$offset] = $value;
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
unset($this->data[$offset]);
|
||||
}
|
||||
|
||||
public function current(): mixed
|
||||
{
|
||||
return SanitizerProxy::apply(current($this->data));
|
||||
}
|
||||
|
||||
public function key(): mixed
|
||||
{
|
||||
return key($this->data);
|
||||
}
|
||||
|
||||
public function next(): void
|
||||
{
|
||||
next($this->data);
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
reset($this->data);
|
||||
}
|
||||
|
||||
public function valid(): bool
|
||||
{
|
||||
$key = key($this->data);
|
||||
return $key !== null && isset($this->data[$key]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Libs\Sanitizer;
|
||||
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* HTML sanitization library.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class SanitizerProxy
|
||||
{
|
||||
public function __construct(
|
||||
protected mixed &$data
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to sanitize if is a string, ignore if is a enum, number boolean or null
|
||||
* or in other case, create a proxy.
|
||||
*
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public static function apply(mixed $data = null): mixed
|
||||
{
|
||||
if ($data instanceof UnitEnum) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
return match (gettype($data)) {
|
||||
'string' => htmlspecialchars($data),
|
||||
'integer', 'double', 'boolean', 'NULL' => $data,
|
||||
'array' => new SanitizerArrayProxy($data),
|
||||
default => new static($data)
|
||||
};
|
||||
}
|
||||
|
||||
public function __set(string $name, mixed $value): void
|
||||
{
|
||||
$this->data->{$name} = $value;
|
||||
}
|
||||
|
||||
public function __unset(string $name): void
|
||||
{
|
||||
unset($this->data->{$name});
|
||||
}
|
||||
|
||||
public function __get(string $key): mixed
|
||||
{
|
||||
return $this->apply($this->data->{$key});
|
||||
}
|
||||
|
||||
public function __isset(string $key): bool
|
||||
{
|
||||
return isset($this->data->{$key});
|
||||
}
|
||||
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
return $this->apply($this->data->{$name}(...$arguments));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user