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 to public/index.php
  • Write access to data/ (search DB, admin password), cache/ (index check), and your content/ 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

  1. Clone or copy the project and point your web server’s document root to public/.

  2. Ensure PHP can create files under data/ and cache/.

  3. Open the site in a browser. The app will create data/search.db when needed (via autoRebuildIndex()).

  4. Admin: visit https://your-domain/admin.php (or /public/admin.php depending on setup). On first run, if no password file exists, the default login is admin—change it immediately under Password in the admin UI.

  5. 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 string
  • date: 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.db is a derived SQLite FTS5 index (titles, normalized body text, tags, category, slug, password flag).
  • The app auto-rebuilds the index when search.db is missing or when any .md file under content/ is newer than the DB (throttled with a short cache check).
  • After schema changes or restoring an old DB, run php build_index.php once.

Admin panel

  • URL: /admin.php (under your public/ 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: only public/ should be the web root; data/ is not under public/ by default.
  • Use HTTPS in production.

Development tips

  • Templates: templates/*.php for the site; templates/admin/*.php for admin.
  • Routing: public/index.php registers paths on Router; 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.