Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Merkd logo

pavex/merkd

Merkd is a lightweight, file-driven CMS for PHP applications. Write content in Markdown, run one command, and your content is compiled into a SQLite database with responsive image variants — ready to serve at full speed.

pavex/merkd is the main package. It includes the content builder, image processor, CLI tools, and the SQLite client (pavex/merkd-client). Install this one package and you have everything.

composer require pavex/merkd

What it does:

  1. Walks your content directory and parses .md files with YAML front-matter
  2. Renders Markdown to HTML — inline images are intercepted and replaced with responsive <picture> elements at parse time
  3. Generates JPG and AVIF image variants in multiple sizes
  4. Persists documents and assets to SQLite with full referential integrity
  5. Tracks soft-deleted content — nothing is lost when you remove a file

What you get from the client side:

  • Typed PHP objects (Post, AssetRecord) — no magic arrays
  • Responsive <picture> HTML from a single method call
  • Multi-language support built in
  • Custom front-matter attributes without schema changes

How it fits together

content/*.md  --→  merkd (CLI builder)  --→  merkd.sqlite  --→  merkd-client  --→  templates
(your files)       (this package)            (database)         (included)

The builder is the write side. The client (pavex/merkd-client, included as a dependency) is the read side — it never writes to the database or touches the filesystem.

Requirements

  • PHP 8.1+
  • Extensions: pdo_sqlite, gd, fileinfo
  • GD with AVIF support (optional — JPG fallback is used if unavailable)
  • pavex/utils
  • pavex/getopt

Installation

composer require pavex/merkd

Then run the install script to verify the environment and create the database:

php vendor/bin/merkd-install
# or via composer script:
composer merkd-install

Quick start

# Install database and check environment
php vendor/bin/merkd-install

# Build all content
composer merkd -- --build

# Reset — wipe generated assets and rebuild from scratch
composer merkd -- --build --reset

Add to composer.json:

{
    "scripts": {
        "merkd": "@php vendor/bin/merkd-build",
        "merkd-install": "@php vendor/bin/merkd-install"
    }
}

Development — auto-rebuild on change

For a comfortable edit-and-preview loop, watch content/ and rebuild on every save:

npx nodemon --watch content -e md --exec "composer merkd -- --build"

Requires Node.js (nodemon runs via npx, no permanent install needed).

Configuration

Create config.php in your project root:

<?php

return [
    'merkd' => [
        'lang'         => 'en',
        'db'           => 'db/merkd.sqlite',
        'content_dir'  => 'content',
        'public_dir'   => 'public',
        'asset_dir'    => 'assets',        // subdirectory inside public_dir (default: assets)
        'base_url'     => '/assets/',      // derived automatically if omitted

        'jpg_quality'  => 85,
        'avif_quality' => 60,
        'image_sizes'  => [400, 800, 1600],

        'max_megapixels' => 80,     // hard cap on source image dimensions (width × height), independent of memory_limit
        'max_memory_mb'  => 0,      // hard cap on estimated decode memory in MB, independent of memory_limit; 0 = disabled
    ],
];

Directory structure

project/
├── config.php
├── content/
│   ├── my-article.md
│   └── images/
│       └── photo.jpg          ← source image (relative to the .md file)
├── db/
│   └── merkd.sqlite
└── public/
    └── assets/                ← builder-owned, safe to wipe on --reset
        └── images/
            ├── photo_400px.jpg
            ├── photo_400px.avif
            ├── photo_800px.jpg
            ├── photo_800px.avif
            ├── photo_1600px.jpg
            ├── photo_1600px.avif
            ├── photo.jpg
            └── photo.avif

Markdown format

---
slug: my-article
title: My Article
lang: en
published: "2026-05-15 10:00"
author: Jane Doe
tags: php;web
perex: A short excerpt shown in listings.
image: images/photo.jpg
hidden: false
---

# My Article

Content here. Inline images are converted to <picture> automatically:

![Alt text](images/other.jpg)

Front-matter fields

Field Required Description
slug no URL identifier — derived from filename if omitted
title yes Document title
lang no Language code — uses config default if omitted
published no Publication datetime — uses file mtime if omitted
author no Author name
tags no Semicolon-separated list or YAML array
perex no Short excerpt
image no Hero image path, relative to the .md file
hidden no true to hide from listings
locale no Full locale string (e.g. en_US)
translations no Map of language codes to slugs

Any unrecognised key is stored in attributes (JSON) and accessible via $post->getAttribute('key').

Build modes

Normal build

php vendor/bin/merkd-build --build
  • Marks all documents is_deleted = 1 at start
  • Parses and upserts every .md file — no hash-based skip for documents
  • Assets: hash check retained — unchanged images are not reprocessed
  • After all files: orphan assets marked is_deleted = 1
  • Never deletes files from disk

Reset

php vendor/bin/merkd-build --build --reset
# or equivalently:
php vendor/bin/merkd-build --build --force
  • Wipes contents of public/assets/ (the directory itself is preserved — permissions are kept)
  • Truncates documents, assets, and document_assets tables
  • Performs a full rebuild from scratch

Requires asset_dir to be non-empty. If empty, the wipe step is skipped with a warning.

Error handling

A single bad document or image doesn't abort the whole build — the problem is logged and that item is skipped, the rest of the build continues:

  • Missing image (frontmatter image: or an inline ![]() reference pointing at a file that doesn't exist) — logged as -> error: <path> (not found relative to ...). The document is still saved, just without that image.
  • Image too large to process safely — before decoding, the builder estimates the memory GD will need and checks it against max_megapixels, max_memory_mb (see Configuration), and the script's actual memory_limit. An image that fails any of these is skipped with a logged reason instead of crashing with a PHP fatal error.
  • Any other unexpected error while processing one .md file is caught, logged, and that file is skipped rather than stopping the entire build.

Watch for -> error: lines in the build output to catch anything that was skipped.

CLI options

Options:
  -b, --build             Run the full build pipeline (required)
  -r, --reset             Wipe asset dir + truncate DB, then rebuild
  -f, --force             Alias for --reset (retained for compatibility)
  -d, --db <path>         Override db path from config.php
  -c, --content <path>    Override content_dir from config.php
  -p, --public <path>     Override public_dir from config.php
  -l, --lang <lang>       Override default lang from config.php
  -h, --help              Show help

merkd-install

php vendor/bin/merkd-install
  1. Verifies directories (content, db, public_dir) — creates if missing
  2. Installs merkd.sqlite from the bundled schema if not present
  3. Checks PHP extensions: pdo_sqlite, gd, fileinfo
  4. Reports image format support: JPEG, PNG, WebP, AVIF

Using the client

use Merkd\Client;

$client = new Client('db/merkd.sqlite', default_lang: 'en');

$posts = $client->getPosts(limit: 10, offset: 0, lang: 'en');
$total = $client->countPosts(lang: 'en');
$post  = $client->getPost(slug: 'my-article', lang: 'en');

echo $post->title;
echo $post->content_html;
echo $post->published->format('Y-m-d');
echo $post->getUrl();

// Hero image
if ($post->image !== null) {
    echo \Merkd\Utils\Html::image($post->image);
}

See pavex/merkd-client for full client documentation.

Soft delete

The builder never physically removes records from the database:

  • Build start — all documents marked is_deleted = 1
  • Per document — upserted with is_deleted = 0
  • Per asset (skip)restoreDeleted() sets is_deleted = 0 without reprocessing
  • Build endmarkOrphanAssetsDeleted() marks assets with no active document reference
  • Client — all queries filter AND is_deleted = 0
  • Cleanup — use --reset to physically truncate and rebuild

Architecture

Build::run()
    ├── markAllDeleted()              documents: is_deleted = 1
    ├── (--reset) clearDirContents() + truncate()
    └── foreach .md file
            └── ContentBuilder::build(file_path)
                    ├── FileParser::parse()
                    │       ├── YAML front-matter  →  SourceRecord
                    │       ├── assetCallback(hero image)
                    │       └── MerkdParsedown::text(body)
                    │               └── inlineImage() → assetCallback(inline images)
                    │
                    ├── assetCallback(src, slug, lang)
                    │       ├── insertStub()                FK → documents satisfied
                    │       ├── hash check
                    │       │     unchanged → restoreDeleted()
                    │       │     changed   → ImageProcessor::process() → insert/update
                    │       └── insertDocumentAssetBinding()
                    │
                    └── buildDatastore::upsert()            is_deleted = 0
    └── markOrphanAssetsDeleted()     orphan assets: is_deleted = 1

License

MIT — © 2026 Pavel Macháček

About

Merkd build - file driven CMS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages