- **Templates:** `devplacepy/templates/`. Shared `templates` instance from `devplacepy.templating` - all routers import from there, do NOT create their own.
3.**Sanitize** → `DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output
4.**Code syntax highlight** → `highlight.js` on all `<pre><code>` blocks
5.**Image URLs** → standalone `.jpg/.png/.gif` URLs become `<img>` tags
6.**YouTube URLs** → `youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
7.**All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
**Sanitization is the XSS control.** Content is rendered client-side from `element.textContent`, so Jinja autoescaping does not protect it. `DOMPurify.sanitize` (vendored at `static/vendor/purify.min.js`, loaded `defer` in `base.html`) runs on the raw `marked` output before `processMedia` injects the trusted YouTube iframes, so user payloads are removed while our embeds survive. It is fail-closed: `render()` throws if `DOMPurify` is missing rather than emitting unsanitized HTML — never relax this into a `typeof` skip.
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
Elements with `data-render` attribute are auto-rendered by `Application.js` on page load. The `.rendered-content` CSS class provides table styles, code block backgrounds, and image sizing.
## CDN Libraries
Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blocking DOMContentLoaded:
**Never use `<script>` without `defer` for CDN libraries** - they block HTML parsing and cause `wait_until="domcontentloaded"` to timeout in Playwright tests.
`Application.js``initModals()` toggles the `.visible` CSS class on the modal overlay. The CSS rule `.modal-overlay.visible { display: flex; }` handles visibility:
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
-`_post_votes.html` — post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
-`_star_vote.html` — project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) — do not put a literal star in markup.
-`_post_header.html` — post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.
-`_topic_selector.html` — topic radio group. Locals: `_topics`, `_selected`.
Vote button styles live ONCE in `feed.css` (`.post-action-btn`) — never redefine them in `post.css`. Page-specific CSS goes in a `static/css/*.css` file referenced from `{% block extra_head %}`, never an inline `<style>` block.
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
**Batch queries eliminate N+1 problems.** Use `get_users_by_uids()`, `get_comment_counts_by_post_uids()`, and `get_vote_counts()` from `database.py` instead of per-row lookups in loops.
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
- **For a missing detail resource, `raise not_found("X not found")`** (`utils.py`) — it returns an `HTTPException(404)` that the global handler renders as `error.html`. Do not return a bare `HTMLResponse(..., status_code=404)`.
- **Detail pages reuse `load_detail(table, target_type, slug, user)`** (`content.py`) for item+author+comments+attachments+`star_count`+`my_vote`; list pages reuse `enrich_items(items, key, authors, extra_maps, user=...)`. Prefer these over manual per-row loading (posts/projects/gists detail and feed/gists/profile lists already do).
- **`get_current_user(request)` is cached** in `_user_cache` dict by session token (per-process, no TTL). Use this for pages viewable by both auth guests (feed, news detail, projects).
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
- Form validation uses Pydantic models in `models.py` via `Annotated[Model, Form()]` params; invalid input is caught by the global `RequestValidationError` handler in `main.py` (auth pages re-render with messages at 400, other routes redirect).
When a user uploads an image during post creation, the markdown `` is appended to the post content. The ContentRenderer then renders it as an `<img>`. All URLs are relative.
- **NEVER run tests unless specifically asked by user.** Not the full suite, not a single file - do not run any tests unless the user explicitly requests it.
- **The uvicorn server is shared for the whole session.** A test that flips a global setting (maintenance mode, registration, rate limit, session length) changes behavior for every later test, so **restore the value in a `try/finally`** — see `tests/test_operational.py`.
- **Saving through the admin form is the reliable mutation path**, not a direct DB write: the form handler runs in the server process and calls `clear_settings_cache()`, so the change takes effect immediately (a direct DB write waits out the 60s server-side cache).
- **`conftest.py`'s `app_server` seeds `rate_limit_per_minute="1000000"`** so the suite is never throttled and the settings form round-trips a safe value instead of the template's `"60"` default.
- **Rate-limit unit tests patch `m.get_int_setting`**, not the `RATE_LIMIT` constant — the constant is now only the fallback default passed to `get_int_setting`.
Notifications are created server-side in the route handlers and stored in the `notifications` table. Unread counts are cached per-process in `_unread_cache` (notifications) and `_messages_cache` (messages), both 10s TTL in `templating.py`. Mutating routes call `clear_unread_cache(uid)` / `clear_messages_cache(uid)` to invalidate.
### Live counter badges
The top-nav bell and Messages link carry `data-counter="notifications"` / `data-counter="messages"` with a child `<span data-counter-badge hidden>`. `CounterManager.js` polls `GET /notifications/counts` (returns `{"notifications", "messages"}`, zeros for guests) every 30s and on tab focus, then sets each badge's text and toggles its `hidden` attribute. Server-rendered counts seed the initial state, so the badges are correct on first paint and self-heal without a reload. Absolute badges use `.nav-badge`; inline badges (next to text links) add `.nav-badge-inline`.
| `level` | Reach a new level | `utils.py``award_xp` | `new_level > current_level` |
| `badge` | Earn any badge | `utils.py``notify_badge` | First grant only (`award_badge` returns `True`) |
`level`/`badge` notifications have `related_uid == user_uid` (self), so the notifications template renders them with the recipient's own avatar; the template is type-agnostic (driven by `message`/`actor`), so no per-type handling is needed.
The template uses `notification_groups` (list of `{label, entries}` dicts). Jinja2 note: avoid `.items` as a dict key - it clashes with Python's `dict.items()` method.
The progression engine lives in `utils.py` and is wired into the existing content-creation, vote, and follow hooks. Do NOT scatter XP/badge logic — go through these helpers.
### XP and levels (`utils.py`)
-`award_xp(user_uid, amount)` — adds XP (clamped to `>= 0`), recomputes and stores `level`, invalidates the per-process user cache (`clear_user_cache`), and on level-up fires a `level` notification plus any level-milestone badge. Returns `{"xp", "level", "leveled_up"}`.
-`level_for_xp(xp)` → `1 + max(0, xp) // LEVEL_XP` (`LEVEL_XP = 100`). `level` is stored on the user (not derived in the template) so `profile.html`'s `xp % 100` progress bar keeps working.
- XP amounts are constants in `utils.py`: `XP_POST=10`, `XP_COMMENT=2`, `XP_PROJECT=15`, `XP_GIST=5`, `XP_UPVOTE=5`, `XP_FOLLOW=5`. Awards for received upvotes/followers go to the content owner / followed user and live **inside** the existing `owner_uid != user["uid"]` guards (`votes.py`, `follow.py`).
### Badges (`utils.py`)
-`award_badge(user_uid, name)` is idempotent (returns `True` only on first grant) — reuse it; never insert into `badges` directly.
-`check_milestone_badges(user_uid)` recomputes count/star thresholds and awards + notifies any newly crossed badge. Call it after content creation and after an upvote/follow that changes the recipient's totals.
-`award_rewards(user_uid, amount, first_badge=None)` is the single entry point for the create/upvote/follow reward sequence: it awards the optional first-time badge, calls `award_xp`, then `check_milestone_badges`. Use it instead of calling the three separately (posts/projects/gists/comments/follow/votes all go through it) so milestone checks are never skipped.
- Badge catalog + display metadata is `BADGE_CATALOG` in `utils.py`, exposed to templates as the `badge_info(name)` global. Names: Member, First Post, First Comment, First Project, First Gist, Prolific (10 posts), Rising Star (25 stars), Star Author (100 stars), Popular (10 followers), Level 5, Level 10. Unknown names fall back to a default icon and the name as description.
### Rank and leaderboard (`database.py`)
-`_ranked_authors()` builds (and 60s-caches in `_authors_cache`) the full list of authors with positive total stars, ordered desc, enriched via `get_users_by_uids`. `get_top_authors(limit)`, `get_leaderboard(limit, offset)` (adds a 1-based `rank`), and `get_user_rank(user_uid)` all slice/scan this one list — keep them consistent.
-`update_target_stars()` clears `_authors_cache` so a vote is reflected on the leaderboard and profile rank immediately (also keeps integration tests deterministic).
- The leaderboard page (`/leaderboard`, `routers/leaderboard.py`) shows the **top 50 only — no pagination** (`TOP_LIMIT = 50`). It is public (`get_current_user`), highlights the current user's row (`.leaderboard-row-self`), and is listed in `sitemap.xml`.
### Backfill
`_backfill_gamification()` runs at the end of `init_db()`. It computes XP from prior activity (same amounts as above) for users still at the default `xp=0`, sets `level`, then runs `check_milestone_badges` per user. Guarded on `xp=0` so it is idempotent across restarts.
Every post card on the feed now has an inline comment form (`.feed-comment-form`) beneath the post actions. It posts to `/comments/create` like the detail page comment form. Tests must scope Post button clicks to `#create-post-modal button.btn-primary:has-text('Post')` to avoid matching the inline comment's Post button.
Post owners see an "Edit" button on the post detail page that opens `#edit-post-modal`. The edit form allows changing title, content, and topic. The POST route is `/posts/edit/{post_uid}` with ownership check. The edit modal's textarea has `id="edit-content"` - tests must scope to `.comment-form textarea[name='content']` for comment operations.
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, and delete-for-owner. The route is `GET /projects/{project_uid}` in `routers/projects.py`. The sitemap generator links to this URL (not the old `?user_uid=` query param).
## Bug Reports
A dedicated `/bugs` page with create modal for authenticated users. Uses `bug_reports` table (auto-created by `dataset`). Registered in `main.py` with prefix `/bugs`. Footer link in `base.html` under `.site-footer`.
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
The seed block in `database.py` is guarded by `if "site_settings" in tables:` — on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
### Operational settings — read sites and rules
| Setting(s) | Read at | Notes |
|-----------|---------|-------|
| `rate_limit_*` | `rate_limit_middleware` in `main.py` | `max(1, get_int_setting(...))` so `0` can't block all writes |
| `maintenance_mode` / `maintenance_message` | `maintenance_middleware` in `main.py` | Allows `/static`, `/avatar`, `/auth`, `/admin` and admins; everyone else gets `error.html` at 503 |
| `news_service_interval` | top of `NewsService.run_once` | `max(60, …)`; `BaseService._run_loop` reads `self.interval_seconds` for its sleep after each cycle, so a change applies next cycle |
| `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together |
| `registration_open` | `auth.py``signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off — `registration_open` and `maintenance_mode` use `<option value="1">`/`<optionvalue="0">` so a value is always submitted.
1. Create `devplacepy/services/your_service.py` with a class extending `BaseService`
2. Override `async def run_once(self) -> None`
3. Register in `main.py` startup event:
```python
from devplacepy.services.your_service import YourService
service_manager.register(YourService())
```
4. The service appears automatically on `/services` with log tail
### CLI
```bash
devplace news clear # Delete all news from local database
```
## Signals Category
The `signals` topic is available as a feed filter sidebar item and post topic. Added CSS variable `--topic-signals: #00bcd4` in `variables.css`, badge class in `base.css`, and dot color in `feed.css`. Allowed in `posts.py` topic validation.
- Validate form input with a typed `Annotated[Model, Form()]` param (define the model in `models.py`); read raw `await request.form()` only for file uploads
Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to `main`. It installs dependencies, validates with `hawk`, runs all tests, and uploads failure screenshots. The CI must be green before merging.
## SEO Implementation
All SEO features are implemented across the following locations:
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
- Reactions are **non-ranking** — they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) — never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation.
### Bookmarks
-`POST /bookmarks/{target_type}/{target_uid}` toggles a `bookmarks` row; `GET /bookmarks/saved` renders the personal list (`saved.html`). Target types: `post`, `gist`, `project`, `news`.
-`_bookmark_button.html` takes `_type`, `_uid`, `_bookmarked`; `BookmarkManager.js` swaps the label/`bookmarked` class from the JSON `{saved}`. Batch state via `get_user_bookmarks(user_uid, target_type, uids)`.
### Polls
- A poll rides on a post (one `polls` row keyed by `post_uid`, options in `poll_options`, one-per-user votes in `poll_votes`). Created in `posts.py:create_poll` when `PostForm.poll_question` plus ≥2 non-empty `poll_options` are submitted (capped at 6). The create-post modal in `feed.html` has the builder (`data-poll-toggle` / `data-poll-add-option`).
-`POST /polls/{poll_uid}/vote` toggles the voter's choice and returns the full poll dict: voting on a new option switches, voting the **same** option again **retracts** (one vote per user, like reactions). Results (bars + `%`) are **always shown** to everyone; the chosen option gets `.chosen` + a `✓`. Guests see read-only bars with a "Log in to vote" hint (options `disabled`). Batch reads via `get_polls_by_post_uids(post_uids, user)` / `get_poll_for_post(post_uid, user)`.
- Composer poll builder (`feed.html`, `PollManager.js`): poll inputs are `disabled` until "Add poll" is opened (so a never-opened or "Remove poll"-collapsed builder submits **nothing**); a capture-phase `submit` listener blocks an incomplete poll (question + ≥2 options) with an inline error instead of silently dropping it — capture phase + `stopImmediatePropagation()` is required so it pre-empts `FormManager`'s bubble-phase submit handlers.
### Cascade
-`delete_engagement(target_type, uids)` in `database.py` removes reactions, bookmarks and (for posts) poll rows. It is called from `content.py:delete_content_item` (for the item and its comments) and `comments.py:delete_comment`. Add it to any new delete path.
### Contribution heatmap + streaks
- Derived, **no new table**: `get_activity_calendar(user_uid)` aggregates `date(created_at)` across `posts`/`comments`/`gists`/`projects` (cached 120s); `get_activity_heatmap` returns 53 Monday-aligned weeks of `{date, count, level}` (level 0–4); `get_streaks` returns `{current, longest}`. Rendered server-side in `profile.html` (`.heatmap-grid`, styles in `profile.css`). The `On Fire` badge is awarded in `check_milestone_badges` when the current streak ≥ 7.
### CSS
- Reactions, bookmarks, polls and the saved page live in `static/css/engagement.css`, loaded globally in `base.html` (the controls appear across feed, detail pages and comments). Heatmap styles are in `profile.css`.