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:
<?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.
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.filescope - 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
| Command | Purpose |
|---|---|
php cli/backup.php --action=run | Create one ZIP, prune old ZIPs, upload enabled remote targets |
php cli/backup.php --action=run --skip-remote | Create and prune locally, without remote upload |
php cli/backup.php --action=prune | Apply retention/storage limits without creating a new ZIP |
php cli/backup.php --action=status | Print local and remote backup status |
php cli/backup.php --action=list | List 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.jsonis 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.
| File | Purpose |
|---|---|
content/ai-settings.json | Provider credentials, model choices, feature flags, quotas, and token/image limits |
content/ai-usage.json | Daily/monthly usage counters and approximate cost tracking |
content/ai-audit/YYYY-MM-DD.jsonl | Append-only audit events for AI requests |
content/ai-image-history.json | Generated 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:
"copyright": "© [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:
| Setting | Behaviour |
|---|---|
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
| Tab | Function |
|---|---|
| Content Editor | Select language and page, edit sections, save with automatic backups, restore previous versions |
| News | Create, edit, publish, and unpublish news posts |
| Events | Manage events with multilingual fields |
| Messages | View, read, and delete contact form submissions |
| Settings | Branding, 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.
| Action | Method | Description |
|---|---|---|
load | GET | Load page content |
save | POST | Save page (creates backup) |
backups | GET | List available backups |
restore | POST | Restore a backup |
backup-status | GET | Load scheduled backup status, retention, and remote targets |
backup-update-settings | POST | Save scheduled backup and remote target settings |
backup-test-remote | POST | Test a remote backup target |
backup-*-oauth-start | GET | Start Dropbox, Google Drive, or OneDrive OAuth connection |
ai-chat | POST | Send an assistant chat request through the server-side AI gateway |
ai-generate-text | POST | Generate text with the configured provider and limits |
ai-generate-image | POST | Generate or edit images and store them locally |
load-ai-settings | GET | Load AI settings metadata without exposing API keys |
save-ai-settings | POST | Save provider, model, feature flag, and quota settings |
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 |
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, andaria-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:
<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.
"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.
"visibility": {
"status": "private",
"title": "Protected page",
"text": "Enter the password to continue.",
"passwordHash": "$2y$..."
}
Security Behaviour
- Maintenance mode returns a
503response and can sendRetry-Afterwhen an end time is set. - Private pages return a password form until the correct password is submitted.
- Lock pages use
noindexheaders so temporary or protected states are not promoted to crawlers. - Static assets, admin routes, API routes,
robots.txt, andsitemap.xmlremain 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:
/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/.
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.phpafter 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.
recipientEmailmay contain one or more primary recipients.bccEmailmay 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()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.
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 Part | Used For |
|---|---|
MAJOR | Incompatible Core/API/content model changes |
MINOR | Compatible new features, such as AI tools or new dashboard modules |
PATCH | Bug 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.