A small, flat-file content system built with PHP. Posts and pages are Markdown files; SQLite FTS5 powers full-text search. There is no MySQL or heavy framework—just PHP 8+, the PDO SQLite extension, and optional Apache mod_rewrite for clean URLs.
https://github.com/bmhoang/minimarkdowncms.git
Features
| Area | What you get |
|---|---|
| Content | Markdown with YAML-style front matter (title, tags, category, date, optional password) |
| Types | Posts (content/posts/) and pages (content/pages/) |
| Taxonomy | Tags and categories; listing routes for /tag/{tag} and /category/{category} |
| Search | SQLite FTS5 with Vietnamese-friendly normalization; highlighted snippets |
| Passwords | Per-post/page password (bcrypt). Listings and search hide body excerpts for protected items |
| Home filter | Optional home_tags in site config so the home page only shows posts matching certain tags |
| Admin | Web UI to create, edit, and delete content (public/admin.php) |
| PWA | Web app manifest and service worker for installable / offline-friendly behavior |
Requirements
- PHP 8.2 or newer (uses
str_starts_with,str_ends_with, typed properties, etc.) - Extensions: PDO, pdo_sqlite, mbstring
- Apache with
mod_rewrite(recommended) or another server configured to route requests topublic/index.php - Write access to
data/(search DB, admin password),cache/(index check), and yourcontent/tree
Project layout
├── bootstrap.php # Autoload, siteConfig(), helpers, search auto-rebuild
├── build_index.php # CLI: rebuild search index manually
├── content/
│ ├── posts/ # Blog posts → /post/{slug}
│ └── pages/ # Static pages → /page/{slug}
├── data/
│ ├── site.php # Site title, menu, home_tags, …
│ ├── search.db # SQLite FTS index (generated)
│ └── .admin_password # Admin hash (created on first run)
├── public/ # Web root (point the vhost here)
│ ├── index.php # Front controller + routes
│ ├── admin.php # Admin UI
│ ├── search.php # Standalone search entry (also routed via index)
│ ├── .htaccess # Rewrite to index.php
│ ├── manifest.json
│ ├── sw.js
│ └── assets/
├── src/ # ContentLoader, Router, SearchEngine, TagManager, …
└── templates/ # PHP templates for the public site + admin
Quick start
-
Clone or copy the project and point your web server’s document root to
public/. -
Ensure PHP can create files under
data/andcache/. -
Open the site in a browser. The app will create
data/search.dbwhen needed (viaautoRebuildIndex()). -
Admin: visit
https://your-domain/admin.php(or/public/admin.phpdepending on setup). On first run, if no password file exists, the default login isadmin—change it immediately under Password in the admin UI. -
Manual index rebuild (optional, e.g. after bulk edits or deploy):
cd path\to\project php build_index.php
Site configuration (data/site.php)
Return a PHP array. Keys are merged over defaults from bootstrap.php.
| Key | Purpose |
|---|---|
title |
Site name (logo, <title>, etc.) |
menu |
Array of ['label' => '…', 'path' => '…']. Paths are passed to baseUrl() (e.g. /, page/about) |
home_tags |
Optional. If missing, empty [], or not an array, the home page lists all posts. If a non-empty array (e.g. ['home', 'landing']), only posts whose tags include at least one of those values (case-insensitive) appear on the home page. Tag and category pages still list all matching posts. |
Example:
<?php
return [
'title' => 'My Site',
'menu' => [
['label' => 'Home', 'path' => '/'],
['label' => 'About', 'path' => 'page/about'],
],
'home_tags' => ['home', 'landing'],
];
Writing content
Front matter
Use a YAML-like block at the top of each .md file:
---
title: My article
tags: [news, update]
category: blog
date: 2026-03-20
---
Body starts here…
tags: comma-separated inside brackets, e.g.tags: [a, b, c]category: single stringdate: used for sorting (newest first)password: optional. If set (via admin), the value is a bcrypt hash; do not paste plain text by hand in production—use the admin editor to set or change passwords
URLs
| Content | URL pattern |
|---|---|
Post with slug hello-world |
/post/hello-world |
Page with slug about |
/page/about |
| Tag | /tag/{tag} |
| Category | /category/{category} |
| Search | /search?q=… (header form uses the same) |
Pretty URLs assume public/.htaccess rewrites to index.php. On nginx or IIS, configure equivalent routing.
Password-protected content
- Visitors see a password form before the Markdown body is rendered.
- Unlock state is stored in the PHP session (
$_SESSION['unlocked']). - On home, tag, and category listings, excerpts are not shown for protected posts (only title, meta, lock icon, and tags—no body preview).
- Search does not show text snippets for protected documents (the index stores a flag so snippets stay hidden).
Search index
- Source of truth is always the Markdown files under
content/. data/search.dbis a derived SQLite FTS5 index (titles, normalized body text, tags, category, slug, password flag).- The app auto-rebuilds the index when
search.dbis missing or when any.mdfile undercontent/is newer than the DB (throttled with a short cache check). - After schema changes or restoring an old DB, run
php build_index.phponce.
Admin panel
- URL:
/admin.php(under yourpublic/document root). - Password file:
data/.admin_password(bcrypt hash). - Features: list posts/pages, Markdown editor, tags, category, date, optional/remove password, CSRF protection.
Security notes
- Change the default admin password on first deploy.
- Keep
data/outside public HTTP access: onlypublic/should be the web root;data/is not underpublic/by default. - Use HTTPS in production.
Development tips
- Templates:
templates/*.phpfor the site;templates/admin/*.phpfor admin. - Routing:
public/index.phpregisters paths onRouter;Router::dispatch()matches paths after stripping the script base path. - Styling:
public/assets/style.css,public/assets/admin.css.
License
Use and modify this project according to your needs; include attribution to upstream authors if you redistribute bundled third-party code (e.g. Parsedown) per their licenses.