├── README.md ├── composer.json └── src └── Phpx.php /README.md: -------------------------------------------------------------------------------- 1 |

2 | <PHPX/>

3 | An intuitive DOMDocument wrapper that makes it easy to write safe valid HTML in plain PHP. 4 |

5 | 6 | --- 7 | 8 | ```php 9 | $x = new Buttress\Phpx\Phpx(); 10 | 11 | $github = 'https://github.com/buttress/phpx'; 12 | 13 | echo $x->render( 14 | $x->main(class: 'content', c: [ 15 | $x->h1(id: 'title', c: 'Hello World!'), 16 | $x->p(c: [ 17 | 'Brought to you by ', 18 | $x->a(href: $github, c: 'PHPX') 19 | ]), 20 | ]) 21 | ); 22 | ``` 23 | 24 | becomes 25 | 26 | ```html 27 |
28 |

Hello World!

29 |

30 | Brought to you by PHPX 31 |

32 |
33 | ``` 34 | 35 | ## Installation 36 | 37 | To install PHPX, use composer: 38 | 39 | ```bash 40 | composer require phpx/phpx 41 | ``` 42 | 43 | ## Usage 44 | 45 | ```php 46 | /** Basic Usage */ 47 | 48 | // foo 49 | $x->render($x->span(c: 'Foo')); 50 | 51 | // foo 52 | $x->render($x->a(href: '#', c: 'Foo')); 53 | 54 | //
Helloworld
55 | $x->render($x->div(c: [ 56 | $x->span(c: 'Hello'), 57 | $x->strong(c: 'world'), 58 | ])); 59 | 60 | // Context specific XSS protection 61 | $xss = '\'"<>"'; 62 | 63 | // '"<>" 64 | $x->render($x->span(title: $xss, c: $xss)); 65 | ``` 66 | 67 | ### Advanced Usage 68 | ```php 69 | /** 70 | * $x->if : Conditional 71 | */ 72 | 73 | // Renders either:
There are 100 items
74 | // or
No items found
75 | 76 | $total = 100; 77 | $result = $x->render( 78 | ...$x->if( 79 | $total > 0, 80 | // if 81 | fn() => $x->div(c: ['There are ', $x->strong(c: (string) $total), ' items']), 82 | // else 83 | fn() => $x->div(c: 'No items found.') 84 | ) 85 | ); 86 | 87 | 88 | /** 89 | * $x->foreach : Loop over a set of items 90 | */ 91 | 92 | $features = ['safe', 'valid', 'simple']; 93 | //
  • safe
  • valid
  • simple
  • 94 | $result = $x->render( 95 | ...$x->foreach($features, fn(string $feature) => $x->li(c: ucfirst($feature))) 96 | ); 97 | 98 | /** 99 | * $x->with : Capture a variable 100 | */ 101 | 102 | // Only run `getProduct()` when `$test` is true 103 | $result = $x->render( 104 | $x->div(c: [ 105 | ...$x->if($test, fn() => $x->with(getProduct(), fn(Product $product) => [ 106 | $x->h3(c: $product->title), 107 | $x->p(c: $product->description) 108 | ])) 109 | ]) 110 | ); 111 | 112 | /** 113 | * Mix HTML and PHPX 114 | */ 115 | ?> 116 |
    117 | render($x->h1(c: getProduct()->title)) ?> 118 | The best product around 119 |
    120 | ``` 121 | 122 | ## Related Projects 123 | - [PHPX Compile](https://github.com/buttress/phpx-compile) An experimental compiler for PHPX. Significantly reduces function calls. 124 | - [PHPX Templates](https://github.com/buttress/phpx-templates) An experimental template engine built around PHPX and PHPX Compile. 125 | 126 | ## Contributing 127 | 128 | Contributions to PHPX are always welcome! Feel free to fork the repository and submit a pull request. 129 | 130 | ## License 131 | 132 | PHPX is released under the MIT License. 133 | 134 | ## Githooks 135 | To add our githooks and run tests before commit: 136 | ```bash 137 | git config --local core.hooksPath .githooks 138 | ``` 139 | 140 | ## Support 141 | 142 | If you encounter any problems or have any questions, please open an issue on GitHub. 143 | 144 | Thanks for checking out PHPX ❤️ -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "buttress/phpx", 3 | "description": "PHP DOM but intuitive", 4 | "autoload": { 5 | "psr-4": { 6 | "Buttress\\Phpx\\": "src/" 7 | } 8 | }, 9 | "authors": [ 10 | { 11 | "name": "Korvin Szanto", 12 | "email": "me@kor.vin" 13 | } 14 | ], 15 | "minimum-stability": "stable", 16 | "license": "MIT", 17 | "require": { 18 | "php": ">8", 19 | "ext-dom": "*" 20 | }, 21 | "require-dev": { 22 | "pestphp/pest": "^2", 23 | "laravel/pint": "^1.14" 24 | }, 25 | "config": { 26 | "allow-plugins": { 27 | "pestphp/pest-plugin": true 28 | } 29 | }, 30 | "scripts": { 31 | "lint": "pint --preset per", 32 | "test:unit": "pest", 33 | "test:lint": "@lint --test", 34 | "test": [ 35 | "@test:unit", 36 | "@test:lint" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Phpx.php: -------------------------------------------------------------------------------- 1 | implementation = new DOMImplementation(); 219 | $type = $this->implementation->createDocumentType('html'); 220 | $this->dom = $this->implementation->createDocument(doctype: $type); 221 | } 222 | 223 | public function __call(string $method, array $args): DOMElement 224 | { 225 | $element = $this->dom->createElement($method); 226 | 227 | // Handle children 228 | $c = $args['c'] ?? []; 229 | if (!is_array($c)) { 230 | $c = [$c]; 231 | } 232 | $element->append(...$c); 233 | 234 | $attributes = $args['attributes'] ?? []; 235 | 236 | foreach (['data', 'aria'] as $type) { 237 | foreach ($args[$type] ?? [] as $key => $val) { 238 | $attributes[$type . '-' . (string) $key] = $val; 239 | } 240 | } 241 | 242 | unset($args['c'], $args['data'], $args['aria'], $args['attributes']); 243 | 244 | // Merge attributes array into args array 245 | foreach ($attributes as $key => $value) { 246 | $args[$key] = $value; 247 | } 248 | 249 | foreach ($args as $key => $val) { 250 | try { 251 | $element->setAttribute($key, (string) $val); 252 | } catch (\Throwable $e) { 253 | throw new \InvalidArgumentException(sprintf( 254 | "Failed setting attribute \"%s\" against element \"%s\"", 255 | $key, 256 | $method 257 | ), 0, $e); 258 | } 259 | } 260 | 261 | return $element; 262 | } 263 | 264 | public function element( 265 | string $tag, 266 | array $attributes = [], 267 | array|DOMNode|string $c = [], 268 | ): DOMElement { 269 | return $this->__call($tag, ['attributes' => $attributes, 'c' => $c]); 270 | } 271 | 272 | public function raw(string $value): DOMDocumentFragment 273 | { 274 | // This is required because a value might be an invalid HTML string like 'dom->createDocumentFragment(); 277 | if ($fragment === false) { 278 | throw new \RuntimeException('Unable to create DOM Fragment.'); 279 | } 280 | 281 | // Test sanitizing 282 | $sanitized = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5); 283 | if ($sanitized === $value) { 284 | $fragment->textContent = $value; 285 | } else { 286 | $fragment->textContent = $key; 287 | $this->replace[$key] = $value; 288 | } 289 | 290 | return $fragment; 291 | } 292 | 293 | public function comment(string $comment): DOMComment 294 | { 295 | return $this->dom->createComment($comment); 296 | } 297 | 298 | /** 299 | * @template T of mixed 300 | * @template TKey of array-key 301 | * 302 | * @param iterable $list 303 | * @param (callable(T, TKey): list) $do 304 | * @return list 305 | */ 306 | public function foreach(iterable $list, callable $do): array 307 | { 308 | $nodes = []; 309 | foreach ($list as $key => $value) { 310 | $nodes[] = $do($value, $key); 311 | } 312 | 313 | return $nodes; 314 | } 315 | 316 | /** 317 | * @param callable(): list|DOMNode $do 318 | * @param null|callable(): list|DOMNode $else 319 | * @return list 320 | */ 321 | public function if(bool $condition, callable $do, ?callable $else = null): array 322 | { 323 | $result = []; 324 | if ($condition) { 325 | $result = $do(); 326 | } elseif ($else !== null) { 327 | $result = $else(); 328 | } 329 | 330 | if (!is_array($result)) { 331 | $result = [$result]; 332 | } 333 | 334 | return $result; 335 | } 336 | 337 | /** 338 | * @template T of mixed 339 | * @template TReturn of list|string|DOMNode 340 | * 341 | * @param T $value 342 | * @param callable(T): TReturn $callable 343 | * @return TReturn 344 | */ 345 | public function with(mixed $value, callable $callable) 346 | { 347 | return $callable($value); 348 | } 349 | 350 | protected function handleReplacements(string $result): string 351 | { 352 | return str_replace(array_keys($this->replace), array_values($this->replace), $result); 353 | } 354 | 355 | public function render(DOMNode ...$nodes): string 356 | { 357 | // If the sole node passed is an HTML element, render entire document include doctype 358 | if (count($nodes) === 1 && $nodes[0] instanceof DOMElement && $nodes[0]->tagName === 'html') { 359 | $this->dom->append($nodes[0]); 360 | $result = $this->handleReplacements($this->dom->saveHTML()); 361 | $this->dom->removeChild($nodes[0]); 362 | 363 | return $result; 364 | } 365 | 366 | $result = ''; 367 | foreach ($nodes as $node) { 368 | $result .= $this->dom->saveHTML($node); 369 | } 370 | 371 | return $this->handleReplacements($result); 372 | } 373 | } 374 | --------------------------------------------------------------------------------