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
│   ├── access-guard.php    Maintenance mode + private pages
│   ├── ai/                 Server-side AI gateway and provider handling
│   ├── backup-helper.php   Full-site backups, retention, remote uploads
│   ├── 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
│   └── backup.php          Cron-friendly full-site backup runner
├── content/
│   ├── events.json         Shared event data (multilingual)
│   ├── settings.json       Site settings (branding, theme, favicon)
│   ├── ai-settings.json    AI provider settings and feature flags
│   ├── ai-usage.json       AI usage counters
│   ├── ai-audit/           AI request audit logs
│   ├── ai-image-history.json Generated image history
│   └── 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/                Page history + full-site backup ZIPs
├── 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)
├── includes/version.php    Core version metadata
├── LICENSE                 Project license
└── 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.

Backups

Nibbly has three backup layers: automatic per-page JSON history, manual full-site ZIP snapshots, and scheduled full-site backups that can be copied to external storage.

Per-Page History

Every save in the inline editor or dashboard stores the previous page JSON in backups/. This is meant for quick content rollback: restore one page without touching templates, images, or the rest of the site.

Manual Full-Site Backups

The dashboard's Backup → Create Backup button creates a ZIP archive of the site, starts a download, and keeps the ZIP in the server-side backup pool. Manual ZIPs are tagged as manual, so scheduled retention and storage limits do not delete them automatically.

Scheduled Backups via Cron

Scheduled backups are run by cli/backup.php. The dashboard stores the policy in content/settings.json; the CLI reads that policy, creates a full-site ZIP, prunes old ZIPs, and uploads enabled remote targets.

In Plesk, create a daily scheduled task at 03:00 and choose Run a PHP script. Use the script path and arguments below. This avoids relying on a php command in Plesk's PATH. In cPanel or shell cron, use the full command as the job body.

Script path: /path/to/site/cli/backup.php
Arguments:   --action=run
Shell cron:  php /path/to/site/cli/backup.php --action=run

The default retention follows a grandfather-father-son pattern: daily, weekly, monthly, and yearly tiers. A hard storage limit can additionally evict the oldest non-manual ZIPs when the backup pool grows too large.

Remote Backup Targets

After a scheduled ZIP is created locally, Nibbly can upload it to external storage. New ZIP files include the site domain in the filename, and remote uploads are placed in a per-site subfolder below the configured remote path so multiple sites can use the same storage account without mixing files. Supported target types:

  • Dropbox — browser-based OAuth connection with refresh token support
  • Google Drive — OAuth connection using the Drive drive.file scope
  • Microsoft OneDrive — OAuth connection through Microsoft Graph
  • SFTP / SCP — server-to-server upload for agencies, VPS, NAS, and classic hosting
  • S3-Compatible Storage — AWS S3, Hetzner Object Storage, Wasabi, MinIO, DigitalOcean Spaces, and similar providers
  • WebDAV — Nextcloud, ownCloud, NAS, and providers with WebDAV endpoints

Dropbox, Google Drive, and OneDrive use a browser login flow from the dashboard. The provider app/client ID is entered once, the admin clicks Connect, and the OAuth callback stores a refresh token so future cron runs can upload without another login.

CLI Reference

CommandPurpose
php cli/backup.php --action=runCreate one ZIP, prune old ZIPs, upload enabled remote targets
php cli/backup.php --action=run --skip-remoteCreate and prune locally, without remote upload
php cli/backup.php --action=pruneApply retention/storage limits without creating a new ZIP
php cli/backup.php --action=statusPrint local and remote backup status
php cli/backup.php --action=listList stored ZIP backups
php cli/backup.php --action=upload-remote --file=example.com-backup-...Retry remote upload for an existing ZIP

Security Notes

  • The backup lock file prevents concurrent runs.
  • Remote secrets are masked in the dashboard.
  • When content/settings.json is written into a ZIP, remote tokens and passwords are scrubbed from the archived copy.
  • Remote upload failures are reported but do not delete the local ZIP.

AI Tools

Nibbly includes optional AI-assisted tools for admins: assistant chat, text generation, prompt improvement, image generation, image-to-image workflows, generated image history, usage limits, and audit logging. AI features are routed through a server-side gateway so API keys are never exposed to the browser.

Provider Gateway

Core AI requests go through includes/ai/ai-helper.php. The gateway reads provider settings from content/ai-settings.json, supports OpenAI-compatible endpoints, and keeps provider credentials server-side.

FilePurpose
content/ai-settings.jsonProvider credentials, model choices, feature flags, quotas, and token/image limits
content/ai-usage.jsonDaily/monthly usage counters and approximate cost tracking
content/ai-audit/YYYY-MM-DD.jsonlAppend-only audit events for AI requests
content/ai-image-history.jsonGenerated image metadata and dashboard history
assets/images/generated/Locally stored generated images

