Weave content into websites.
A lightweight, Git-native publishing engine that renders Markdown and reusable components into fast, cacheable websites using plain PHP.
Loom is not a CMS. There's no admin panel, no database, no build step. Just content.
Create a new site that uses Loom as its engine:
mkdir my-site && cd my-site
git init
composer init --name=you/my-site --type=project
composer require mrdave996/loomContact forms include CSRF protection, a honeypot, minimum completion time, session/IP rate limits, field-size limits, and optional Cloudflare Turnstile. Turnstile is configured per site, outside the shared engine, for example:
'contact' => [
'email' => 'you@example.com',
'spam' => [
'turnstile' => [
'enabled' => true,
'site_key' => getenv('TURNSTILE_SITE_KEY'),
'secret_key' => getenv('TURNSTILE_SECRET_KEY'),
],
],
],Keep the secret key in the server environment, never in Git. Turnstile is verified server-side against Cloudflare before a submission is stored or emailed. Server-level controls such as fail2ban can complement this, but are not a substitute for application validation because they depend on web-server logs and stable client IPs.
Create the site structure:
my-site/
├── public/
│ ├── index.php ← bootstrap (see below)
│ ├── .htaccess
│ └── assets/
│ └── css/
│ └── style.css
├── content/
│ └── pages/
│ └── index.md ← your first page
├── templates/ ← optional overrides
└── cache/
public/index.php — the only file you write:
<?php
require_once __DIR__ . '/../vendor/autoload.php';
$rootDir = dirname(__DIR__);
$loomDir = __DIR__ . '/../vendor/mrdave996/loom';
// Merge: site templates override Loom defaults
$templatesDir = is_dir($rootDir . '/templates')
? $rootDir . '/templates'
: $loomDir . '/templates';
// Boot the engine
$router = new Loom\Router($rootDir);
$content = new Loom\ContentLoader();
$renderer = new Loom\Renderer($rootDir, $templatesDir);
$cache = new Loom\CacheManager($rootDir);
$seo = new Loom\SeoGenerator($rootDir);
// Handle sitemap/robots
if ($seo->handle()) exit;
// Route
$pageFile = $router->resolve($_SERVER['REQUEST_URI']);
if (!$pageFile) { http_response_code(404); /* handle 404 */ exit; }
// Cache check
$cached = $cache->get($pageFile);
if ($cached) { echo $cached; exit; }
// Render
$page = $content->load($pageFile);
$html = $renderer->render($page);
$cache->set($pageFile, $html);
echo $html;Create your first page (content/pages/index.md):
---
title: "My First Page"
description: "Hello from Loom."
template: default
---
## Hello World
Your content goes here.Preview locally:
php -S localhost:8080 -t public/Open http://localhost:8080 in your browser.
Import an entire WordPress site — pages, posts, media, styles, navigation, and forms:
# In your site repo (not the Loom repo)
composer require mrdave996/loom
php vendor/bin/loom import /path/to/export.xmlThat's it. Loom will:
- Convert all pages and posts to Markdown with YAML front matter
- Download and convert images to WebP for fast loading
- Extract theme CSS and build a Loom-compatible stylesheet
- Generate navigation from your WordPress menus
- Convert contact forms to Loom's built-in FormHandler
- Create 301 redirect rules for any changed URLs
Using UpDraftPlus? Point the import at your SQL dump:
php vendor/bin/loom import /path/to/backup.sqlAfter import, preview your site:
php -S localhost:8080 -t public/Verify everything looks good:
php vendor/bin/loom verify[ Request ]
│
▼
[ Router ] → resolve URI to markdown file
│
▼
[ Cache Check ] → serve cached HTML if valid
│
▼ (cache miss)
[ ContentLoader ] → parse YAML front matter + Markdown
│
▼
[ Renderer ] → load PHP template + inject partials
│
▼
[ CacheManager ] → save rendered HTML
│
▼
[ Output ]
The entire core runtime is under 600 lines of PHP across 6 files. Small enough for an AI to hold in a single context window.
Your site repo (not the Loom framework repo):
my-site/
├── composer.json # requires mrdave996/loom
├── public/
│ ├── index.php # bootstrap (thin — loads engine from vendor)
│ ├── .htaccess # URL rewrites
│ └── assets/
│ └── css/style.css # your site's styles
├── content/
│ ├── pages/ # Markdown pages
│ └── posts/ # blog posts (optional)
├── templates/ # optional: override Loom's defaults
│ ├── layouts/ # page layouts
│ └── partials/ # reusable components
└── cache/ # generated HTML (gitignored)
The framework lives in vendor/mrdave996/loom/ — you never edit it directly.
Loom ships with default templates and partials. Override any of them by placing a file with the same name in your site's templates/ directory.
| Template | Use case |
|---|---|
default |
Simple page with body content |
pillar |
Long-form page with component slots |
blog |
Blog listing from content/posts/ |
raw |
Minimal — no nav, no footer, just content |
Include reusable partials by listing them in your page's front matter components array:
hero— Hero section with title, subtitle, and CTAfeatures— Grid of feature cardspricing— Pricing tablefaq— Accordion FAQ sectioncta— Call-to-action block
Every page is a Markdown file with YAML front matter:
---
title: "Page Title"
description: "SEO description."
template: pillar
components:
- hero
- features
- faq
- cta
hero_title: "Welcome"
hero_subtitle: "Build something great."
features:
- title: "Fast"
description: "Single-digit millisecond TTFB."
- title: "Simple"
description: "Zero framework, zero database."
---
## Your content here
Markdown body rendered as HTML.Run from your site repo root:
# Verify content integrity
php vendor/bin/loom verify
# Import WordPress site
php vendor/bin/loom import export.xml
php vendor/bin/loom import export.sql
# Scrape a mirrored site
php vendor/bin/loom scrape scrape/sites/example.com.phpcomposer update mrdave996/loomYour content and templates are in your own repo — updating the framework never touches them.
- On-demand caching — First request renders and caches; subsequent requests serve static HTML
- WebP images — All images converted to WebP during import
- Zero database — No queries, no connection overhead
- Minimal runtime — Under 600 lines of core PHP
Analytics is optional and remains flat-file, privacy-aware, and database-free. Enable it with
LOOM_ANALYTICS_ENABLED=1 (and leave LOOM_ANALYTICS_CONSENT_REQUIRED=1 unless the site has
an explicitly reviewed first-party measurement policy). The shared tracker records anonymous
session journeys, persisted UTM/referrer attribution, bounded search-referrer keywords when a
search engine supplies q, query, or text, CTA/form events, and one bounded page_exit event
per page view. Page-exit metadata contains elapsed and visible/active seconds; reports must show
coverage and must not infer time from gaps between arbitrary events.
Known AI assistant referrers such as ChatGPT, Claude, Perplexity, Gemini, Copilot, Grok, DeepSeek, You.com, Phind, and Poe can be classified by the separate reporting application when a human click arrives with a usable referrer. Copied or referrer-stripped AI links remain direct/unattributed; tagged UTM links are the reliable fallback. Do not put names, email addresses, message bodies, or raw IP addresses in analytics metadata.
MIT
Built with:
- league/commonmark — Markdown parsing
- intervention/image — Image processing
- league/html-to-markdown — HTML conversion