View.php 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http;
  4. use Twig\Environment;
  5. use Twig\Loader\FilesystemLoader;
  6. use Twig\TwigFunction;
  7. /**
  8. * Twig 3 wrapper that keeps the historical View::render($name, $data, $layout)
  9. * signature so controllers don't change. Templates live under views/ as
  10. * *.twig and use {% extends "layout.twig" %} for inheritance — the $layout
  11. * parameter is now vestigial (only `null` is honoured, used by the bare
  12. * /sprints/{id}/present route to skip layout inheritance — but Twig's
  13. * extends handles that just by picking the right base template).
  14. */
  15. final class View
  16. {
  17. private readonly Environment $twig;
  18. public function __construct(string $viewsDir, ?string $cacheDir = null)
  19. {
  20. $loader = new FilesystemLoader($viewsDir);
  21. $this->twig = new Environment($loader, [
  22. 'cache' => $cacheDir ?? false,
  23. 'auto_reload' => (getenv('APP_ENV') ?: 'production') !== 'production',
  24. 'strict_variables' => false,
  25. 'autoescape' => 'html',
  26. ]);
  27. // fmt_days(x) — same shape used by the previous PHP templates and the
  28. // CapacityCalculator: 0 → "0", whole numbers → integer, halves → x.5.
  29. $this->twig->addFunction(new TwigFunction(
  30. 'fmt_days',
  31. static function (mixed $x): string {
  32. $n = (float) $x;
  33. if (abs($n - round($n)) < 1e-9) {
  34. return (string) (int) round($n);
  35. }
  36. return number_format($n, 1);
  37. }
  38. ));
  39. // fmt_rtb(x) — two decimals, e.g. 0.05.
  40. $this->twig->addFunction(new TwigFunction(
  41. 'fmt_rtb',
  42. static fn(mixed $x): string => number_format((float) $x, 2, '.', '')
  43. ));
  44. // pretty_json(raw) — used by audit/index.twig for the diff <pre> blocks.
  45. $this->twig->addFunction(new TwigFunction(
  46. 'pretty_json',
  47. static function (?string $raw): string {
  48. if ($raw === null || $raw === '') {
  49. return '';
  50. }
  51. try {
  52. $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
  53. } catch (\JsonException) {
  54. return $raw;
  55. }
  56. return (string) (json_encode(
  57. $v,
  58. JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
  59. ) ?: $raw);
  60. }
  61. ));
  62. // query_string(filters, drop, extra) — rebuilds a query string while
  63. // dropping one key and merging extras. Used by /audit pagination.
  64. $this->twig->addFunction(new TwigFunction(
  65. 'query_string',
  66. /**
  67. * @param array<string,scalar|null> $filters
  68. * @param array<string,scalar|null> $extra
  69. */
  70. static function (array $filters, string $drop, array $extra = []): string {
  71. $params = array_filter(
  72. array_merge($filters, $extra),
  73. static fn($v) => $v !== '' && $v !== null
  74. );
  75. unset($params[$drop]);
  76. return $params === [] ? '' : '?' . http_build_query($params);
  77. }
  78. ));
  79. }
  80. /**
  81. * Render a view and return the HTML.
  82. *
  83. * @param string $name view name; "home" → views/home.twig
  84. * @param array<string,mixed> $data
  85. * @param string|null $layout retained for back-compat; ignored
  86. * by Twig (templates use {% extends %})
  87. */
  88. public function render(string $name, array $data = [], ?string $layout = 'layout'): string
  89. {
  90. unset($layout);
  91. return $this->twig->render($name . '.twig', $data);
  92. }
  93. /** @param array<string,mixed> $data */
  94. public function renderRaw(string $name, array $data): string
  95. {
  96. return $this->twig->render($name . '.twig', $data);
  97. }
  98. /** Direct access to the Twig env (rarely needed; mostly for tests). */
  99. public function twig(): Environment
  100. {
  101. return $this->twig;
  102. }
  103. }
  104. /** Escape for HTML. Kept so legacy callers still resolve; Twig auto-escapes. */
  105. function e(mixed $v): string
  106. {
  107. if ($v === null) {
  108. return '';
  109. }
  110. return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  111. }