first commit.

This commit is contained in:
kj
2026-10-05 15:08:39 -03:00
commit b7799f9fff
93 changed files with 8966 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "htmx",
"version": "0.1.0",
"description": "Motor de vistas basado en componentes y HTMX",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"htmlComponent": {
"files": ["src/Libs/HTMLComponent.php", "src/Libs/Sanitizer"]
},
"htmx": {
"files": ["src/Libs/HTMX.php"],
"require": { "htmx:htmlComponent": ">=0.1" }
}
},
"default": ["htmlComponent", "htmx"]
}
+470
View File
@@ -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]);
}
}
+447
View File
@@ -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));
}
}