Supported Workflows

  • Assistant chat for admin-side content and site editing support.
  • Text generation for drafts, page copy, SEO snippets, and structured field suggestions.
  • Prompt improvement before image generation.
  • Image generation with selectable models, resolution/orientation controls, and local media storage.
  • Image-to-image workflows with multiple reference images where the selected provider supports them.
  • Multilingual editor translation inside localized editor dialogs when the AI module is enabled.

Security and Privacy Defaults

  • API keys are saved on the server and are not returned by load-ai-settings.
  • Browser code calls Nibbly's admin API, not the AI provider directly.
  • Local/private provider URLs require an explicit local-provider setting before they are accepted.
  • Feature flags can disable AI tools globally or per capability.
  • Usage counters and audit logs are flat files, keeping the no-database architecture intact.

Admin Dashboard

Access the admin panel by double-clicking the year in the footer copyright, or go directly to /admin/.

Hidden Admin Access via Footer

Nibbly hides the login behind a double-click on a designated footer element — by default the year inside the copyright line. The element is marked up via the [id="adminAccess"]…[/id] shortcode in content/pages/footer.json:

content/pages/footer.json
"copyright": "&copy; [id=\"adminAccess\"]2026[/id] Your Company"

Anything between the shortcode tags becomes a <span id="adminAccess">. The footer JavaScript registers a dblclick handler on that span; double-clicking sends the user to /admin/?redirect=<current-path>. After login, the Login setting determines what happens next:

SettingBehaviour
auto (default) The user is redirected back to the page they came from, with the inline editor ready to use.
dashboard The user always lands in the dashboard. A one-shot info banner offers a link back to the source page.

Change the mode in Dashboard → Settings → Login. The redirect URL is sanitised by validateRedirectUrl(): it must be same-origin and may not point inside /admin/, so the shortcode cannot be abused for open redirects.

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
NewsCreate, edit, publish, and unpublish news posts
EventsManage events with multilingual fields
MessagesView, read, and delete contact form submissions
SettingsBranding, theme, language, modules, login behaviour, access controls, email, AI providers, menus, users, password, and danger zone

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
backup-statusGETLoad scheduled backup status, retention, and remote targets
backup-update-settingsPOSTSave scheduled backup and remote target settings
backup-test-remotePOSTTest a remote backup target
backup-*-oauth-startGETStart Dropbox, Google Drive, or OneDrive OAuth connection
ai-chatPOSTSend an assistant chat request through the server-side AI gateway
ai-generate-textPOSTGenerate text with the configured provider and limits
ai-generate-imagePOSTGenerate or edit images and store them locally
load-ai-settingsGETLoad AI settings metadata without exposing API keys
save-ai-settingsPOSTSave provider, model, feature flag, and quota settings
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

Accessibility Best Practices

Nibbly includes accessibility foundations for public websites and admin editing workflows. The goal is not to replace a project-specific audit, but to make the default output easier to navigate with keyboard, screen readers, reduced-motion settings, and robust semantics.

What Core Provides

  • A skip link that jumps to #main-content.
  • Landmark-friendly header, main content, footer, and labelled navigation areas.
  • Mobile menu state via aria-expanded, aria-controls, and aria-hidden.
  • Visible focus styles and reduced-motion handling through prefers-reduced-motion.
  • Accessible editor and media dialogs with dialog roles, labelled titles, focus handling, and live status messages.
  • SEO health checks that also flag images without alt text.

Template Best Practices

When creating custom layouts, keep the Core accessibility hooks intact:

custom template
<main class="main-content" id="main-content">
    <div class="content-inner">
        <h1><?php echo editableText($_p, 'hero.title', 'Page title'); ?></h1>
        <?php echo renderAllSections($_p); ?>
    </div>
</main>
  • Use one visible H1 per page, then H2/H3 levels in logical order.
  • Write useful alt text for informative images; use empty alt text only for decorative images.
  • Use real <button> elements for actions and real links for navigation.
  • Keep labels connected to form fields and place error/help text close to the field.
  • Do not remove focus outlines unless you provide an equally visible replacement.
  • Respect reduced-motion preferences for page-specific animations.
  • Test important pages with keyboard-only navigation before launch.

Access Controls

Nibbly can temporarily lock a whole site for maintenance and protect individual pages with a password. Both features are flat-file friendly: configuration stays in JSON, passwords and bypass secrets are hashed, and no database is required.

Maintenance Mode

Enable maintenance mode in Dashboard → Settings → Access. The visitor-facing lock page can use one of three modes: regular maintenance, back-soon messaging, or launch countdown. Each mode supports custom title/text, an optional unavailable-until time, and an optional countdown.

Logged-in admins and editors bypass maintenance mode automatically, so they can keep editing and previewing the site. For clients or reviewers, configure a bypass parameter and secret key. Example: if the parameter is preview, opening /?preview=secret-value grants access for the current browser session.

content/settings.json
"access": {
  "maintenance": {
    "enabled": true,
    "mode": "maintenance",
    "title": "Maintenance",
    "text": "We will be back online shortly.",
    "until": "2026-05-20T12:00",
    "showCountdown": true,
    "bypassParam": "preview",
    "bypassKeyHash": "$2y$..."
  }
}

Password-Protected Pages

Open a page in the Content Editor and use the Access card to set its visibility to Private. Add a password and, optionally, customize the title and text shown on the password page.

Private pages are enforced server-side. Knowing the URL is not enough to view the content: the page JSON is checked before rendering, successful access is stored per page in the session, and logged-in admins/editors bypass the lock.

content/pages/en_private-page.json
"visibility": {
  "status": "private",
  "title": "Protected page",
  "text": "Enter the password to continue.",
  "passwordHash": "$2y$..."
}

Security Behaviour

  • Maintenance mode returns a 503 response and can send Retry-After when an end time is set.
  • Private pages return a password form until the correct password is submitted.
  • Lock pages use noindex headers so temporary or protected states are not promoted to crawlers.
  • Static assets, admin routes, API routes, robots.txt, and sitemap.xml remain reachable while maintenance mode is active.
  • Do not edit JSON with plaintext secrets. Use the dashboard or store hashes created with password_hash().

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

Bot-Protected Forms

Nibbly's public contact forms are protected without Google reCAPTCHA or other third-party tracking services. The protection is designed for simple PHP hosting and flat-file projects: no database, no external account, no consent banner just for spam prevention.

How It Works

  • Lazy-loaded form HTML — the initial page only contains a placeholder; the real form is fetched from api/form.php after a short delay.
  • Server-generated one-time tokens — every rendered form receives a fresh token stored server-side in content/form-tokens.json.
  • Minimum submit time — forms submitted immediately after token creation are rejected, which blocks many automated direct POST attempts.
  • Honeypot field — a visually hidden field catches bots that fill every input.
  • Rate limiting — repeated submissions and failed checks are throttled via hashed client keys in content/form-rate-limit.json.
  • Conservative content checks — obvious link spam is rejected before it reaches the message inbox.

Using Protection Fields

For custom forms, add the protection helper inside the form and validate the same form id in the submit endpoint:

<?php require_once __DIR__ . '/../includes/form-protection.php'; ?>

<form class="contact-form" action="<?php echo $basePath; ?>api/contact.php" method="post">
    <?php echo nibblyFormProtectionFields('contact'); ?>
    <!-- visible form fields -->
</form>

Lazy-Loading a Form

The built-in contact forms use lazy loading automatically. For additional whitelisted forms, render only a placeholder in the page template:

<?php
echo nibblyLazyFormPlaceholder('contact', [
    'basePath' => $basePath,
    'params' => ['lang' => $currentLang, 'basePath' => $basePath],
]);
?>

The lazy endpoint intentionally only renders known forms. If a project adds a new public form, register it deliberately in api/form.php instead of including arbitrary PHP files from request parameters.

Email Delivery

Contact form messages are always stored locally before delivery is attempted. Email sending can be disabled, handled by PHP mail(), or sent through SMTP.

Settings

Configure mail delivery in Dashboard → Settings → Email. The settings are stored in content/settings.json:

"email": {
  "method": "smtp",
  "recipientEmail": "info@example.com, team@example.com",
  "bccEmail": "archive@example.com",
  "fromEmail": "noreply@example.com",
  "fromName": "Nibbly Website",
  "smtpHost": "smtp.example.com",
  "smtpPort": 587,
  "smtpUsername": "noreply@example.com",
  "smtpPassword": "...",
  "smtpEncryption": "tls"
}

Multiple Recipients and BCC

Primary recipients and BCC recipients accept comma-separated address lists. Nibbly normalizes the list, removes duplicates, rejects invalid addresses, and keeps BCC recipients out of visible message headers.

  • recipientEmail may contain one or more primary recipients.
  • bccEmail may contain optional archive, CRM, or team-copy recipients.
  • SMTP delivery sends each recipient as its own envelope recipient.
  • The PHP mail fallback sends BCC copies separately to avoid exposing blind-copy recipients.

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.

Licensing & Releases

Nibbly is licensed under the Mozilla Public License 2.0 starting with version 1.4.0. Earlier releases up to and including 1.3.2 were published under the MIT License.

What MPL-2.0 Means for Nibbly

  • You may use Nibbly for personal, agency, client, and commercial projects.
  • You may combine Nibbly with project-specific templates, assets, content, and site code under your own terms.
  • If you distribute modified Nibbly Core files, those modified Core files must remain available under MPL-2.0.
  • The license applies to code; project branding, the Nibbly name, and logos are separate from the software license.

Versioning

Nibbly follows Semantic Versioning:

Version PartUsed For
MAJORIncompatible Core/API/content model changes
MINORCompatible new features, such as AI tools or new dashboard modules
PATCHBug fixes, polish, documentation, and safe maintenance updates

Release Availability

Published versions are intended to be tagged on GitHub so older source archives remain available. The Changelog records what changed in each release, while GitHub tags and releases make the exact historical code state downloadable.