1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\AppScopeTrait;
8: use Atk4\Core\WarnDynamicPropertyTrait;
9: use Atk4\Data\Model;
10: use Atk4\Ui\HtmlTemplate\TagTree;
11: use Atk4\Ui\HtmlTemplate\Value as HtmlValue;
12:
13: /**
14: * @phpstan-consistent-constructor
15: */
16: class HtmlTemplate
17: {
18: use AppScopeTrait;
19: use WarnDynamicPropertyTrait;
20:
21: public const TOP_TAG = '_top';
22:
23: /** @var array<string, string|false> */
24: private static array $_realpathCache = [];
25: /** @var array<string, string|false> */
26: private static array $_filesCache = [];
27:
28: private static ?self $_parseCacheParentTemplate = null;
29: /** @var array<string, array<string, TagTree>> */
30: private static array $_parseCache = [];
31:
32: /** @var array<string, TagTree> */
33: private array $tagTrees;
34:
35: public function __construct(string $template = '')
36: {
37: $this->loadFromString($template);
38: }
39:
40: public function _hasTag(string $tag): bool
41: {
42: return isset($this->tagTrees[$tag]);
43: }
44:
45: /**
46: * @param string|list<string> $tag
47: */
48: public function hasTag($tag): bool
49: {
50: // check if all tags exist
51: if (is_array($tag)) {
52: foreach ($tag as $t) {
53: if (!$this->_hasTag($t)) {
54: return false;
55: }
56: }
57:
58: return true;
59: }
60:
61: return $this->_hasTag($tag);
62: }
63:
64: public function getTagTree(string $tag): TagTree
65: {
66: if (!isset($this->tagTrees[$tag])) {
67: throw (new Exception('Tag is not defined in template'))
68: ->addMoreInfo('tag', $tag)
69: ->addMoreInfo('template_tags', array_diff(array_keys($this->tagTrees), [self::TOP_TAG]));
70: }
71:
72: return $this->tagTrees[$tag];
73: }
74:
75: private function cloneTagTrees(array $tagTrees): array
76: {
77: $res = [];
78: foreach ($tagTrees as $k => $v) {
79: $res[$k] = $v->clone($this);
80: }
81:
82: return $res;
83: }
84:
85: public function __clone()
86: {
87: $this->tagTrees = $this->cloneTagTrees($this->tagTrees);
88: }
89:
90: /**
91: * @return static
92: */
93: public function cloneRegion(string $tag): self
94: {
95: $template = new static();
96: $template->tagTrees = $template->cloneTagTrees($this->tagTrees);
97:
98: // rename top tag tree
99: $topTagTree = $template->tagTrees[$tag];
100: unset($template->tagTrees[$tag]);
101: $template->tagTrees[self::TOP_TAG] = $topTagTree;
102: $topTag = self::TOP_TAG;
103: \Closure::bind(static function () use ($topTagTree, $topTag) {
104: $topTagTree->tag = $topTag;
105: }, null, TagTree::class)();
106:
107: // TODO prune unreachable nodes
108: // $template->rebuildTagsIndex();
109:
110: if ($this->issetApp()) {
111: $template->setApp($this->getApp());
112: }
113:
114: return $template;
115: }
116:
117: protected function _unsetFromTagTree(TagTree $tagTree, int $k): void
118: {
119: \Closure::bind(static function () use ($tagTree, $k) {
120: if ($k === array_key_last($tagTree->children)) {
121: array_pop($tagTree->children);
122: } else {
123: unset($tagTree->children[$k]);
124: }
125: }, null, TagTree::class)();
126: }
127:
128: protected function emptyTagTree(TagTree $tagTree): void
129: {
130: foreach ($tagTree->getChildren() as $k => $v) {
131: if ($v instanceof TagTree) {
132: $this->emptyTagTree($v);
133: } else {
134: $this->_unsetFromTagTree($tagTree, $k);
135: }
136: }
137: }
138:
139: /**
140: * Internal method for setting or appending content in $tag.
141: *
142: * If tag contains another tag trees, these tag trees are emptied.
143: *
144: * @param string|array<string, string>|Model $tag
145: * @param ($tag is array|Model ? never : string|null) $value
146: */
147: protected function _setOrAppend($tag, string $value = null, bool $encodeHtml = true, bool $append = false, bool $throwIfNotFound = true): void
148: {
149: if ($tag instanceof Model) {
150: if (!$encodeHtml) {
151: throw new Exception('HTML is not allowed to be dangerously set from Model');
152: }
153:
154: $tag = $this->getApp()->uiPersistence->typecastSaveRow($tag, $tag->get());
155: }
156:
157: // $tag passed as associative array [tag => value]
158: // in this case we don't throw exception if tags don't exist
159: if (is_array($tag) && $value === null) {
160: foreach ($tag as $k => $v) {
161: $this->_setOrAppend($k, $v, $encodeHtml, $append, false);
162: }
163:
164: return;
165: }
166:
167: if (!is_string($tag) || $tag === '') {
168: throw (new Exception('Tag must be non-empty string'))
169: ->addMoreInfo('tag', $tag)
170: ->addMoreInfo('value', $value);
171: }
172:
173: if ($value === null) {
174: $value = '';
175: }
176:
177: $htmlValue = new HtmlValue();
178: if ($encodeHtml) {
179: $htmlValue->set($value);
180: } else {
181: $htmlValue->dangerouslySetHtml($value);
182: }
183:
184: // set or append value
185: if (!$throwIfNotFound && !$this->hasTag($tag)) {
186: return;
187: }
188:
189: $tagTree = $this->getTagTree($tag);
190: if (!$append) {
191: $this->emptyTagTree($tagTree);
192: }
193: $tagTree->add($htmlValue);
194: }
195:
196: /**
197: * This function will replace region referred by $tag to a new content.
198: *
199: * If tag is found inside template several times, all occurrences are
200: * replaced.
201: *
202: * @param string|array<string, string>|Model $tag
203: * @param ($tag is array|Model ? never : string|null) $value
204: *
205: * @return $this
206: */
207: public function set($tag, string $value = null): self
208: {
209: $this->_setOrAppend($tag, $value, true, false);
210:
211: return $this;
212: }
213:
214: /**
215: * Same as set(), but won't generate exception for non-existing
216: * $tag.
217: *
218: * @param string|array<string, string>|Model $tag
219: * @param ($tag is array|Model ? never : string|null) $value
220: *
221: * @return $this
222: */
223: public function trySet($tag, string $value = null): self
224: {
225: $this->_setOrAppend($tag, $value, true, false, false);
226:
227: return $this;
228: }
229:
230: /**
231: * Set value of a tag to a HTML content. The value is set without
232: * encoding, so you must be sure to sanitize.
233: *
234: * @param string|array<string, string>|Model $tag
235: * @param ($tag is array|Model ? never : string|null) $value
236: *
237: * @return $this
238: */
239: public function dangerouslySetHtml($tag, string $value = null): self
240: {
241: $this->_setOrAppend($tag, $value, false, false);
242:
243: return $this;
244: }
245:
246: /**
247: * See dangerouslySetHtml() but won't generate exception for non-existing
248: * $tag.
249: *
250: * @param string|array<string, string>|Model $tag
251: * @param ($tag is array|Model ? never : string|null) $value
252: *
253: * @return $this
254: */
255: public function tryDangerouslySetHtml($tag, string $value = null): self
256: {
257: $this->_setOrAppend($tag, $value, false, false, false);
258:
259: return $this;
260: }
261:
262: /**
263: * Add more content inside a tag.
264: *
265: * @param string|array<string, string>|Model $tag
266: * @param ($tag is array|Model ? never : string|null) $value
267: *
268: * @return $this
269: */
270: public function append($tag, ?string $value): self
271: {
272: $this->_setOrAppend($tag, $value, true, true);
273:
274: return $this;
275: }
276:
277: /**
278: * Same as append(), but won't generate exception for non-existing
279: * $tag.
280: *
281: * @param string|array<string, string>|Model $tag
282: * @param ($tag is array|Model ? never : string|null) $value
283: *
284: * @return $this
285: */
286: public function tryAppend($tag, ?string $value): self
287: {
288: $this->_setOrAppend($tag, $value, true, true, false);
289:
290: return $this;
291: }
292:
293: /**
294: * Add more content inside a tag. The content is appended without
295: * encoding, so you must be sure to sanitize.
296: *
297: * @param string|array<string, string>|Model $tag
298: * @param ($tag is array|Model ? never : string|null) $value
299: *
300: * @return $this
301: */
302: public function dangerouslyAppendHtml($tag, ?string $value): self
303: {
304: $this->_setOrAppend($tag, $value, false, true);
305:
306: return $this;
307: }
308:
309: /**
310: * Same as dangerouslyAppendHtml(), but won't generate exception for non-existing
311: * $tag.
312: *
313: * @param string|array<string, string>|Model $tag
314: * @param ($tag is array|Model ? never : string|null) $value
315: *
316: * @return $this
317: */
318: public function tryDangerouslyAppendHtml($tag, ?string $value): self
319: {
320: $this->_setOrAppend($tag, $value, false, true, false);
321:
322: return $this;
323: }
324:
325: /**
326: * Empty contents of specified region. If region contains sub-hierarchy,
327: * it will be also removed.
328: *
329: * @param string|list<string> $tag
330: *
331: * @return $this
332: */
333: public function del($tag): self
334: {
335: if (is_array($tag)) {
336: foreach ($tag as $t) {
337: $this->del($t);
338: }
339:
340: return $this;
341: }
342:
343: $tagTree = $this->getTagTree($tag);
344: \Closure::bind(static function () use ($tagTree) {
345: $tagTree->children = [];
346: }, null, TagTree::class)();
347:
348: // TODO prune unreachable nodes
349: // $template->rebuildTagsIndex();
350:
351: return $this;
352: }
353:
354: /**
355: * Similar to del() but won't throw exception if tag is not present.
356: *
357: * @param string|list<string> $tag
358: *
359: * @return $this
360: */
361: public function tryDel($tag): self
362: {
363: if (is_array($tag)) {
364: foreach ($tag as $t) {
365: $this->tryDel($t);
366: }
367:
368: return $this;
369: }
370:
371: if ($this->hasTag($tag)) {
372: $this->del($tag);
373: }
374:
375: return $this;
376: }
377:
378: /**
379: * @return $this
380: */
381: public function loadFromFile(string $filename): self
382: {
383: if ($this->tryLoadFromFile($filename) !== false) {
384: return $this;
385: }
386:
387: throw (new Exception('Unable to read template from file'))
388: ->addMoreInfo('filename', $filename);
389: }
390:
391: /**
392: * Same as load(), but will not throw an exception.
393: *
394: * @return $this|false
395: */
396: public function tryLoadFromFile(string $filename)
397: {
398: // realpath() is slow on Windows, so cache it and dedup only directories
399: $filenameBase = basename($filename);
400: $filename = dirname($filename);
401: if (!isset(self::$_realpathCache[$filename])) {
402: self::$_realpathCache[$filename] = realpath($filename);
403: }
404: $filename = self::$_realpathCache[$filename];
405: if ($filename === false) {
406: return false;
407: }
408: $filename .= '/' . $filenameBase;
409:
410: if (!isset(self::$_filesCache[$filename])) {
411: $data = @file_get_contents($filename);
412: if ($data !== false) {
413: $data = preg_replace('~(?:\r\n?|\n)$~s', '', $data); // always trim end NL
414: }
415: self::$_filesCache[$filename] = $data;
416: }
417:
418: $str = self::$_filesCache[$filename];
419: if ($str === false) {
420: return false;
421: }
422:
423: $this->loadFromString($str);
424:
425: return $this;
426: }
427:
428: /**
429: * @return $this
430: */
431: public function loadFromString(string $str): self
432: {
433: $this->parseTemplate($str);
434:
435: return $this;
436: }
437:
438: protected function parseTemplateTree(array &$inputReversed, string $openedTag = null): TagTree
439: {
440: $tagTree = new TagTree($this, $openedTag ?? self::TOP_TAG);
441:
442: $chunk = array_pop($inputReversed);
443: if ($chunk !== '') {
444: $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk));
445: }
446:
447: while (($tag = array_pop($inputReversed)) !== null) {
448: $firstChar = substr($tag, 0, 1);
449: if ($firstChar === '/') { // is closing tag
450: $tag = substr($tag, 1);
451: if ($openedTag === null
452: || ($tag !== '' && $tag !== $openedTag)) {
453: throw (new Exception('Template parse error: tag was not opened'))
454: ->addMoreInfo('opened_tag', $openedTag)
455: ->addMoreInfo('tag', $tag);
456: }
457:
458: $openedTag = null;
459:
460: break;
461: }
462:
463: // is new/opening tag
464: $childTagTree = $this->parseTemplateTree($inputReversed, $tag);
465: $this->tagTrees[$tag] = $childTagTree;
466: $tagTree->addTag($tag);
467:
468: $chunk = array_pop($inputReversed);
469: if ($chunk !== null && $chunk !== '') {
470: $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk));
471: }
472: }
473:
474: if ($openedTag !== null) {
475: throw (new Exception('Template parse error: tag is not closed'))
476: ->addMoreInfo('tag', $openedTag);
477: }
478:
479: return $tagTree;
480: }
481:
482: protected function parseTemplate(string $str): void
483: {
484: $cKey = static::class . "\0" . $str;
485: if (!isset(self::$_parseCache[$cKey])) {
486: // expand self-closing tags {$tag} -> {tag}{/tag}
487: $str = preg_replace('~\{\$([\w\-:]+)\}~', '{\1}{/\1}', $str);
488:
489: $input = preg_split('~\{(/?[\w\-:]*)\}~', $str, -1, \PREG_SPLIT_DELIM_CAPTURE);
490: $inputReversed = array_reverse($input); // reverse to allow to use fast array_pop()
491:
492: $this->tagTrees = [];
493: try {
494: $this->tagTrees[self::TOP_TAG] = $this->parseTemplateTree($inputReversed);
495: $tagTrees = $this->tagTrees;
496:
497: if (self::$_parseCacheParentTemplate === null) {
498: $cKeySelfEmpty = self::class . "\0";
499: self::$_parseCache[$cKeySelfEmpty] = [];
500: try {
501: self::$_parseCacheParentTemplate = new self();
502: } finally {
503: unset(self::$_parseCache[$cKeySelfEmpty]);
504: }
505: }
506: $parentTemplate = self::$_parseCacheParentTemplate;
507:
508: \Closure::bind(static function () use ($tagTrees, $parentTemplate) {
509: foreach ($tagTrees as $tagTree) {
510: $tagTree->parentTemplate = $parentTemplate;
511: }
512: }, null, TagTree::class)();
513: self::$_parseCache[$cKey] = $tagTrees;
514: } finally {
515: $this->tagTrees = [];
516: }
517: }
518:
519: $this->tagTrees = $this->cloneTagTrees(self::$_parseCache[$cKey]);
520: }
521:
522: public function toLoadableString(string $region = self::TOP_TAG): string
523: {
524: $res = [];
525: foreach ($this->getTagTree($region)->getChildren() as $v) {
526: if ($v instanceof HtmlValue) {
527: $res[] = $v->getHtml();
528: } elseif ($v instanceof TagTree) {
529: $tag = $v->getTag();
530: $tagInnerStr = $this->toLoadableString($tag);
531: $res[] = $tagInnerStr === ''
532: ? '{$' . $tag . '}'
533: : '{' . $tag . '}' . $tagInnerStr . '{/' . $tag . '}';
534: } else {
535: throw (new Exception('Value class has no save support'))
536: ->addMoreInfo('value_class', get_class($v));
537: }
538: }
539:
540: return implode('', $res);
541: }
542:
543: public function renderToHtml(string $region = null): string
544: {
545: return $this->renderTagTreeToHtml($this->getTagTree($region ?? self::TOP_TAG));
546: }
547:
548: protected function renderTagTreeToHtml(TagTree $tagTree): string
549: {
550: $res = [];
551: foreach ($tagTree->getChildren() as $v) {
552: if ($v instanceof HtmlValue) {
553: $res[] = $v->getHtml();
554: } elseif ($v instanceof TagTree) {
555: $res[] = $this->renderTagTreeToHtml($v);
556: } elseif ($v instanceof self) {
557: $res[] = $v->renderToHtml();
558: } else {
559: throw (new Exception('Unexpected value class'))
560: ->addMoreInfo('value_class', get_class($v));
561: }
562: }
563:
564: return implode('', $res);
565: }
566: }
567: