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:

en/example.php
<?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:

URLFileNotes
/en/index.phpRoot index.php includes primary lang homepage
/docsen/docs.phpPrimary lang page, accessible from root via route.php
/de/de/index.phpSecondary language with prefix
/de/showcasede/showcase.phpSecondary language subpage
/aboutcontent/pages/en_about.jsonJSON-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:

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):

includes/nav-config.php
$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.

content/pages/en_example.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

FieldTypeDescription
pagestringPage identifier (matches filename without .json)
langstringLanguage code
lastModifiedstring|nullISO 8601 timestamp, set automatically on save
sectionsarrayOrdered list of content sections

Shared Content

Nibbly uses two patterns for multilingual content:

PatternUse CaseExample
Separate files Pages with per-language content en_home.json, de_home.json, es_home.json
Nested language objects Shared data with translations events.json, footer.json

Footer

The footer uses nested language objects in content/pages/footer.json:

content/pages/footer.json
{
    "tagline":  { "en": "Nibbly CMS", "de": "Nibbly CMS", "es": "Nibbly CMS" },
    "services": { "en": "Lightweight Content Management", "de": "Leichtgewichtiges Content Management", "es": "Gestión de contenidos ligera" },
    "contact":  { "phone": "+43 1 234 567", "email": "info@example.com" },
    "credit":   { "text": "Made by", "link": "https://...", "linkText": "..." }
}

Events

Events are stored in content/events.json with nested language objects for translatable fields. Language-agnostic fields (id, date, time, url) exist only once:

content/events.json
{
  "events": [
    {
      "id": "2026-04-07-pytorch-conference",
      "date": "2026-04-07",
      "time": "09:00",
      "url": "https://example.com",
      "title": {
        "en": "PyTorch Conference 2026",
        "de": "PyTorch Conference 2026",
        "es": "PyTorch Conference 2026"
      },
      "location": {
        "en": "Paris, France",
        "de": "Paris, Frankreich",
        "es": "París, Francia"
      },
      "description": { "en": "...", "de": "...", "es": "..." },
      "admission":   { "en": "...", "de": "...", "es": "..." }
    }
  ]
}

Event Functions

FunctionDescription
loadEvents()Loads all events, sorted by date (newest first)
getUpcomingEvents($limit)Returns future events, sorted ascending
getPastEvents($limit)Returns past events, sorted descending
getNextEvent()Returns the single next upcoming event
renderEventList($events, $lang)Renders an event list with admin edit/add buttons
renderEvent($event, $lang)Renders a single event card

Section Types

Nibbly supports 11 built-in section types for standard pages. Each type has its own renderer in includes/block-renderers/.

TypeCategoryKey Fields
textcontenttitle, content (HTML), titleTag, style
headingcontenttext, level (h1–h6), subtitle
quotecontenttext, attribution, style (default/large)
listcontenttitle, style (bullet/numbered), content (HTML)
imagemediasrc, alt, caption, width (full/medium/small)
cardcardstitle, content, image
youtubemediavideoId, title
soundcloudmediatrackId, title
audiomediasrc, title
dividerlayout(none)
spacerlayoutheight (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():

FunctionDescription
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
Usage example
<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:

AttributeElementPurpose
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.

FunctionJSON KeyItem 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/*.jsonNews post cards

List items in JSON must use numbered object keys, not arrays:

Correct format
"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>
JSON
"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

FunctionAuto-writes?JSON format
editableText()YesString value
editableHtml()YesString value
editableImage()Yes{src, alt}
editableLink()Yes{text, href}
editableListItems()NoReturns [] 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:

FunctionDescription
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.

OptionDescription
--slug=NAMEPage slug for URLs (default: from filename)
--lang=CODELanguage code (default: en)
--title=TEXTPage title (default: from <title> or <h1>)
--dry-runPreview output without writing files
--json-onlyOnly generate JSON, no PHP template
--no-cssSkip CSS extraction
--forceOverwrite 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

ClassPurpose
.main-contentMain content area with header offset
.content-innerNarrow centered container (800px)
.content-highlightHighlighted background box for text sections
.cards-gridAuto-layout grid for card sections
.revealScroll-triggered fade-in animation
.stagger-revealStaggered 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:

ComponentClassDescription
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:

  1. 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).
  2. Pulse: After all items have landed, they pulse sequentially from 01 → 10 — scaling up briefly and flashing in the brand color (--color-primary-light in dark mode, --color-primary in 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

TabFunction
Content EditorSelect language and page, edit sections, save with automatic backups, restore previous versions
MessagesView, read, and delete contact form submissions
SettingsChange admin password

API Endpoints

The dashboard and inline editor communicate through admin/api.php. All POST requests require a CSRF token.

ActionMethodDescription
loadGETLoad page content
savePOSTSave page (creates backup)
backupsGETList available backups
restorePOSTRestore a backup
load-eventsGETLoad all events
save-eventPOSTCreate or update an event
delete-eventPOSTDelete an event
load-eventGETLoad a single event by ID
upload-imagePOSTUpload image file
upload-audioPOSTUpload audio file
change-passwordPOSTChange 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: /abouten/about.php or JSON
  • Language-prefixed pages: /de/beispielde/beispiel.php or JSON
  • News posts: /news/slug and /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() with PASSWORD_DEFAULT)
  • Sessions use HttpOnly + SameSite=Strict cookies
  • 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
  • .htaccess blocks direct access to content/, backups/, and trash directories
  • Config files protected via FilesMatch directive
  • 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.