create-prisma-php-app 4.0.0-alpha.19 → 4.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Lib\Middleware;
6
+
7
+ /**
8
+ * CORS middleware driven by .env allow-list.
9
+ *
10
+ * Env keys (CSV or JSON array for origins):
11
+ * CORS_ALLOWED_ORIGINS=["http://localhost:5173","https://*.example.com"]
12
+ * CORS_ALLOW_CREDENTIALS=true
13
+ * CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
14
+ * CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With
15
+ * CORS_EXPOSE_HEADERS=
16
+ * CORS_MAX_AGE=86400
17
+ *
18
+ * Call as early as possible (after Dotenv::load, before session_start).
19
+ */
20
+ final class CorsMiddleware
21
+ {
22
+ /** Entry point */
23
+ public static function handle(?array $overrides = null): void
24
+ {
25
+ // Not a CORS request
26
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
27
+ if ($origin === '') {
28
+ return;
29
+ }
30
+
31
+ // Resolve config (env → overrides)
32
+ $cfg = self::buildConfig($overrides);
33
+
34
+ // Not allowed? Do nothing (browser will block)
35
+ if (!self::isAllowedOrigin($origin, $cfg['allowedOrigins'])) {
36
+ return;
37
+ }
38
+
39
+ // Compute which value to send for Access-Control-Allow-Origin
40
+ // If credentials are disabled and '*' is in list, we can send '*'
41
+ $sendWildcard = (!$cfg['allowCredentials'] && self::listHasWildcard($cfg['allowedOrigins']));
42
+ $allowOriginValue = $sendWildcard ? '*' : self::normalize($origin);
43
+
44
+ // Vary for caches
45
+ header('Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers');
46
+
47
+ header('Access-Control-Allow-Origin: ' . $allowOriginValue);
48
+ if ($cfg['allowCredentials']) {
49
+ header('Access-Control-Allow-Credentials: true');
50
+ }
51
+
52
+ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
53
+ // Preflight response
54
+ $requestedHeaders = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? '';
55
+ $allowedHeaders = $cfg['allowedHeaders'] !== ''
56
+ ? $cfg['allowedHeaders']
57
+ : ($requestedHeaders ?: 'Content-Type, Authorization, X-Requested-With');
58
+
59
+ header('Access-Control-Allow-Methods: ' . $cfg['allowedMethods']);
60
+ header('Access-Control-Allow-Headers: ' . $allowedHeaders);
61
+ if ($cfg['maxAge'] > 0) {
62
+ header('Access-Control-Max-Age: ' . (string) $cfg['maxAge']);
63
+ }
64
+
65
+ // Optional: Private Network Access preflights (Chrome)
66
+ if (!empty($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK'])) {
67
+ header('Access-Control-Allow-Private-Network: true');
68
+ }
69
+
70
+ http_response_code(204);
71
+ header('Content-Length: 0');
72
+ exit;
73
+ }
74
+
75
+ // Simple/actual request
76
+ if ($cfg['exposeHeaders'] !== '') {
77
+ header('Access-Control-Expose-Headers: ' . $cfg['exposeHeaders']);
78
+ }
79
+ }
80
+
81
+ /** Read env + normalize + apply overrides */
82
+ private static function buildConfig(?array $overrides): array
83
+ {
84
+ $allowed = self::parseList($_ENV['CORS_ALLOWED_ORIGINS'] ?? '');
85
+ $cfg = [
86
+ 'allowedOrigins' => $allowed,
87
+ 'allowCredentials' => filter_var($_ENV['CORS_ALLOW_CREDENTIALS'] ?? 'false', FILTER_VALIDATE_BOOLEAN),
88
+ 'allowedMethods' => $_ENV['CORS_ALLOWED_METHODS'] ?? 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
89
+ 'allowedHeaders' => trim($_ENV['CORS_ALLOWED_HEADERS'] ?? ''),
90
+ 'exposeHeaders' => trim($_ENV['CORS_EXPOSE_HEADERS'] ?? ''),
91
+ 'maxAge' => (int)($_ENV['CORS_MAX_AGE'] ?? 86400),
92
+ ];
93
+
94
+ if (is_array($overrides)) {
95
+ foreach ($overrides as $k => $v) {
96
+ if (array_key_exists($k, $cfg)) {
97
+ $cfg[$k] = $v;
98
+ }
99
+ }
100
+ }
101
+
102
+ // Normalize patterns
103
+ $cfg['allowedOrigins'] = array_map([self::class, 'normalize'], $cfg['allowedOrigins']);
104
+ return $cfg;
105
+ }
106
+
107
+ /** CSV or JSON array → array<string> */
108
+ private static function parseList(string $raw): array
109
+ {
110
+ $raw = trim($raw);
111
+ if ($raw === '') return [];
112
+
113
+ if ($raw[0] === '[') {
114
+ $arr = json_decode($raw, true);
115
+ if (is_array($arr)) {
116
+ return array_values(array_filter(array_map('strval', $arr), 'strlen'));
117
+ }
118
+ }
119
+ return array_values(array_filter(array_map('trim', explode(',', $raw)), 'strlen'));
120
+ }
121
+
122
+ private static function normalize(string $origin): string
123
+ {
124
+ return rtrim($origin, '/');
125
+ }
126
+
127
+ private static function isAllowedOrigin(string $origin, array $list): bool
128
+ {
129
+ $o = self::normalize($origin);
130
+
131
+ foreach ($list as $pattern) {
132
+ $p = self::normalize($pattern);
133
+
134
+ // literal "*"
135
+ if ($p === '*') return true;
136
+
137
+ // allow literal "null" for file:// or sandboxed if explicitly listed
138
+ if ($o === 'null' && strtolower($p) === 'null') return true;
139
+
140
+ // wildcard like https://*.example.com
141
+ if (strpos($p, '*') !== false) {
142
+ $regex = '/^' . str_replace('\*', '[^.]+', preg_quote($p, '/')) . '$/i';
143
+ if (preg_match($regex, $o)) return true;
144
+ } else {
145
+ if (strcasecmp($p, $o) === 0) return true;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+
151
+ private static function listHasWildcard(array $list): bool
152
+ {
153
+ foreach ($list as $p) {
154
+ if (trim($p) === '*') return true;
155
+ }
156
+ return false;
157
+ }
158
+ }
@@ -0,0 +1,32 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Lib\PHPX;
6
+
7
+ use Lib\PHPX\PHPX;
8
+
9
+ class Fragment extends PHPX
10
+ {
11
+ /** @property ?string $as = div|span|section|article|nav|header|footer|main|aside */
12
+ public ?string $as = null;
13
+ public ?string $class = '';
14
+
15
+ public function __construct(array $props = [])
16
+ {
17
+ parent::__construct($props);
18
+ }
19
+
20
+ public function render(): string
21
+ {
22
+ if ($this->as !== null) {
23
+ $attributes = $this->getAttributes();
24
+ $class = $this->getMergeClasses($this->class);
25
+ $classAttr = $class ? "class=\"{$class}\"" : '';
26
+
27
+ return "<{$this->as} {$classAttr} {$attributes}>{$this->children}</{$this->as}>";
28
+ }
29
+
30
+ return $this->children;
31
+ }
32
+ }
@@ -26,9 +26,19 @@ class PHPX implements IPHPX
26
26
  */
27
27
  protected array $attributesArray = [];
28
28
 
29
+ /**
30
+ * @var array|null Component hierarchy set during rendering
31
+ */
32
+ protected static ?array $currentHierarchy = null;
33
+
34
+ /**
35
+ * @var string|null Current component ID
36
+ */
37
+ protected static ?string $currentComponentId = null;
38
+
29
39
  /**
30
40
  * Constructor to initialize the component with the given properties.
31
- *
41
+ *
32
42
  * @param array<string, mixed> $props Optional properties to customize the component.
33
43
  */
34
44
  public function __construct(array $props = [])
@@ -37,6 +47,112 @@ class PHPX implements IPHPX
37
47
  $this->children = $props['children'] ?? '';
38
48
  }
39
49
 
50
+ /**
51
+ * Convert a property name to a JavaScript variable path.
52
+ *
53
+ * This method generates a JavaScript variable path for the given property name,
54
+ * based on the current component hierarchy. It is used to access properties in
55
+ * the JavaScript context of the component.
56
+ *
57
+ * @param string $name The property name to convert.
58
+ * @return string The JavaScript variable path for the property.
59
+ */
60
+ protected function propsAsVar(string $name): string
61
+ {
62
+ if (empty($name)) {
63
+ return '';
64
+ }
65
+
66
+ $hierarchy = $this->getComponentHierarchy();
67
+
68
+ if (count($hierarchy) > 1 && end($hierarchy) === self::$currentComponentId) {
69
+ array_pop($hierarchy);
70
+ }
71
+
72
+ return "pphp.props." . implode('.', $hierarchy) . ".{$name}";
73
+ }
74
+
75
+ /**
76
+ * Emit a client-side dispatch call.
77
+ *
78
+ * @param non-empty-string|null $name Reactive key (e.g. "myVar", "voucher.total", or "app.s9ggniz.myVar").
79
+ * @param string $value Raw JS inserted verbatim (variable, expression, or pre-JSON-encoded value).
80
+ * @param array{
81
+ * scope?: 'current'|'parent'|'root'|string[]
82
+ * } $options Dispatch options (defaults to ['scope' => 'current']).
83
+ *
84
+ * @return string JavaScript snippet like: pphp.dispatchEvent("myVar", 123, {"scope":"current"});
85
+ */
86
+ protected function dispatchEvent(?string $name, string $value, array $options = []): string
87
+ {
88
+ $name = trim((string) $name);
89
+ if ($name === '') {
90
+ return '';
91
+ }
92
+
93
+ // normalize options
94
+ $opts = $options;
95
+ if (!array_key_exists('scope', $opts)) {
96
+ $opts['scope'] = 'current';
97
+ } else {
98
+ if (is_string($opts['scope'])) {
99
+ $allowed = ['current', 'parent', 'root'];
100
+ if (!in_array($opts['scope'], $allowed, true)) {
101
+ $opts['scope'] = 'current';
102
+ }
103
+ } elseif (is_array($opts['scope'])) {
104
+ $opts['scope'] = array_values(array_map('strval', $opts['scope']));
105
+ } else {
106
+ $opts['scope'] = 'current';
107
+ }
108
+ }
109
+
110
+ // safely encode name/options; value is inserted verbatim
111
+ $jsName = json_encode($name, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
112
+ $jsOpts = json_encode($opts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
113
+
114
+ return "pphp.dispatchEvent({$jsName}, {$value}, {$jsOpts});";
115
+ }
116
+
117
+ /**
118
+ * Set the component hierarchy and current component ID (called by TemplateCompiler during rendering)
119
+ *
120
+ * @param array $hierarchy The component hierarchy
121
+ * @param string $currentId The current component ID
122
+ */
123
+ public static function setRenderingContext(array $hierarchy, string $currentId): void
124
+ {
125
+ self::$currentHierarchy = $hierarchy;
126
+ self::$currentComponentId = $currentId;
127
+ }
128
+
129
+ /**
130
+ * Get the parent component ID from the current hierarchy.
131
+ *
132
+ * @return string|null The parent component ID, or null if no parent exists
133
+ */
134
+ protected function getParentComponent(): ?string
135
+ {
136
+ $fullHierarchy = $this->getComponentHierarchy();
137
+
138
+ if (count($fullHierarchy) >= 2) {
139
+ return $fullHierarchy[count($fullHierarchy) - 2];
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Get the full component hierarchy as an array.
147
+ * Useful for building complete hierarchy paths for JavaScript.
148
+ *
149
+ * @return array Array of component IDs from root to current
150
+ */
151
+ protected function getComponentHierarchy(): array
152
+ {
153
+ return self::$currentHierarchy ?? ['app'];
154
+ }
155
+
40
156
  /**
41
157
  * Combines and returns the CSS classes for the component.
42
158
  *
@@ -68,7 +184,7 @@ class PHPX implements IPHPX
68
184
  unset($chunk);
69
185
 
70
186
  $merged = PrismaPHPSettings::$option->tailwindcss
71
- ? TwMerge::mergeClasses(...$all)
187
+ ? TwMerge::merge(...$all)
72
188
  : $this->mergeClasses(...$all);
73
189
 
74
190
  return str_replace(array_keys($expr), array_values($expr), $merged);
@@ -118,9 +234,7 @@ class PHPX implements IPHPX
118
234
  array_flip(array_merge($reserved, $exclude))
119
235
  );
120
236
 
121
- foreach ($params as $k => $v) {
122
- $props[$k] = $v;
123
- }
237
+ $props = array_merge($params, $props);
124
238
 
125
239
  $pairs = array_map(
126
240
  static fn($k, $v) => sprintf(
@@ -108,12 +108,14 @@ class TemplateCompiler
108
108
  );
109
109
 
110
110
  if (!isset($_SERVER['HTTP_X_PPHP_NAVIGATION'])) {
111
- $htmlContent = preg_replace(
112
- '/<body([^>]*)>/i',
113
- '<body$1 hidden>',
114
- $htmlContent,
115
- 1
116
- );
111
+ if (!PrismaPHPSettings::$option->backendOnly) {
112
+ $htmlContent = preg_replace(
113
+ '/<body([^>]*)>/i',
114
+ '<body$1 hidden>',
115
+ $htmlContent,
116
+ 1
117
+ );
118
+ }
117
119
  }
118
120
 
119
121
  $bodyClosePattern = '/(<\/body\s*>)/i';
@@ -202,9 +204,12 @@ class TemplateCompiler
202
204
 
203
205
  private static function protectInlineScripts(string $html): string
204
206
  {
205
- return preg_replace_callback(
206
- '#<script\b([^>]*?)>(.*?)</script>#is',
207
- static function ($m) {
207
+ if (stripos($html, '<script') === false) {
208
+ return $html;
209
+ }
210
+
211
+ $processScripts = static function (string $content): string {
212
+ $callback = static function (array $m): string {
208
213
  if (preg_match('/\bsrc\s*=/i', $m[1])) {
209
214
  return $m[0];
210
215
  }
@@ -233,9 +238,33 @@ class TemplateCompiler
233
238
  $code = str_replace(']]>', ']]]]><![CDATA[>', $m[2]);
234
239
 
235
240
  return "<script{$m[1]}><![CDATA[\n{$code}\n]]></script>";
236
- },
237
- $html
238
- );
241
+ };
242
+
243
+ $result = preg_replace_callback(
244
+ '#<script\b([^>]*?)>(.*?)</script>#is',
245
+ $callback,
246
+ $content
247
+ );
248
+
249
+ if ($result === null) {
250
+ $result = preg_replace_callback(
251
+ '#<script\b([^>]*?)>(.*?)</script>#is',
252
+ $callback,
253
+ $content
254
+ );
255
+
256
+ return $result ?? $content;
257
+ }
258
+
259
+ return $result;
260
+ };
261
+
262
+ if (preg_match('/^(.*?<body\b[^>]*>)(.*?)(<\/body>.*)$/is', $html, $parts)) {
263
+ [$all, $beforeBody, $body, $afterBody] = $parts;
264
+ return $beforeBody . $processScripts($body) . $afterBody;
265
+ }
266
+
267
+ return $processScripts($html);
239
268
  }
240
269
 
241
270
  public static function innerXml(DOMNode $node): string
@@ -381,23 +410,35 @@ class TemplateCompiler
381
410
  string $componentName,
382
411
  array $incomingProps
383
412
  ): string {
384
- $mapping = self::selectComponentMapping($componentName);
385
- $instance = self::initializeComponentInstance($mapping, $incomingProps);
413
+ $mapping = self::selectComponentMapping($componentName);
414
+
415
+ $baseId = 's' . base_convert(sprintf('%u', crc32($mapping['className'])), 10, 36);
416
+ $idx = self::$componentInstanceCounts[$baseId] ?? 0;
417
+ self::$componentInstanceCounts[$baseId] = $idx + 1;
418
+ $sectionId = $idx === 0 ? $baseId : "{$baseId}{$idx}";
419
+
420
+ $originalStack = self::$sectionStack;
421
+ self::$sectionStack[] = $sectionId;
422
+
423
+ PHPX::setRenderingContext($originalStack, $sectionId);
424
+
425
+ $instance = self::initializeComponentInstance($mapping, $incomingProps);
386
426
 
387
427
  $childHtml = '';
388
428
  foreach ($node->childNodes as $c) {
389
429
  $childHtml .= self::processNode($c);
390
430
  }
391
431
 
392
- $instance->children = $childHtml;
432
+ self::$sectionStack = $originalStack;
393
433
 
394
- $baseId = 's' . base_convert(sprintf('%u', crc32($mapping['className'])), 10, 36);
395
- $idx = self::$componentInstanceCounts[$baseId] ?? 0;
396
- self::$componentInstanceCounts[$baseId] = $idx + 1;
397
- $sectionId = $idx === 0 ? $baseId : "{$baseId}{$idx}";
434
+ $instance->children = trim($childHtml);
435
+
436
+ PHPX::setRenderingContext($originalStack, $sectionId);
437
+
438
+ $html = $instance->render();
439
+ $html = self::preprocessFragmentSyntax($html);
398
440
 
399
- $html = $instance->render();
400
- $fragDom = self::convertToXml($html);
441
+ $fragDom = self::convertToXml($html);
401
442
  $root = $fragDom->documentElement;
402
443
  foreach ($root->childNodes as $c) {
403
444
  if ($c instanceof DOMElement) {
@@ -407,6 +448,14 @@ class TemplateCompiler
407
448
  }
408
449
 
409
450
  $htmlOut = self::innerXml($fragDom);
451
+ $htmlOut = preg_replace_callback(
452
+ '/<([a-z0-9-]+)([^>]*)\/>/i',
453
+ fn($m) => in_array(strtolower($m[1]), self::$selfClosingTags, true)
454
+ ? $m[0]
455
+ : "<{$m[1]}{$m[2]}></{$m[1]}>",
456
+ $htmlOut
457
+ );
458
+
410
459
  if (
411
460
  str_contains($htmlOut, '{{') ||
412
461
  self::hasComponentTag($htmlOut) ||
@@ -418,6 +467,14 @@ class TemplateCompiler
418
467
  return $htmlOut;
419
468
  }
420
469
 
470
+ private static function preprocessFragmentSyntax(string $content): string
471
+ {
472
+ $content = preg_replace('/<>/', '<Fragment>', $content);
473
+ $content = preg_replace('/<\/>/', '</Fragment>', $content);
474
+
475
+ return $content;
476
+ }
477
+
421
478
  private static function selectComponentMapping(string $componentName): array
422
479
  {
423
480
  if (!isset(self::$classMappings[$componentName])) {