Bagian 1 meninggalkan satu kejanggalan: daftar artikel ditulis tetap di dalam templates/home.php, sehingga menambah artikel berarti menyunting template. Pada Bagian 2 ini kita memperbaikinya sekaligus membuka fitur paling penting sebuah blog: halaman detail artikel. Tiga hal baru masuk sekaligus — class pertama proyek ini (PostRepository) yang menjadi satu-satunya pihak yang tahu di mana data artikel hidup, rute parameter /post/{slug} yang menuntut pencocokan pola alih-alih kunci pasti, dan disiplin escaping output yang lebih ketat karena halaman detail kini menampilkan teks panjang. Penyimpanannya masih array dalam memori; API-nya sengaja dibentuk seperti antarmuka database agar Bagian 3 cukup mengganti isi class tanpa menyentuh pemanggilnya.
Dari Data Tetap ke Repository #
Menaruh data di template menciptakan tiga masalah yang saling menguatkan: template sulit diuji karena bercampur dengan presentasi, handler tidak bisa mengambil data yang sama untuk halaman lain, dan pergantian sumber data (kelak: SQLite) menuntut penyuntingan berkas tampilan. Pola yang memisahkannya bernama repository: satu class yang menyediakan operasi baca (dan nanti tulis) atas satu jenis data, sehingga seluruh sisa aplikasi hanya berbicara lewat method-nya.
Method yang kita butuhkan pada tahap ini tiga saja: semua() untuk seluruh artikel, terbaru(n) untuk n artikel terbaru di beranda, dan cariSlug(slug) untuk satu artikel di halaman detail. Berikut isi lengkap src/PostRepository.php:
<?php
declare(strict_types=1);
namespace App;
/**
* PostRepository — lapisan akses data artikel blog.
*
* Pada Bagian 2 penyimpanannya masih array dalam memori: data hilang saat
* proses PHP berakhir. API method di bawah sengaja dirancang seperti
* antarmuka ke database, sehingga pada Bagian 3 kita bisa mengganti
* seluruh isi class ini dengan query PDO TANPA mengubah pemanggilnya.
*/
final class PostRepository
{
/** @var list<array<string,mixed>> data artikel, urut terlama duluan */
private array $posts;
public function __construct()
{
// Data contoh — pada Bagian 3 baris ini diganti koneksi database.
$this->posts = [
[
'id' => 1,
'slug' => 'kenapa-php-masih-relevan',
'judul' => 'Kenapa PHP Masih Relevan di 2026',
'konten' => "Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang.\n\nRilis besar hadir setiap tahun, ekosistemnya matang, dan kinerjanya jauh melampaui reputasi lamanya.\n\nBagi kita, itu berarti alat yang sudah dikuasai tetap layak dipakai membangun aplikasi modern.",
'dibuat_pada' => '2026-01-03 09:15:00',
],
[
'id' => 2,
'slug' => 'membangun-router-sederhana',
'judul' => 'Membangun Router Sederhana Tanpa Framework',
'konten' => "Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string.\n\nKita buktikan dengan membangunnya sendiri dalam satu berkas kecil.\n\nHasilnya: routing yang bisa dibaca dari awal sampai akhir tanpa membaca dokumentasi framework.",
'dibuat_pada' => '2026-01-10 14:40:00',
],
[
'id' => 3,
'slug' => 'tips-aman-menampilkan-data-pengguna',
'judul' => 'Tips Aman Menampilkan Data Pengguna',
'konten' => "Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan.\n\nSatu helper kecil bernama e() menyelamatkan seluruh template Anda dari XSS.\n\nBiasakan memakainya pada setiap titik keluaran, bukan hanya pada tempat yang terlihat berisiko.",
'dibuat_pada' => '2026-01-17 20:05:00',
],
];
}
/**
* Seluruh artikel, terbaru duluan.
*
* @return list<array<string,mixed>>
*/
public function semua(): array
{
return array_values(array_reverse($this->posts));
}
/**
* N artikel terbaru.
*
* @return list<array<string,mixed>>
*/
public function terbaru(int $n = 3): array
{
return array_slice($this->semua(), 0, $n);
}
/**
* Cari satu artikel berdasarkan slug-nya.
*
* @return array<string,mixed>|null null bila slug tidak dikenal
*/
public function cariSlug(string $slug): ?array
{
foreach ($this->posts as $post) {
if ($post['slug'] === $slug) {
return $post;
}
}
return null;
}
}Empat keputusan desain di class ini akan terus dibayar sampai akhir proyek. Pertama, namespace App menempatkan class dalam package App\ — konvensi yang membuat nama class tidak bertabrakan dengan kode pihak ketiga. Kedua, kata kunci final menutup class dari pewarisan; bagian ini belum membutuhkannya, tetapi menutup pintu lebih awal memaksa perubahan lewat komposisi, bukan pewarisan yang rapuh. Ketiga, data internal disimpan urut terlama-duluan sementara semua() membaliknya — urutan masuk dan urutan tampil dipisahkan secara eksplisit, dan array_values memastikan kunci array kembali berurutan 0,1,2 setelah dibalik. Keempat, cariSlug() mengembalikan ?array — null adalah jawaban sah untuk “tidak ditemukan”, bukan alasan untuk melempar exception.
Memakai Repository dari CLI #
Karena class ini tidak menyentuh superglobal maupun echo, seluruh perilakunya bisa diperiksa dari skrip CLI biasa — inilah dividen pertama dari pemisahan data:
<?php
require __DIR__ . '/src/PostRepository.php';
use App\PostRepository;
$posts = new PostRepository();
echo "semua() : ", count($posts->semua()), " artikel\n";
echo "terbaru(2) :\n";
foreach ($posts->terbaru(2) as $post) {
echo " - ", $post['dibuat_pada'], " ", $post['judul'], "\n";
}
$post = $posts->cariSlug('membangun-router-sederhana');
echo "cariSlug('membangun-router-sederhana'):\n";
echo " judul = ", $post['judul'], "\n";
echo " paragraf awal = ", substr($post['konten'], 0, 40), "...\n";
$hilang = $posts->cariSlug('tidak-ada');
echo "cariSlug('tidak-ada') = ", $hilang === null ? 'null' : 'ditemukan', "\n";Output yang Dihasilkan:
semua() : 3 artikel
terbaru(2) :
- 2026-01-17 20:05:00 Tips Aman Menampilkan Data Pengguna
- 2026-01-10 14:40:00 Membangun Router Sederhana Tanpa Framework
cariSlug('membangun-router-sederhana'):
judul = Membangun Router Sederhana Tanpa Framework
paragraf awal = Router pada framework favorit Anda seben...
cariSlug('tidak-ada') = nullPerhatikan urutan terbaru(2): artikel tertanggal 2026-01-17 tampil sebelum 2026-01-10, persis seperti yang diharapkan pembaca blog. Beranda pada Bagian 1 menampilkan artikel dalam urutan penulisan array; kini urutan itu ditentukan data, bukan kebetulan pengetikan.
Rute Parameter /post/{slug} #
Tabel rute Bagian 1 memetakan kunci pasti ke handler. Halaman detail menuntut lebih: /post/kenapa-php-masih-relevan dan /post/membangun-router-sederhana adalah dua URL berbeda yang harus ditangani satu handler dengan nilai berbeda. Solusinya adalah regular expression dengan named group — pola #^/post/(?<slug>[a-z0-9-]+)$# menangkap satu segmen path (huruf kecil, angka, tanda hubung) sebagai parameter $cocok['slug'].
Front controller dari Bagian 1 berubah di tiga titik. Yang pertama, class App\ kini perlu dimuat otomatis, jadi autoloader kecil dipasang tepat setelah penjaga CLI:
<?php
// Autoloader kecil: App\PostRepository -> src/PostRepository.php
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$berkas = dirname(__DIR__) . '/src/'
. str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($berkas)) {
require $berkas;
}
});Autoloader ini bekerja dengan aturan sederhana: potong prefix App\, ubah sisa nama class menjadi path (PostRepository tetap, Sub\Nama menjadi Sub/Nama), lalu require berkasnya bila ada. Bandingkan jumlah barisnya dengan require manual di setiap handler — dan bayangkan bagaimana nanti ketika class-nya berjumlah tujuh.
Yang kedua, handler beranda dan handler baru untuk detail. Beranda kini mengambil datanya dari repository, dan pola pembungkusan layout yang berulang di setiap handler Bagian 1 diangkat menjadi helper halaman() — satu tempat yang tahu cara menggabungkan judul, konten, dan status menjadi respons:
<?php
use App\PostRepository;
function halamanBeranda(PostRepository $posts): array
{
$konten = render('home.php', ['posts' => $posts->terbaru(3)]);
return halaman('Beranda', $konten);
}
function halamanDetail(PostRepository $posts, string $slug): array
{
$post = $posts->cariSlug($slug);
$konten = render('post.php', ['post' => $post]);
$judul = $post['judul'] ?? 'Artikel Tidak Ditemukan';
return halaman($judul, $konten, $post === null ? 404 : 200);
}halamanDetail() memusatkan seluruh keputusan tentang artikel yang hilang: konten dirender dari template yang sama (template-lah yang tahu bentuk halaman “tidak ditemukan”), judul halaman diberi teks cadangan lewat ?? karena $post bisa null, dan status 404 atau 200 dipilih di satu tempat.
Yang ketiga, dispatch() menerima repository sebagai parameter kedua dan menambahkan satu cabang pola setelah tabel rute pasti:
<?php
use App\PostRepository;
/**
* @param array<string,mixed> $server isi $_SERVER (asli atau tiruan)
*
* @return array{0: int, 1: string} pasangan [status HTTP, badan respons]
*/
function dispatch(array $server, PostRepository $posts): array
{
$path = (string) (parse_url((string) ($server['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/');
$path = rtrim($path, '/') ?: '/';
$rute = [
'/' => 'halamanBeranda',
'/tentang' => 'halamanTentang',
];
if (isset($rute[$path])) {
$handler = $rute[$path];
return $handler($posts);
}
// Rute parameter: /post/{slug} — slug terbatas huruf kecil, angka, hubung.
if (preg_match('#^/post/(?<slug>[a-z0-9-]+)$#', $path, $cocok) === 1) {
return halamanDetail($posts, $cocok['slug']);
}
return halamanTidakDitemukan($path);
}Urutan pemeriksaan sengaja begini: kunci pasti dicek lebih dulu karena paling murah (isset pada array), pola parameter belakangan karena preg_match lebih mahal. Batasan karakter slug pada pola bukan hiasan — slug yang memuat huruf kapital, titik, atau garis miring langsung tidak cocok dan jatuh ke 404, sehingga URL aneh tidak pernah menyentuh database kelak.
Karena dispatch() tetap murni, tabel keputusan routing bisa diuji dari CLI dengan array $_SERVER tiruan — kali ini memakai repository sungguhan dan template detail sungguhan:
<?php
require __DIR__ . '/src/PostRepository.php';
use App\PostRepository;
function render(string $template, array $data = []): string
{
extract($data, EXTR_SKIP);
ob_start();
require __DIR__ . '/templates/' . $template;
return (string) ob_get_clean();
}
/** @return array{0: int, 1: string} pasangan [status, html] */
function dispatch(array $server, PostRepository $posts): array
{
$path = (string) (parse_url((string) ($server['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/');
$path = rtrim($path, '/') ?: '/';
if (preg_match('#^/post/(?<slug>[a-z0-9-]+)$#', $path, $cocok) === 1) {
$post = $posts->cariSlug($cocok['slug']);
return [$post === null ? 404 : 200, render('post.php', ['post' => $post])];
}
return [404, ''];
}
$posts = new PostRepository();
foreach (['/post/tips-aman-menampilkan-data-pengguna', '/post/tidak-ada'] as $uri) {
[$status, $html] = dispatch(['REQUEST_URI' => $uri], $posts);
echo $uri, ' -> ', $status, "\n";
foreach (explode("\n", $html) as $baris) {
$baris = trim($baris);
if (str_starts_with($baris, '<h2>') || str_starts_with($baris, '<small>')) {
echo ' ', $baris, "\n";
}
}
}Output yang Dihasilkan:
/post/tips-aman-menampilkan-data-pengguna -> 200
<h2>Tips Aman Menampilkan Data Pengguna</h2>
<small>Diterbitkan 2026-01-17 20:05:00</small>
/post/tidak-ada -> 404
<h2>Artikel Tidak Ditemukan</h2>Slug yang dikenal menghasilkan 200 dengan judul dan tanggal artikel; slug yang tidak dikenal tetap mendapat halaman utuh — status 404 dengan pesan yang ramah, bukan layar kosong.
Template Detail Artikel #
Halaman detail butuh template sendiri karena bentuknya berbeda dari daftar: satu artikel utuh, judul besar, konten dipisah paragraf. Berikut templates/post.php:
<?php
/**
* templates/post.php — halaman detail satu artikel.
*
* Template ini menerima variabel $post (array asosiatif dari
* PostRepository::cariSlug()) atau null bila slug tidak dikenal.
* Nilai bawaan `?? null` membuat berkas ini aman dijalankan langsung
* tanpa data: halaman menampilkan pesan "tidak ditemukan" dan keluar
* dengan status sukses.
*
* Setiap nilai dinamis di-escape dengan e() sebelum dicetak; konten
* dipecah per paragraf supaya struktur HTML-nya rapi.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
$post = $post ?? null;
?>
<?php if ($post === null): ?>
<section class="post">
<h2>Artikel Tidak Ditemukan</h2>
<p>Sepertinya artikel ini sudah dipindahkan atau dihapus.</p>
<p>Kembali ke <a href="/">beranda</a> dan pilih artikel lain.</p>
</section>
<?php else: ?>
<article class="post-full">
<h2><?= e($post['judul']) ?></h2>
<small>Diterbitkan <?= e($post['dibuat_pada']) ?></small>
<?php foreach (explode("\n\n", (string) $post['konten']) as $paragraf): ?>
<?php if (trim($paragraf) !== ''): ?>
<p><?= e($paragraf) ?></p>
<?php endif; ?>
<?php endforeach; ?>
<p><a href="/">← Kembali ke beranda</a></p>
</article>
<?php endif; ?>
Template ini mencontohkan idiom template yang akan dipakai terus: cabang if/else dengan sintaks alternatif untuk dua keadaan ($post ada atau null), serta explode("\n\n", ...) yang mengubah konvensi penulisan konten — paragraf dipisah baris kosong — menjadi elemen <p> terpisah. Konvensi sederhana ini menghindari kebutuhan parser HTML di sisi penulis artikel; paragraf kosong dilewati lewat pemeriksaan trim, jadi penulis boleh lega memberi spasi ekstra.
Escape Setiap Output yang Dinamis #
Sampai di sini e() dipakai karena kebiasaan baik. Cuplikan berikut menunjukkan apa yang sebenarnya dipertaruhkan. Anggap saja judul dan konten berikut lolos masuk ke data — misalnya dari form admin yang kelak kita bangun — lalu diteruskan mentah ke template:
<?php
function render(string $template, array $data = []): string
{
extract($data, EXTR_SKIP);
ob_start();
require __DIR__ . '/templates/' . $template;
return (string) ob_get_clean();
}
// Judul dan konten "jahat" seolah berasal dari input pengguna.
$post = [
'judul' => '<script>alert("XSS")</script>',
'dibuat_pada' => '2026-01-17 20:05:00',
'konten' => 'Paragraf dengan <b>markup</b> & tanda kutip "ganda".',
];
$html = render('post.php', ['post' => $post]);
foreach (explode("\n", $html) as $baris) {
$baris = trim($baris);
if (str_starts_with($baris, '<h2>') || str_starts_with($baris, '<p>')) {
echo $baris, "\n";
}
}Output yang Dihasilkan:
<h2><script>alert("XSS")</script></h2>
<p>Paragraf dengan <b>markup</b> & tanda kutip "ganda".</p>
<p><a href="/">← Kembali ke beranda</a></p>Tag <script> tiba di browser sebagai teks <script>, bukan sebagai skrip yang dijalankan. Inilah pertahanan XSS pada sisi output: data tetap disimpan apa adanya (mentah), dan di-escape tepat saat melintasi batas menuju HTML. Membalik urutannya — menyimpan teks ter-escape lalu mencetak mentah — terlihat setara tetapi rapuh, karena data yang sudah dua kali di-escape akan tampil aneh dan data dari sumber lain mungkin belum pernah di-escape sama sekali.
Menguji dengan Web Server Bawaan #
Dengan seluruh perubahan diterapkan pada public/index.php, nyalakan server seperti biasa dan periksa alurnya lewat HTTP:
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8093 public/index.phpBeranda kini mengambil data dari repository — urutan artikel mengikuti terbaru(), bukan urutan pengetikan template:
# terminal 2
$ curl -sS http://127.0.0.1:8093/ | grep '<h3>' <h3><a href="/post/tips-aman-menampilkan-data-pengguna">Tips Aman Menampilkan Data Pengguna</a></h3>
<h3><a href="/post/membangun-router-sederhana">Membangun Router Sederhana Tanpa Framework</a></h3>
<h3><a href="/post/kenapa-php-masih-relevan">Kenapa PHP Masih Relevan di 2026</a></h3>Rute parameter terbukti bekerja lewat HTTP — status 200 dan judul halaman diambil dari data artikel:
$ curl -sS -i http://127.0.0.1:8093/post/membangun-router-sederhana | head -n 12HTTP/1.1 200 OK
Host: 127.0.0.1:8093
Date: Tue, 13 Oct 2026 21:49:46 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-type: text/html; charset=UTF-8
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">Bagian <main> halaman yang sama memperlihatkan hasil render post.php — judul, tanggal, paragraf terpisah rapi:
$ curl -sS http://127.0.0.1:8093/post/membangun-router-sederhana | sed -n '/<main/,/<\/main>/p' <main class="site-main">
<article class="post-full">
<h2>Membangun Router Sederhana Tanpa Framework</h2>
<small>Diterbitkan 2026-01-10 14:40:00</small>
<p>Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string.</p>
<p>Kita buktikan dengan membangunnya sendiri dalam satu berkas kecil.</p>
<p>Hasilnya: routing yang bisa dibaca dari awal sampai akhir tanpa membaca dokumentasi framework.</p>
<p><a href="/">← Kembali ke beranda</a></p>
</article>
</main>Dua penutup pengujian: slug yang tidak dikenal dan slug dengan huruf kapital. Yang pertama ditemukan repository tapi tidak ada (null → 404 ramah); yang kedua ditolak pola rute sebelum sempat bertanya ke data:
$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/post/tak-ada404$ curl -sS http://127.0.0.1:8093/post/tak-ada | grep '<title>' <title>Artikel Tidak Ditemukan — Blog PHP</title>$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/post/UPPER404Apa yang Berubah #
Dibandingkan Bagian 1, tahap ini menambahkan dua berkas dan mengubah satu:
src/PostRepository.php— class pertama proyek: namespaceApp,final, data artikel dalam memori, dengan APIsemua(),terbaru(n), dancariSlug(slug).templates/post.php— template detail artikel dengan dua cabang (ditemukan/tidak) dan pemecah paragraf.public/index.php— autoloaderApp\, handler menerimaPostRepositorylewat parameter, cabangpreg_matchuntuk/post/{slug}, serta beranda kini memakaiterbaru(3).
Data masih lenyap setiap request selesai karena hidup di memori. Bagian 3 mengganti isi PostRepository dengan query PDO ke SQLite — dan berkat abstraksi ini, tidak satu pun handler atau template perlu ikut berubah.
Cara Menjalankan #
Dari direktori bagian-2-post-dan-detail-artikel/ (folder bundle halaman ini):
# 1. Perilaku repository dari CLI murni
php -r 'require "src/PostRepository.php"; foreach ((new App\PostRepository())->terbaru(2) as $p) echo $p["judul"], "\n";'
# 2. Gabungkan dengan berkas Bagian 1 (public/, templates/) lalu nyalakan server
php -S 127.0.0.1:8093 public/index.php
curl -sS http://127.0.0.1:8093/post/tips-aman-menampilkan-data-pengguna
# 3. Pemeriksaan sintaks berkas baru
php -l src/PostRepository.php && php -l templates/post.phpPerintah pertama memakai php -r untuk memanggil repository tanpa berkas perantara — pola require + instansiasi + iterasi dalam satu baris. Dua berkas pendukung halaman ini, src/PostRepository.php dan templates/post.php, tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang spl_autoload_register()
, preg_match()
beserta sintaks named subpattern
, dan htmlspecialchars()
. Setelah data dan halamannya beres, kita siap memindahkan penyimpanan ke database sungguhan pada Bagian 3.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/PostRepository.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
/**
* PostRepository — lapisan akses data artikel blog.
*
* Pada Bagian 2 penyimpanannya masih array dalam memori: data hilang saat
* proses PHP berakhir. API method di bawah sengaja dirancang seperti
* antarmuka ke database, sehingga pada Bagian 3 kita bisa mengganti
* seluruh isi class ini dengan query PDO TANPA mengubah pemanggilnya.
*/
final class PostRepository
{
/** @var list<array<string,mixed>> data artikel, urut terlama duluan */
private array $posts;
public function __construct()
{
// Data contoh — pada Bagian 3 baris ini diganti koneksi database.
$this->posts = [
[
'id' => 1,
'slug' => 'kenapa-php-masih-relevan',
'judul' => 'Kenapa PHP Masih Relevan di 2026',
'konten' => "Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang.\n\nRilis besar hadir setiap tahun, ekosistemnya matang, dan kinerjanya jauh melampaui reputasi lamanya.\n\nBagi kita, itu berarti alat yang sudah dikuasai tetap layak dipakai membangun aplikasi modern.",
'dibuat_pada' => '2026-01-03 09:15:00',
],
[
'id' => 2,
'slug' => 'membangun-router-sederhana',
'judul' => 'Membangun Router Sederhana Tanpa Framework',
'konten' => "Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string.\n\nKita buktikan dengan membangunnya sendiri dalam satu berkas kecil.\n\nHasilnya: routing yang bisa dibaca dari awal sampai akhir tanpa membaca dokumentasi framework.",
'dibuat_pada' => '2026-01-10 14:40:00',
],
[
'id' => 3,
'slug' => 'tips-aman-menampilkan-data-pengguna',
'judul' => 'Tips Aman Menampilkan Data Pengguna',
'konten' => "Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan.\n\nSatu helper kecil bernama e() menyelamatkan seluruh template Anda dari XSS.\n\nBiasakan memakainya pada setiap titik keluaran, bukan hanya pada tempat yang terlihat berisiko.",
'dibuat_pada' => '2026-01-17 20:05:00',
],
];
}
/**
* Seluruh artikel, terbaru duluan.
*
* @return list<array<string,mixed>>
*/
public function semua(): array
{
return array_values(array_reverse($this->posts));
}
/**
* N artikel terbaru.
*
* @return list<array<string,mixed>>
*/
public function terbaru(int $n = 3): array
{
return array_slice($this->semua(), 0, $n);
}
/**
* Cari satu artikel berdasarkan slug-nya.
*
* @return array<string,mixed>|null null bila slug tidak dikenal
*/
public function cariSlug(string $slug): ?array
{
foreach ($this->posts as $post) {
if ($post['slug'] === $slug) {
return $post;
}
}
return null;
}
}
templates/post.php
Unduh berkas<?php
/**
* templates/post.php — halaman detail satu artikel.
*
* Template ini menerima variabel $post (array asosiatif dari
* PostRepository::cariSlug()) atau null bila slug tidak dikenal.
* Nilai bawaan `?? null` membuat berkas ini aman dijalankan langsung
* tanpa data: halaman menampilkan pesan "tidak ditemukan" dan keluar
* dengan status sukses.
*
* Setiap nilai dinamis di-escape dengan e() sebelum dicetak; konten
* dipecah per paragraf supaya struktur HTML-nya rapi.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
$post = $post ?? null;
?>
<?php if ($post === null): ?>
<section class="post">
<h2>Artikel Tidak Ditemukan</h2>
<p>Sepertinya artikel ini sudah dipindahkan atau dihapus.</p>
<p>Kembali ke <a href="/">beranda</a> dan pilih artikel lain.</p>
</section>
<?php else: ?>
<article class="post-full">
<h2><?= e($post['judul']) ?></h2>
<small>Diterbitkan <?= e($post['dibuat_pada']) ?></small>
<?php foreach (explode("\n\n", (string) $post['konten']) as $paragraf): ?>
<?php if (trim($paragraf) !== ''): ?>
<p><?= e($paragraf) ?></p>
<?php endif; ?>
<?php endforeach; ?>
<p><a href="/">← Kembali ke beranda</a></p>
</article>
<?php endif; ?>