Project Structure
Nibbly is a flat-file CMS — everything lives in a single directory. No database, no build tools, no package manager.
nibbly/
├── admin/ Admin panel & API
│ ├── config.php Your configuration (created by setup)
│ ├── setup.php First-run setup wizard
│ ├── index.php Login page
│ ├── dashboard.php Content editor
│ └── api.php REST API
├── api/
│ ├── contact.php Contact form handler
│ └── SmtpMailer.php SMTP mailer class
├── includes/
│ ├── header.php HTML head + header + navigation
│ ├── footer.php Footer + scripts
│ ├── content-loader.php Section rendering + events + editable fields
│ ├── block-renderers/ One PHP file per block type
│ ├── block-types.php Block type definitions & defaults
│ ├── nav-config.php Navigation items & language mapping
│ └── page.php Front controller for JSON-based pages
├── cli/
│ ├── convert.php HTML-to-Nibbly converter
│ └── make.php Page scaffolding tool
├── content/
│ ├── events.json Shared event data (multilingual)
│ ├── settings.json Site settings (branding, theme, favicon)
│ └── pages/ JSON content files
│ ├── en_home.json English homepage content
│ ├── de_home.json German homepage content
│ └── footer.json Footer content (multilingual)
├── css/
│ ├── style.css Main stylesheet (custom properties at top)
│ ├── components.css Render component styles
│ ├── fonts.css Font definitions (auto-loaded if present)
│ └── inline-editor.css Inline editor styles
├── js/
│ ├── inline-editor.js Inline editing system
│ ├── landing-effects.js Landing page effects
│ └── audio-player.js Custom audio player
├── assets/ Images, audio, fonts
├── backups/ Automatic backups
├── en/ English pages (primary language)
├── de/ German pages
├── .htaccess Security rules + routes to route.php (Apache)
├── route.php Front controller (all routing logic)
├── router.php Dev server router (php -S localhost:3000 router.php)
└── index.php Homepage entry point
Creating Pages
Every page is a PHP file that sets a few variables, then includes the template system. Here's the minimal structure:
<?php
$pageTitle = 'Example Page';
$pageDescription = 'A meta description for SEO.';
$currentLang = 'en';
$currentPage = 'example';
$contentPage = 'en_example';
if (!isset($basePath)) $basePath = '../';
$_includeBase = dirname(__DIR__) . '/';
include $_includeBase . 'includes/header.php';
include $_includeBase . 'includes/content-loader.php';
?>
<main class="main-content">
<div class="content-inner">
<?php echo renderAllSections($contentPage); ?>
</div>
</main>
<?php include $_includeBase . 'includes/sidebar.php'; ?>
<?php include $_includeBase . 'includes/footer.php'; ?>
Template Variables
| Variable | Purpose | Example |
|---|---|---|
$pageTitle |
HTML <title> and Open Graph title |
'My Page' |
$pageDescription |
Meta description for SEO | 'About this page.' |
$currentLang |
Language code (ISO 639-1) | 'en', 'de', 'es' |
$currentPage |
Page key for navigation highlighting | 'example' |
$contentPage |
JSON content file identifier | 'en_example' |
$basePath |
Relative path to document root | '../' |
$pageClass |
Optional CSS class added to <body> |
'page-landing' |
URL Routing
Primary language pages are accessible from root without a prefix. Secondary languages use /{code}/ prefixes:
| URL | File | Notes |
|---|---|---|
/ | en/index.php | Root index.php includes primary lang homepage |
/docs | en/docs.php | Primary lang page, accessible from root via route.php |
/de/ | de/index.php | Secondary language with prefix |
/de/showcase | de/showcase.php | Secondary language subpage |
/about | content/pages/en_about.json | JSON-only page (no PHP template needed) |
Page Discovery
Pages are auto-discovered from JSON files in content/pages/. Any file matching {lang}_{slug}.json appears in the admin dashboard automatically. Control which menus a page appears in via the "nav" field: "nav": ["header", "footer"] for both, "nav": [] to hide from all.
For navigation ordering and language switching, edit includes/nav-config.php. For the quickest setup, use the scaffolding tool:
php cli/make.php --slug=about --lang=en --title="About Us"
Adding Navigation Links
Edit the $NAV_ITEMS array in includes/nav-config.php:
$NAV_ITEMS = [
'en' => [
['href' => '.', 'label' => 'Home', 'page' => 'home'],
['href' => 'docs', 'label' => 'Docs', 'page' => 'docs'],
],
'de' => [
['href' => 'de/', 'label' => 'Startseite', 'page' => 'home'],
['href' => 'docs', 'label' => 'Dokumentation', 'page' => 'docs'],
],
'es' => [
['href' => 'es/', 'label' => 'Inicio', 'page' => 'home'],
['href' => 'docs', 'label' => 'Documentación', 'page' => 'docs'],
],
];
For language switching, add the mapping in $PAGE_MAPPING (same file):
$PAGE_MAPPING = [
'home' => ['en' => '.', 'de' => 'de/', 'es' => 'es/'],
'docs' => ['en' => 'docs', 'de' => 'docs', 'es' => 'docs'],
];
Primary language pages are accessible from root (no prefix). Secondary languages use /{code}/ prefixes.
JSON Content Format
Each page's content is stored as a JSON file in content/pages/. The filename follows the pattern {lang}_{slug}.json.
{
"page": "en_example",
"lang": "en",
"lastModified": "2026-03-01T14:30:00+01:00",
"sections": [
{
"id": "section_intro",
"type": "text",
"title": "Welcome",
"titleTag": "h2",
"content": "<p>Your HTML content here.</p>"
},
{
"id": "section_video",
"type": "youtube",
"videoId": "dQw4w9WgXcQ"
}
]
}
Document Fields
| Field | Type | Description |
|---|---|---|
page | string | Page identifier (matches filename without .json) |
lang | string | Language code |
lastModified | string|null | ISO 8601 timestamp, set automatically on save |
sections | array | Ordered list of content sections |
Section Types
Nibbly supports 11 built-in section types for standard pages. Each type has its own renderer in includes/block-renderers/.
| Type | Category | Key Fields |
|---|---|---|
text | content | title, content (HTML), titleTag, style |
heading | content | text, level (h1–h6), subtitle |
quote | content | text, attribution, style (default/large) |
list | content | title, style (bullet/numbered), content (HTML) |
image | media | src, alt, caption, width (full/medium/small) |
card | cards | title, content, image |
youtube | media | videoId, title |
soundcloud | media | trackId, title |
audio | media | src, title |
divider | layout | (none) |
spacer | layout | height (sm/md/lg/xl) |
text
Rich text with optional title and highlight styling.
{
"id": "s1",
"type": "text",
"title": "Section Title",
"titleTag": "h2",
"content": "<p>HTML content with <strong>bold</strong>, <em>italic</em>, links, and lists.</p>",
"style": "highlight"
}
Allowed HTML tags: <p> <br> <strong> <b> <em> <i> <u> <a> <ul> <ol> <li> <h1>–<h6> <blockquote> <span> <div>
heading
A standalone heading with optional subtitle.
{ "id": "s2", "type": "heading", "text": "Welcome", "level": "h1", "subtitle": "A tagline" }
quote
Blockquote with attribution. Use "style": "large" for a larger display.
{ "id": "s3", "type": "quote", "text": "A wise quote.", "attribution": "Someone" }
list
Bullet or numbered list. Content is raw HTML (<ul><li>...</li></ul>).
{ "id": "s4", "type": "list", "title": "Features", "style": "bullet", "content": "<ul><li>Item 1</li><li>Item 2</li></ul>" }
image
Image with optional caption and width control.
{ "id": "s5", "type": "image", "src": "assets/images/photo.jpg", "alt": "Description", "caption": "Optional caption" }
card
Image card with title and description. Consecutive cards are automatically arranged in a grid.
{ "id": "s6", "type": "card", "title": "Card Title", "content": "Description.", "image": "assets/images/photo.jpg" }
youtube
Embedded YouTube video (uses privacy-enhanced youtube-nocookie.com).
{ "id": "s7", "type": "youtube", "videoId": "dQw4w9WgXcQ", "title": "Video Title" }
soundcloud & audio
SoundCloud embed or self-hosted audio with custom player.
{ "id": "s8", "type": "soundcloud", "trackId": "1431378517", "title": "Track Name" }
{ "id": "s9", "type": "audio", "src": "assets/audio/track.mp3", "title": "Track Name" }
divider & spacer
Layout helpers. Divider renders a horizontal rule. Spacer adds vertical space.
{ "id": "s10", "type": "divider" }
{ "id": "s11", "type": "spacer", "height": "lg" }
Inline Editing
When an admin is logged in, every page becomes editable directly in the browser. The system works through HTML attributes and CSS classes that the content loader adds automatically.
How It Activates
The inline editor initializes when two meta tags are present (injected by footer.php for logged-in admins):
<meta name="csrf-token" content="...">
<meta name="content-page" content="en_example">
<link rel="stylesheet" href="css/inline-editor.css">
<script src="js/inline-editor.js"></script>
Editable Field Functions
For custom layouts (like the landing page), use these functions instead of renderAllSections():
| Function | Description |
|---|---|
editableText($page, $fieldKey, $default) | Inline-editable plain text field using dot notation |
editableHtml($page, $fieldKey, $default) | Inline-editable rich HTML field |
editableLink($page, $fieldKey, $text, $href, $class) | Editable link (text + href) |
editableImage($page, $fieldKey, $src, $alt, $class) | Editable image (click to replace) |
editableListAttrs($page, $listKey, $defaults) | Attributes for a repeatable list container |
editableListItemAttrs($page, $listKey, $index) | Attributes for a single list item |
editableListItems($page, $listKey) | Returns list items from JSON for iteration |
<h1><?php echo editableText('en_home', 'hero.title', 'Default Title'); ?></h1>
<?php echo editableLink('en_home', 'hero.cta1', 'Get Started', '#', 'btn btn-primary'); ?>
<?php foreach (editableListItems('en_home', 'features.items') as $i => $item): ?>
<div<?php echo editableListItemAttrs('en_home', 'features.items', $i); ?>>
<?php echo editableText('en_home', "features.items.$i.title", 'Feature'); ?>
</div>
<?php endforeach; ?>
Data Attributes
These attributes control what's editable and how:
| Attribute | Element | Purpose |
|---|---|---|
data-content-page |
Container | Identifies which JSON file this area belongs to |
data-section-index |
.editable-section |
Zero-based position in the sections array |
data-section-type |
.editable-section |
Section type (text, card, audio, ...) |
data-section-id |
.editable-section |
Unique section identifier |
Admin Bar
When logged in, a fixed bar appears at the top of every page with edit mode toggle, undo/redo history, dashboard link, and logout. The body receives a has-admin-bar class that adds top padding.
Drag & Drop
Sections can be reordered by dragging the handle that appears on hover. The inline editor sets draggable="true" on each section and handles the reordering via the API.
Render Components
Pre-built components for common patterns. Each reads from a specific JSON key and renders editable HTML. Use them in custom layout pages alongside editableText() fields.
| Function | JSON Key | Item Fields |
|---|---|---|
renderFeatureGrid($page) | features.items | {icon, title, desc} |
renderPricingTable($page) | pricing.plans | {name, price, period, desc, features, cta} |
renderFaqAccordion($page) | faq.entries | {question, answer} |
renderTeamGrid($page) | team.members | {name, role, bio, image} |
renderGallery($page) | gallery.images | {src, alt, caption} |
renderTimeline($page) | timeline.entries | {date, title, content} |
renderStats($page) | stats.items | {value, label} |
renderTestimonials($page) | testimonials.items | {text, author, role} |
renderComparisonTable($page) | comparison.rows | {feature, us, them} |
renderNewsList($limit, $lang) | content/news/*.json | News post cards |
List items in JSON must use numbered object keys, not arrays:
"features": {
"heading": "Features",
"items": {
"0": { "icon": "zap", "title": "Fast", "desc": "No database." },
"1": { "icon": "shield", "title": "Secure", "desc": "Minimal attack surface." }
}
}
Editable Text List
editableTextList($page, $listKey) renders a list of editable HTML paragraphs with add/remove/reorder controls. Each paragraph gets inline editing with a floating toolbar.
<div class="about-text">
<?php echo editableTextList($_p, 'about.paragraphs'); ?>
</div>
"about": {
"paragraphs": {
"0": { "content": "First paragraph..." },
"1": { "content": "Second paragraph with <strong>formatting</strong>..." }
}
}
Auto-Write
When an admin browses a page, every editableText(), editableHtml(), editableImage(), and editableLink() call checks whether its key exists in the JSON. If the key is missing, Nibbly automatically writes the fallback value to the JSON file.
This means you can focus on the PHP template — the JSON is populated on first admin page view. A toast notification tells the admin how many fields were auto-generated.
What auto-writes
| Function | Auto-writes? | JSON format |
|---|---|---|
editableText() | Yes | String value |
editableHtml() | Yes | String value |
editableImage() | Yes | {src, alt} |
editableLink() | Yes | {text, href} |
editableListItems() | No | Returns [] if missing |
When you must pre-populate JSON
- Standard pages with
sections[]—renderAllSections()needs the sections array - Editable lists — list structure (with at least one item) must exist in JSON
- Content that should differ from the PHP fallback — edit the JSON directly
Template System
Nibbly uses a simple include-based template system. Every page includes three files in order:
$_includeBase = dirname(__DIR__) . '/';
include $_includeBase . 'includes/header.php';
include $_includeBase . 'includes/content-loader.php';
// ... your page content ...
include $_includeBase . 'includes/sidebar.php';
include $_includeBase . 'includes/footer.php';
header.php
Outputs the complete HTML head, fixed header with logo and navigation, mobile navigation overlay, theme toggle (light/dark), and language selector. Auto-loads admin/config.php for language settings and includes/nav-config.php for navigation items.
content-loader.php
Provides the core rendering functions:
| Function | Description |
|---|---|
renderAllSections($page) |
Renders all sections for a page. Groups consecutive cards into a grid. Wraps in editable container for admins. |
renderSection($section, $index, $editable) |
Renders a single section based on its type. |
loadContent($page) |
Loads and parses the JSON file for a page. |
sanitizeHtml($html) |
Strips disallowed tags, event handlers, and dangerous URI schemes. |
loadEvents() |
Loads all events from content/events.json. |
renderEventList($events, $lang) |
Renders a list of event cards with admin controls. |
footer.php
Outputs the footer with editable fields, main JavaScript (scroll behavior, mobile nav, smooth scroll, reveal animations, contact form), audio player script, and — for logged-in admins — the inline editor.
CLI Tools
Page Scaffolding
Generate a new page with PHP template, JSON content file, and optional navigation registration:
php cli/make.php --slug=about --lang=en --title="About Us"
php cli/make.php --slug=about --lang=en --type=custom --title="About Us"
Options: --type=standard|custom (default: standard), --force (overwrite existing).
HTML-to-Nibbly Converter
Convert any static HTML page into an editable Nibbly template + JSON + CSS:
php cli/convert.php landing.html --slug=home --lang=en --dry-run
php cli/convert.php about.html --slug=about --lang=en --force
The converter detects sections, headings, text, images, links, and repeating patterns (cards, testimonials). It generates editableText(), editableImage(), editableLink(), and editable list calls. Inline styles are extracted to css/page-{slug}.css.
| Option | Description |
|---|---|
--slug=NAME | Page slug for URLs (default: from filename) |
--lang=CODE | Language code (default: en) |
--title=TEXT | Page title (default: from <title> or <h1>) |
--dry-run | Preview output without writing files |
--json-only | Only generate JSON, no PHP template |
--no-css | Skip CSS extraction |
--force | Overwrite existing files |
CSS & Design System
All styles use CSS custom properties defined in :root. Change the look of your site by modifying these variables in css/style.css:
Colors
:root {
--color-primary: #60c8cd; /* Main brand color (türkis) */
--color-primary-dark: #0d959c; /* Hover/active states */
--color-primary-light: #7df6fc; /* Accents */
--color-secondary: #61abcd; /* Secondary blue */
--color-text: #171717; /* Body text */
--color-text-secondary: #525252; /* Muted text */
--color-background: #ffffff; /* Page background */
--color-background-section: #f5f5f4;/* Alternating sections */
--color-border: rgba(0, 0, 0, 0.08);/* Borders */
}
Typography
:root {
--font-display: 'Quicksand', system-ui, sans-serif; /* Headings */
--font-body: 'Quicksand', system-ui, sans-serif; /* Body text */
--font-mono: 'Geist Mono', 'SF Mono', monospace; /* Code blocks */
}
Spacing & Layout
:root {
--spacing-xs: 0.5rem; /* 8px */
--spacing-sm: 1rem; /* 16px */
--spacing-md: 2rem; /* 32px */
--spacing-lg: 4rem; /* 64px */
--spacing-xl: 6rem; /* 96px */
--header-height: 80px;
--container-max: 1400px; /* Full-width container */
--container-narrow: 800px; /* Content container */
}
Key CSS Classes
| Class | Purpose |
|---|---|
.main-content | Main content area with header offset |
.content-inner | Narrow centered container (800px) |
.content-highlight | Highlighted background box for text sections |
.cards-grid | Auto-layout grid for card sections |
.reveal | Scroll-triggered fade-in animation |
.stagger-reveal | Staggered child animations |
Responsive Breakpoints
Primary breakpoint at 768px. The layout switches from multi-column to single-column below this width. A secondary breakpoint at 1024px is used for the landing page grid.
Showcase Page
The showcase page demonstrates all content types and components Nibbly supports. It uses a custom layout with editableText() fields rather than renderAllSections(), and is available in all configured languages.
Page Structure
The showcase page consists of these components, top to bottom:
| Component | Class | Description |
|---|---|---|
| Hero | .showcase-hero |
Two-column grid: editable text (label, title, intro) on the left, mascot image on the right. The image switches between light and dark variants based on data-theme. |
| Jump Nav | .showcase-jumpnav |
Sticky navigation bar with a label and 10 numbered links (01–10). Items fly in pairwise from left/right with staggered delays, then pulse sequentially in brand color. |
| Explainer | .showcase-explainer |
Two-column grid: description on the left, code window on the right. The code window slides in from the right with a randomized rotation (–12° to +12°, set via PHP rand()). |
| Examples (×10) | .showcase-example |
Each example has an explainer section with a code window, followed by a live rendered component (calendar, FAQ, pricing table, etc.). |
Dark Mode Images
The hero mascot uses two <img> elements toggled via CSS based on the data-theme attribute (not prefers-color-scheme, since the theme is controlled by JavaScript):
<div class="showcase-hero__image">
<img src="images/nibbly-beaver-showcase.webp"
class="showcase-hero__beaver showcase-hero__beaver--light">
<img src="images/nibbly-beaver-showcase-darkmode.webp"
class="showcase-hero__beaver showcase-hero__beaver--dark">
</div>
Jump Nav Animations
The numbered items use two chained CSS animations:
- Fly-in: Items arrive pairwise — the center pair (05 + 06) lands first, then 04 + 07, and so on outward to 01 + 10. Each item flies in from its respective side (left or right).
- Pulse: After all items have landed, they pulse sequentially from 01 → 10 — scaling up briefly and flashing in the brand color (
--color-primary-lightin dark mode,--color-primaryin light mode).
Code Window Rotation
Each explainer code window gets a random start rotation via a CSS custom property set by PHP:
<div class="showcase-explainer__code"
style="--code-rotation: <?php echo rand(-12, 12); ?>deg">
The CSS uses this variable as the initial rotation, animating to 0deg when the section scrolls into view. This creates visual variety across the 10 examples.
Admin Dashboard
Access the admin panel by double-clicking "MIT License" in the footer, or go directly to /admin/.
First-Time Setup
On first visit, the setup wizard (admin/setup.php) creates your config.php. It asks for site name, primary language, optional secondary language, admin username, and password. Once configured, the wizard deactivates itself.
Dashboard Tabs
| Tab | Function |
|---|---|
| Content Editor | Select language and page, edit sections, save with automatic backups, restore previous versions |
| Messages | View, read, and delete contact form submissions |
| Settings | Change admin password |
API Endpoints
The dashboard and inline editor communicate through admin/api.php. All POST requests require a CSRF token.
| Action | Method | Description |
|---|---|---|
load | GET | Load page content |
save | POST | Save page (creates backup) |
backups | GET | List available backups |
restore | POST | Restore a backup |
load-events | GET | Load all events |
save-event | POST | Create or update an event |
delete-event | POST | Delete an event |
load-event | GET | Load a single event by ID |
upload-image | POST | Upload image file |
upload-audio | POST | Upload audio file |
change-password | POST | Change admin password |
Local Development
For local development, use PHP's built-in server with router.php:
php -S localhost:3000 router.php
Both router.php (dev) and route.php (Apache production) read SITE_LANG_DEFAULT from admin/config.php — no hardcoded language anywhere. Changing the primary language in config updates all routing immediately.
How Routing Works
On Apache, .htaccess handles security rules and serves static files directly. Everything else goes to route.php, which handles:
- Primary language root access:
/about→en/about.phpor JSON - Language-prefixed pages:
/de/beispiel→de/beispiel.phpor JSON - News posts:
/news/slugand/en/news/slug - JSON-only pages served via
includes/page.php(no PHP template needed) - 404 fallback
The dev server router.php implements the same logic, plus static file serving and access control for /content/ and /backups/.
Security
Authentication
- Passwords stored as bcrypt hashes (
password_hash()withPASSWORD_DEFAULT) - Sessions use
HttpOnly+SameSite=Strictcookies - Session timeout after 1 hour (configurable)
- CSRF tokens on all POST requests
Brute Force Protection
- IP-based tracking (SHA-256 hashed, stored in
content/login_attempts.json) - 3 free attempts, then progressive delay (+15s per attempt)
- Hard lockout after 20 failed attempts (24 hours)
- Countdown timer shown to user
Content Protection
- HTML sanitization strips event handlers, dangerous URI schemes (
javascript:,data:,vbscript:), and disallowed tags .htaccessblocks direct access tocontent/,backups/, and trash directories- Config files protected via
FilesMatchdirective - File upload validation: MIME type checking, size limits, extension whitelist
- Path traversal prevention on all file operations
Password Requirements
Minimum 8 characters with at least one uppercase letter, one lowercase letter, one digit, and one special character. A warning banner appears after login if the current password doesn't meet these requirements.