diff --git a/App/pages/blog/_layout/layout.twig b/App/pages/blog/_layout/layout.twig
index 2fb2e22..ebf9a17 100644
--- a/App/pages/blog/_layout/layout.twig
+++ b/App/pages/blog/_layout/layout.twig
@@ -17,6 +17,13 @@
{% block blog_content %}{% endblock %}
+ {# Only pages whose sidecar opts in by returning a 'comments'
+ key get a thread — see /admin/docs/comments and
+ App/pages/blog/hello-world/index.php. A sidecar-less post
+ never has this key, so it's silently skipped. #}
+ {% if comments is defined %}
+ {% include '_partials/comments/thread.twig' %}
+ {% endif %}
{% endblock %}
diff --git a/App/pages/blog/comments-demo/index.php b/App/pages/blog/comments-demo/index.php
new file mode 100644
index 0000000..af0aecc
--- /dev/null
+++ b/App/pages/blog/comments-demo/index.php
@@ -0,0 +1,59 @@
+required($body, 'body', 'Enter a comment.')
+ ->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.');
+
+ if ($validator->passes()) {
+ // Same "bot gets an identical response" reasoning as the contact
+ // form — see App/pages/contact/index.php. Only the insert is
+ // skipped for spam.
+ if (!$spamGuard->isSpam(Input::post())) {
+ Comments::create($pagePath, $user['id'], $body);
+ }
+
+ return Response::redirect($pagePath);
+ }
+
+ $commentError = $validator->errors()['body'] ?? null;
+}
+
+return [
+ 'comments' => Comments::forPage($pagePath),
+ 'currentUser' => $user,
+ 'commentError' => $commentError,
+ 'renderedAt' => $spamGuard->renderedAt(),
+ 'csrfField' => Csrf::fieldName(),
+ 'csrfToken' => Csrf::token(),
+];
diff --git a/App/pages/blog/comments-demo/index.twig b/App/pages/blog/comments-demo/index.twig
new file mode 100644
index 0000000..8a2d3c2
--- /dev/null
+++ b/App/pages/blog/comments-demo/index.twig
@@ -0,0 +1,27 @@
+{% extends layout %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Comments Demo{% endblock %}
+{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %}
+
+{% block robots %}index, follow{% endblock %}
+{% block tags %}comments, meta{% endblock %}
+{% block canonical %}{{ request_path|default('/') }}{% endblock %}
+
+{% block og_type %}article{% endblock %}
+{% block og_title %}{{ block('title') }}{% endblock %}
+{% block og_description %}{{ block('description') }}{% endblock %}
+{% block og_url %}{{ block('canonical') }}{% endblock %}
+
+{% block twitter_card %}summary{% endblock %}
+{% block twitter_title %}{{ block('title') }}{% endblock %}
+{% block twitter_description %}{{ block('description') }}{% endblock %}
+
+{% block blog_content %}
+
Comments Demo
+
+ Unlike every other post under App/pages/blog/, this one has its own index.php sidecar — App/pages/blog/comments-demo/index.php — which reads and writes a comments table via Lib\Comments and returns a comments key in its context. App/pages/blog/_layout/layout.twig only includes the comment-thread partial (novaconium/pages/_partials/comments/thread.twig) when that key is present, so this is the only post here with a thread below.
+
+ Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see {{ icons.book() }}Static caching) — an explicit, per-page tradeoff you accept the moment a page needs comments. See {{ icons.users() }}Comments for the full write-up of Lib\Comments, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at /admin/comments rather than a pending queue.
+{% endblock %}
diff --git a/App/pages/blog/index.php b/App/pages/blog/index.php
index 1294796..56f76c9 100644
--- a/App/pages/blog/index.php
+++ b/App/pages/blog/index.php
@@ -46,5 +46,11 @@ return [
'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.',
'published' => '2026-07-15',
],
+ [
+ 'slug' => 'comments-demo',
+ 'title' => 'Comments Demo',
+ 'excerpt' => 'A worked example of Lib\\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.',
+ 'published' => '2026-07-15',
+ ],
],
];
diff --git a/App/pages/blog/novaconium-features/index.twig b/App/pages/blog/novaconium-features/index.twig
index 02194af..fb941b6 100644
--- a/App/pages/blog/novaconium-features/index.twig
+++ b/App/pages/blog/novaconium-features/index.twig
@@ -36,6 +36,7 @@
Access control — assign a page (or a section, one line per page) to a user or group from its sidecar: Access::require('group:members') returns null or a ready-made Response (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.
Draft pages — list a route under draft_routes in App/config.php to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.
Media manager — /admin/media, an upload/browse/delete UI for files under public/uploads/, covered by the existing /admin/* auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.
+ Comments — Lib\Comments, a reusable comment thread any page can attach to itself via its own sidecar (see App/pages/blog/comments-demo/). Tied to real logged-in accounts, not anonymous name/email fields; auto-approved on submission with after-the-fact hide/delete moderation at /admin/comments.
Dark/light theme toggle — a nav button flips a data-theme attribute (persisted to localStorage) that swaps every color via CSS custom properties.
Self-hosted spam prevention & form validation — Lib\SpamGuard (honeypot + submission-timing check, no external CAPTCHA), Lib\FormValidator, and Lib\Validate, demonstrated on the contact form.
Form security by default — Lib\Input (cleaning accessor for $_POST/$_GET) and Lib\Csrf (standalone session-token CSRF protection), wired into the contact form and every admin form.
diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md
index 79bf02c..eb9fd4d 100644
--- a/novaconium/ISSUES.md
+++ b/novaconium/ISSUES.md
@@ -78,34 +78,6 @@ Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
-### In-house comments
-
-- **Type:** Feature
-- **Status:** Backlog
-- **Priority:** Medium
-- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
-- **Added:** 2026-07-14
-
-A self-hosted comments library — no third-party service (Disqus,
-Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
-to any page, not just blog posts, the same way `Lib\SpamGuard`/
-`Lib\FormValidator` are reusable across any form rather than hardcoded to
-the contact page. Comments tied to a real user account rather than
-anonymous name/email fields, which is why this rides on Admin login &
-user management rather than SQLite groundwork alone — needs that
-feature's user store to exist first. Likely a `comments` table
-(route/user/body/created_at/approved or similar — a migration under
-`App/migrations/`, following the two-root convention documented in
-`/admin/docs/database`) plus a small set of sidecar-callable methods
-(list comments for a route, submit one, moderate one). Needs a decision
-on moderation model (auto-approve vs. admin-approval queue, reusing the
-`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
-`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
-mechanism, consistent with how CSRF protection already works — see
-`/admin/docs/sidecars`'s "Form security" section for the existing
-input-cleaning/CSRF/spam-prevention layers a comment form should compose
-the same way the contact form does).
-
### Ecommerce functionality
- **Type:** Feature
@@ -158,6 +130,58 @@ _Nothing yet._
## Done
+### In-house comments
+
+- **Type:** Feature
+- **Status:** Done
+- **Priority:** Medium
+- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
+- **Added:** 2026-07-14
+- **Shipped:** 2026-07-15
+
+A self-hosted comment thread — no third-party service (Disqus, Commento,
+etc.) — `Lib\Comments` (`novaconium/lib/Comments.php`), a plain static
+class any sidecar can call to attach comments to any page, not just blog
+posts, the same way `Lib\SpamGuard`/`Lib\FormValidator` are reusable
+across any form rather than hardcoded to the contact page. Comments are
+tied to a real logged-in account, never anonymous name/email fields —
+`App\AdminAuth::currentUser()` already excludes disabled and unverified
+accounts (see Email verification below), so there's nothing further to
+check before accepting a comment from whoever it returns.
+
+**Framework-level, not `App/migrations/`.** The original backlog draft
+sketched the migration under `App/migrations/`, but comments — like Admin
+auth and Media manager — is reusable framework functionality, not project
+content, so the migration (`novaconium/migrations/0004_create_comments.sql`),
+the class, and the moderation UI (`novaconium/pages/admin/comments/`) all
+live under `novaconium/`, consistent with every other feature of this
+shape.
+
+**Moderation model: auto-approve, then moderate.** A comment is visible
+the instant it's posted — no pending queue — since only a verified
+account can post one at all, there's no anonymous-spam vector to pre-vet
+against. An admin can hide (excluded at the query level, not just
+visually) or permanently delete any comment after the fact at
+`/admin/comments`, mirroring how `/admin/users` disables rather than
+pre-vets accounts. Submission still runs through `Lib\SpamGuard`'s
+honeypot/timing check and `Lib\Csrf`, same as the contact form.
+
+**A page needs its own sidecar to use it.** This codebase has no
+client-side JS/fetch anywhere — every dynamic feature is a plain
+server-rendered POST form — so a comment thread is no different: giving a
+page a sidecar is what excludes it from the static HTML cache (only
+sidecar-less pages are ever cached), an explicit per-page tradeoff the
+same as `/admin/media` and `/search`. `App/pages/blog/comments-demo/` is
+the one post under `App/pages/blog/` with a sidecar, added specifically
+to demonstrate the pattern without disturbing the other posts (several of
+which reference `hello-world` by name as the canonical *sidecar-less*
+example — retrofitting it instead would have made those references
+wrong). The reusable Twig half is
+`novaconium/pages/_partials/comments/thread.twig` — a `_`-prefixed,
+never-routable partial, included with `{% if comments is defined %}` so
+a layout shared by comment-enabled and sidecar-less pages alike can
+include it unconditionally. Documented at `/admin/docs/comments`.
+
### Email verification for user accounts
- **Type:** Feature
diff --git a/novaconium/lib/Comments.php b/novaconium/lib/Comments.php
new file mode 100644
index 0000000..f9e6138
--- /dev/null
+++ b/novaconium/lib/Comments.php
@@ -0,0 +1,84 @@
+>
+ */
+ public static function forPage(string $pagePath): array
+ {
+ return Db::query(
+ 'SELECT comments.id, comments.body, comments.created_at, users.username ' .
+ 'FROM comments JOIN users ON users.id = comments.user_id ' .
+ 'WHERE comments.page_path = ? AND comments.is_hidden = 0 ' .
+ 'ORDER BY comments.created_at ASC',
+ [$pagePath]
+ )->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ public static function create(string $pagePath, int $userId, string $body): void
+ {
+ Db::query(
+ 'INSERT INTO comments (page_path, user_id, body, is_hidden, created_at) VALUES (?, ?, ?, 0, ?)',
+ [$pagePath, $userId, $body, gmdate('Y-m-d\TH:i:s\Z')]
+ );
+ }
+
+ public static function setHidden(int $id, bool $hidden): void
+ {
+ Db::query('UPDATE comments SET is_hidden = ? WHERE id = ?', [$hidden ? 1 : 0, $id]);
+ }
+
+ public static function delete(int $id): void
+ {
+ Db::query('DELETE FROM comments WHERE id = ?', [$id]);
+ }
+
+ /**
+ * Every comment, hidden or not, newest first — for the /admin/comments
+ * moderation list.
+ *
+ * @return array>
+ */
+ public static function all(): array
+ {
+ return Db::query(
+ 'SELECT comments.id, comments.page_path, comments.body, comments.created_at, comments.is_hidden, users.username ' .
+ 'FROM comments JOIN users ON users.id = comments.user_id ' .
+ 'ORDER BY comments.created_at DESC'
+ )->fetchAll(\PDO::FETCH_ASSOC);
+ }
+}
diff --git a/novaconium/migrations/0004_create_comments.sql b/novaconium/migrations/0004_create_comments.sql
new file mode 100644
index 0000000..53df21a
--- /dev/null
+++ b/novaconium/migrations/0004_create_comments.sql
@@ -0,0 +1,9 @@
+CREATE TABLE comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ page_path TEXT NOT NULL,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ body TEXT NOT NULL,
+ is_hidden INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX idx_comments_page_path ON comments(page_path);
diff --git a/novaconium/pages/_partials/comments/thread.twig b/novaconium/pages/_partials/comments/thread.twig
new file mode 100644
index 0000000..431fcff
--- /dev/null
+++ b/novaconium/pages/_partials/comments/thread.twig
@@ -0,0 +1,45 @@
+{#
+ Reusable comment thread — included from any page whose sidecar
+ returns 'comments' (Lib\Comments::forPage()), 'currentUser'
+ (App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
+ 'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
+ /admin/docs/comments for the full sidecar contract this expects, and
+ App/pages/blog/hello-world/index.php for a worked example. A page
+ with no 'comments' key never includes this at all — see the
+ surrounding {% if comments is defined %} in blog/_layout/layout.twig.
+#}
+{% import '_layout/icons.twig' as icons %}
+
+
diff --git a/novaconium/pages/admin/comments/index.php b/novaconium/pages/admin/comments/index.php
new file mode 100644
index 0000000..20f77e0
--- /dev/null
+++ b/novaconium/pages/admin/comments/index.php
@@ -0,0 +1,65 @@
+fetch(PDO::FETCH_ASSOC);
+
+ if ($target === false) {
+ Session::flash('comments_error', 'No such comment.');
+ } elseif ($action === 'hide') {
+ Comments::setHidden($id, true);
+ Session::flash('comments_notice', 'Comment hidden.');
+ } elseif ($action === 'show') {
+ Comments::setHidden($id, false);
+ Session::flash('comments_notice', 'Comment shown.');
+ } elseif ($action === 'delete') {
+ Comments::delete($id);
+ Session::flash('comments_notice', 'Comment deleted.');
+ }
+
+ return Response::redirect('/admin/comments', 303);
+}
+
+// Truncated in PHP, not with Twig's |slice — that filter calls
+// mb_substr() unconditionally, a hard mbstring dependency this project
+// deliberately avoids (see AGENTS.md).
+$truncate = static function (string $body): string {
+ $limit = 140;
+ $length = function_exists('mb_strlen') ? mb_strlen($body) : strlen($body);
+ if ($length <= $limit) {
+ return $body;
+ }
+
+ return (function_exists('mb_substr') ? mb_substr($body, 0, $limit) : substr($body, 0, $limit)) . '…';
+};
+
+$comments = Comments::all();
+foreach ($comments as &$comment) {
+ $comment['excerpt'] = $truncate($comment['body']);
+}
+unset($comment);
+
+return [
+ 'comments' => $comments,
+ 'notice' => Session::getFlash('comments_notice'),
+ 'error' => Session::getFlash('comments_error'),
+ 'csrfField' => Csrf::fieldName(),
+ 'csrfToken' => Csrf::token(),
+];
diff --git a/novaconium/pages/admin/comments/index.twig b/novaconium/pages/admin/comments/index.twig
new file mode 100644
index 0000000..4a8c61a
--- /dev/null
+++ b/novaconium/pages/admin/comments/index.twig
@@ -0,0 +1,67 @@
+{% extends layout %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Comments{% endblock %}
+
+{% block description %}Moderate comments left across the site.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block content %}
+
+ {{ icons.users() }}Comments
+
+ Every comment left via Lib\Comments, across every page — auto-approved on submission (only a verified account can post one), moderated here after the fact instead of a pending queue. Hiding a comment removes it from its page immediately; deleting it is permanent. See {{ icons.book() }}Comments.
+
+ {% if notice %}
+ {{ notice }}
+ {% endif %}
+
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+ {% if comments is empty %}
+ No comments yet.
+ {% else %}
+
+
+
+ | Page |
+ User |
+ Comment |
+ Posted |
+ Status |
+ Actions |
+
+
+
+ {% for comment in comments %}
+
+ | {{ comment.page_path }} |
+ {{ comment.username }} |
+ {{ comment.excerpt }} |
+ {{ comment.created_at }} |
+ {{ comment.is_hidden ? 'Hidden' : 'Visible' }} |
+
+
+
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig
index b33d444..8a6cae8 100644
--- a/novaconium/pages/admin/docs/_layout/layout.twig
+++ b/novaconium/pages/admin/docs/_layout/layout.twig
@@ -23,6 +23,7 @@
{{ icons.lock() }}Access control
{{ icons.lock() }}Draft pages
{{ icons.tag() }}Media manager
+ {{ icons.users() }}Comments
{{ icons.book() }}Layouts
{{ icons.book() }}Static caching
{{ icons.book() }}SEO
diff --git a/novaconium/pages/admin/docs/comments/index.twig b/novaconium/pages/admin/docs/comments/index.twig
new file mode 100644
index 0000000..176542b
--- /dev/null
+++ b/novaconium/pages/admin/docs/comments/index.twig
@@ -0,0 +1,52 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Comments{% endblock %}
+
+{% block description %}Lib\Comments — a reusable comment thread any page can attach to itself via its own sidecar.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+ Comments
+
+ Lib\Comments is a self-hosted comment thread — no third-party service (Disqus, Commento, etc.) — a plain static class any sidecar can call to attach comments to any page, not just blog posts, the same way {{ icons.email() }}Lib\SpamGuard/Lib\FormValidator are reusable across any form rather than hardcoded to the contact page. It depends on {{ icons.lock() }}admin authentication being enabled — comments are tied to a real logged-in account (App\AdminAuth::currentUser()), never anonymous name/email fields, since currentUser() already excludes disabled and unverified accounts, there's nothing further to check before accepting a comment from whoever it returns.
+
+ Auto-approve, moderate after
+
+ A comment is visible the instant it's posted — there's no pending-approval queue. Since only a verified account can post one at all, there's no anonymous-spam vector to pre-vet against, the same reasoning {{ icons.lock() }}admin authentication already applies by disabling rather than pre-vetting accounts. An admin can hide or permanently delete any comment after the fact at /admin/comments — hiding removes it from its page immediately (it's excluded at the query level, not just visually), deleting is permanent.
+
+ Attaching a thread to a page
+
+ A page needs its own sidecar to use Lib\Comments — this codebase has no client-side JS/fetch anywhere, every dynamic feature is a plain server-rendered POST form, and comments are no different. Giving a page a sidecar is exactly what excludes it from the {{ icons.book() }}static HTML cache (only sidecar-less pages are ever cached), so attaching comments to a previously sidecar-less page is an explicit, per-page tradeoff — the same one /admin/media and /search already accept. See App/pages/blog/comments-demo/index.php for a full worked example (the one post under App/pages/blog/ with a sidecar, specifically for this):
+
+ $pagePath = Comments::currentPagePath();
+$user = AdminAuth::currentUser();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!Csrf::verify(Input::post('csrf_token'))) { ... }
+ if ($user === null) { ... } // must be logged in
+ // validate $body, run it past SpamGuard, then:
+ Comments::create($pagePath, $user['id'], $body);
+ return Response::redirect($pagePath);
+}
+
+return [
+ 'comments' => Comments::forPage($pagePath),
+ 'currentUser' => $user,
+ // ...csrfField/csrfToken/renderedAt, same as any other form sidecar
+];
+
+ Then include the reusable partial, novaconium/pages/_partials/comments/thread.twig — a _-prefixed segment, so it's never itself routable (see {{ icons.link() }}Routing's reserved-segments rule), the same convention _layout/ already uses:
+
+ {% verbatim %}{% if comments is defined %}
+ {% include '_partials/comments/thread.twig' %}
+{% endif %}{% endverbatim %}
+
+ Guarding the include with comments is defined — as App/pages/blog/_layout/layout.twig does — means a layout shared by both comment-enabled and sidecar-less pages can include it unconditionally without breaking the pages that never set the key. The partial itself expects comments, currentUser, csrfField/csrfToken, and renderedAt in context — the same shape returned above — and renders the thread plus a honeypot-carrying submit form (mirroring the contact form's spam prevention, see {{ icons.email() }}Forms) when someone's logged in, or a "log in to comment" link otherwise.
+
+ Schema
+
+ Ships as a framework migration, novaconium/migrations/0004_create_comments.sql — a comments table (id, page_path, user_id, body, is_hidden, created_at) on the same default connection as users, applied automatically the first time anything touches it, no manual step. page_path is whatever Comments::currentPagePath() derives from the request (e.g. /blog/comments-demo) — free-text, not a foreign key, so a thread survives even if the page it was attached to is later restructured.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/index.twig b/novaconium/pages/admin/docs/index.twig
index b6ba560..651fa0e 100644
--- a/novaconium/pages/admin/docs/index.twig
+++ b/novaconium/pages/admin/docs/index.twig
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %}
-{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, layouts, caching, styling.{% endblock %}
+{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, comments, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
@@ -43,6 +43,7 @@
{{ icons.lock() }}Access control — assign a page or section to a user or group from its sidecar with Lib\Access; public by default, static pages always public.
{{ icons.lock() }}Draft pages — let an admin preview a page before the public can see it, reusing the same auth check.
{{ icons.tag() }}Media manager — upload, browse, and delete files under public/uploads/ from /admin/media.
+ {{ icons.users() }}Comments — Lib\Comments, a reusable comment thread any page can attach to itself via its own sidecar.
{{ icons.book() }}Layouts — pages and layouts are overridable, just like Lib\.
{{ icons.book() }}Static caching — how sidecar-less pages get served as static HTML.
{{ icons.book() }}SEO — the meta tags every page gets for free, and how to override them.
diff --git a/novaconium/pages/admin/index.twig b/novaconium/pages/admin/index.twig
index fd5aa64..e53b3d9 100644
--- a/novaconium/pages/admin/index.twig
+++ b/novaconium/pages/admin/index.twig
@@ -16,6 +16,7 @@
{{ icons.book() }}Project docs
{{ icons.tag() }}Media
{% if admin_auth_enabled %}{{ icons.users() }}Users{% endif %}
+ {% if admin_auth_enabled %}{{ icons.users() }}Comments{% endif %}
{% if admin_auth_enabled %}{{ icons.external_link() }}Logout{% endif %}
{{ icons.users() }}Comments
+ + {% if comments is empty %} +No comments yet.
+ {% else %} + {% for comment in comments %} +{{ comment.username }} — {{ comment.created_at }}
+{{ comment.body }}
+Log in to leave a comment.
+ {% endif %} +