Content Management Guide

⚠️ Documentation Under Review: This documentation is currently being updated and verified against the actual implementation. Some information may be incorrect or incomplete. Please verify all code examples against the actual source code before use.

Overview

The Neuron CMS provides a complete content management system with two primary content types:

  1. Blog Posts - Dynamic, time-based content with categories, tags, and Editor.js block editing
  2. Pages - Static/dynamic pages with Editor.js block editing and shortcodes

Both content types use Editor.js for rich block-based content editing and share common features like SEO optimization, publishing workflows, shortcode support, and author attribution.


Blog Posts

Overview

Blog posts are time-based content designed for news, articles, and regular updates. They use Editor.js for block-based content editing and support categorization, tagging, shortcodes, and RSS feed generation.

Post Model

Location: Neuron\Cms\Models\Post

The Post model extends Neuron\Orm\Model and uses PHP 8+ attributes for ORM relationships:

use Neuron\Orm\Model;
use Neuron\Orm\Attributes\Table;
use Neuron\Orm\Attributes\BelongsTo;
use Neuron\Orm\Attributes\BelongsToMany;

#[Table( 'posts' )]
class Post extends Model
{
    #[BelongsTo( User::class, 'author_id' )]
    protected User $author;

    #[BelongsToMany( Category::class, 'post_categories' )]
    protected array $categories;

    #[BelongsToMany( Tag::class, 'post_tags' )]
    protected array $tags;
}

Key Fields:

Relationships:

Post Status Workflow

use Neuron\Cms\Enums\ContentStatus;

Draft (ContentStatus::Draft)

Published (ContentStatus::Published)

Scheduled (ContentStatus::Scheduled)

Creating Posts

Via Admin Interface

  1. Navigate to /admin/posts/create
  2. Enter title and slug (auto-generated from title)
  3. Use Editor.js to create content blocks:
    • Headers (H2, H3, H4)
    • Paragraphs
    • Lists (ordered/unordered)
    • Images
    • Quotes
    • Code blocks
    • Delimiters
    • Raw HTML/Shortcodes
  4. Add excerpt (optional)
  5. Select categories and tags
  6. Choose status (draft/published)
  7. Set featured image (optional)
  8. Click "Create Post"

Via Code

use Neuron\Cms\Services\Post\Creator;
use Neuron\Cms\Models\Post;

$creator = new Creator( $postRepository, $categoryRepository, $tagResolver );

$editorContent = json_encode( [
    'blocks' => [
        [
            'type' => 'header',
            'data' => ['text' => 'My Blog Post', 'level' => 2]
        ],
        [
            'type' => 'paragraph',
            'data' => ['text' => 'This is my post content.']
        ]
    ]
] );

$post = $creator->create( title: 'My Blog Post',
    content: $editorContent,
    authorId: $user->getId(),
    status: ContentStatus::Published,
    excerpt: 'Optional excerpt',
    categoryIds: [1, 2],
    tagNames: 'technology, php, tutorial'
);

Post Services

Creator - Neuron\Cms\Services\Post\Creator

Updater - Neuron\Cms\Services\Post\Updater

Publisher - Neuron\Cms\Services\Post\Publisher

Deleter - Neuron\Cms\Services\Post\Deleter

Category Management

Model: Neuron\Cms\Models\Category

Creating Categories:

use Neuron\Cms\Services\Category\Creator;

$creator = new Creator( $categoryRepository );
$category = $creator->create( name: 'Technology',
    slug: 'technology', // Optional, auto-generated
    description: 'Tech news and updates' );

Assigning to Posts:

$post->setCategories( [$category1, $category2] );
$postRepository->update( $post );

Tag Management

Model: Neuron\Cms\Models\Tag

Creating Tags:

use Neuron\Cms\Services\Tag\Creator;

$creator = new Creator( $tagRepository );
$tag = $creator->create( name: 'PHP',
    slug: 'php' );

Tag Resolution: Tags can be created on-the-fly during post creation by passing tag names that don't exist yet.

Querying Posts

PostRepository Methods:

// Get all published posts
$posts = $postRepository->getPublished( $limit = 10 );

// Get posts by category
$posts = $postRepository->getByCategory( $categoryId, $limit = 10 );

// Get posts by tag
$posts = $postRepository->getByTag( $tagId, $limit = 10 );

// Get posts by author
$posts = $postRepository->getByAuthor( $authorId, $limit = 10 );

// Get single post by slug
$post = $postRepository->findBySlug( 'my-post-slug' );

// Search posts
$posts = $postRepository->search( $query, $limit = 10 );

// Get drafts
$drafts = $postRepository->getDrafts( $authorId );

// Count posts
$count = $postRepository->count( $status = null );

SEO Features

Auto-Slug Generation:

// Automatically converts "My Blog Post" to "my-blog-post"
$slug = $creator->generateSlug( $title );

Meta Tags:

RSS Feed:


Pages

Overview

Pages are static or semi-dynamic content designed for "About", "Contact", "Services" pages, etc. They use Editor.js for block-based content editing and support shortcodes for dynamic content insertion.

Page Model

Location: Neuron\Cms\Models\Page

Key Fields:

Relationships:

Templates:

Creating Pages

Via Admin Interface

  1. Navigate to /admin/pages/create
  2. Enter title and slug
  3. Use Editor.js to create content blocks:
    • Headers (H2, H3, H4)
    • Paragraphs
    • Lists (ordered/unordered)
    • Images
    • Quotes
    • Code blocks
    • Delimiters
    • Raw HTML/Shortcodes
  4. Insert shortcodes for dynamic content
  5. Choose template
  6. Set status and SEO metadata
  7. Click "Create Page"

Via Code

use Neuron\Cms\Services\Page\Creator;
use Neuron\Cms\Models\Page;

$creator = new Creator( $pageRepository, $eventDispatcher );

$editorContent = json_encode( [
    'blocks' => [
        [
            'type' => 'header',
            'data' => ['text' => 'Welcome', 'level' => 2]
        ],
        [
            'type' => 'paragraph',
            'data' => ['text' => 'This is my page content.']
        ]
    ]
] );

$page = $creator->create( title: 'About Us',
    content: $editorContent,
    authorId: $user->getId(),
    status: Page::STATUS_PUBLISHED,
    template: Page::TEMPLATE_DEFAULT
);

Editor.js Block Types

Both Pages and Blog Posts use Editor.js for content editing with the following supported blocks:

  1. Header - H2, H3, H4 headings
  2. Paragraph - Text paragraphs with inline formatting
  3. List - Ordered or unordered lists
  4. Image - Images with captions
  5. Quote - Blockquotes with attribution
  6. Code - Code blocks with syntax highlighting
  7. Delimiter - Visual separators
  8. Raw - Raw HTML or shortcodes

Content Structure:

{
  "blocks": [
    {
      "type": "header",
      "data": {
        "text": "Page Title",
        "level": 2
      }
    },
    {
      "type": "paragraph",
      "data": {
        "text": "Your content here..."
      }
    }
  ]
}

Rendering Content

Both Pages and Posts are rendered server-side for SEO:

use Neuron\Cms\Controllers\Pages;
use Neuron\Cms\Services\Content\EditorJsRenderer;

// Pages controller
$page = $this->pageRepository->findBySlug( $slug );
$content = $page->getContent(); // Returns array from JSON
$html = $this->editorRenderer->render( $content );

// Blog controller (Posts)
$post = $this->postRepository->findBySlug( $slug );
$content = $post->getContent(); // Returns array from JSON
$html = $this->editorRenderer->render( $content );

The EditorJsRenderer converts Editor.js JSON to Bootstrap-styled HTML with shortcode support.


Shortcodes & Widgets

Overview

Shortcodes allow you to embed dynamic content within both Pages and Blog Posts using a simple [shortcode] syntax, similar to WordPress.

Example:

[latest-posts limit="5"]

[contact-form recipient="[email protected]" title="Contact Us"]

[pricing-table]

How Shortcodes Work

  1. Author inserts shortcode in Editor.js "Raw" block or paragraph
  2. Content is saved as JSON with shortcode intact
  3. On page render, EditorJsRenderer passes content through ShortcodeParser
  4. ShortcodeParser finds shortcodes and calls registered widget handlers
  5. Widgets render to HTML
  6. Final HTML is displayed to user

Architecture:

Page Content (Editor.js JSON)
    ↓
EditorJsRenderer parses blocks
    ↓
Finds shortcodes like [widget attr="value"]
    ↓
ShortcodeParser extracts shortcode + attributes
    ↓
WidgetRegistry finds registered widget
    ↓
Widget renders to HTML
    ↓
Final HTML output

Built-in Widgets

Latest Posts Widget

Shortcode: [latest-posts]

Attributes:

Examples:

[latest-posts limit="3"]
[latest-posts limit="10" category="technology"]

Output: Bootstrap card grid with post titles, excerpts, dates, and links to full articles

Calendar Widget

Shortcode: [calendar]

Attributes:

Examples:

[calendar]
[calendar limit="10"]
[calendar upcoming="false" limit="5"]
[calendar category="conferences" limit="3"]

Output: Unordered list of events with titles, dates, times, and locations. Each event links to its detail page at /calendar/event/{slug}.

Features:

CSS Classes:

Creating Custom Widgets

1. Create Widget Class

<?php

namespace App\Widgets;

use Neuron\Cms\Services\Widget\Widget;

class MapWidget extends Widget
{
    public function getName(): string
    {
        return 'map';
    }

    public function render( array $attrs ): string
    {
        $address = $this->attr( $attrs, 'address', '' );
        $zoom = $this->attr( $attrs, 'zoom', 15 );
        $height = $this->attr( $attrs, 'height', 400 );

        if (empty( $address )) {
            return '<p class="alert alert-warning">Map widget requires an address</p>';
        }

        $encodedAddress = urlencode( $address );

        return "
            <div class='map-widget mb-4'>
                <iframe
                    src='https://maps.google.com/maps?q={$encodedAddress}&z={$zoom}&output=embed'
                    width='100%'
                    height='{$height}'
                    frameborder='0'
                    style='border:0;'
                    allowfullscreen>
                </iframe>
            </div>
        ";
    }

    public function getDescription(): string
    {
        return 'Embeds a Google Map with the specified address';
    }

    public function getAttributes(): array
    {
        return [
            'address' => 'Street address to display',
            'zoom' => 'Zoom level (1-20, default: 15)',
            'height' => 'Map height in pixels (default: 400)'
        ];
    }
}

2. Register Widget

In your application initializer or bootstrap file:

use Neuron\Patterns\Registry;
use App\Widgets\MapWidget;

$widgetRegistry = Registry::getInstance()->get( 'WidgetRegistry' );
$widgetRegistry->register( new MapWidget());

3. Use in Pages

[map address="1600 Amphitheatre Parkway, Mountain View, CA" zoom="14"]

Widget Helper Methods

The Widget base class provides helper methods:

attr($attrs, $key, $default = null)

sanitizeHtml($html)

view($template, $data = [])

Advanced Widget Example

<?php

namespace App\Widgets;

use Neuron\Cms\Services\Widget\Widget;
use Neuron\Cms\Repositories\IPostRepository;

class FeaturedPostsWidget extends Widget
{
    private IPostRepository $postRepository;

    public function __construct( IPostRepository $postRepository )
    {
        $this->postRepository = $postRepository;
    }

    public function getName(): string
    {
        return 'featured-posts';
    }

    public function render( array $attrs ): string
    {
        $limit = (int) $this->attr( $attrs, 'limit', 3 );
        $category = $this->attr( $attrs, 'category' );

        // Get posts
        if( $category )
{
            $posts = $this->postRepository->getByCategory( $category, $limit );
        }
else {
            $posts = $this->postRepository->getPublished( $limit );
        }

        if (empty( $posts )) {
            return '';
        }

        $html = '<div class="featured-posts row">';

        foreach( $posts as $post )
{
            $title = htmlspecialchars( $post->getTitle());
            $excerpt = htmlspecialchars( $post->getExcerpt() ?? '');
            $url = route_path( 'blog_post', ['slug' => $post->getSlug()]);
            $image = $post->getFeaturedImage()
                ? htmlspecialchars( $post->getFeaturedImage())
                : '/images/placeholder.jpg';

            $html .= "
                <div class='col-md-4 mb-4'>
                    <div class='card h-100'>
                        <img src='{$image}' class='card-img-top' alt='{$title}'>
                        <div class='card-body'>
                            <h3 class='card-title h5'>{$title}</h3>
                            <p class='card-text'>{$excerpt}</p>
                            <a href='{$url}' class='btn btn-primary'>Read More</a>
                        </div>
                    </div>
                </div>
            ";
        }

        $html .= '</div>';

        return $html;
    }

    public function getDescription(): string
    {
        return 'Displays featured blog posts in a card grid';
    }

    public function getAttributes(): array
    {
        return [
            'limit' => 'Number of posts to display (default: 3)',
            'category' => 'Filter by category slug (optional)'
        ];
    }
}

Widget Registry

Location: Neuron\Cms\Services\Widget\WidgetRegistry

Methods:

// Register a widget
$registry->register( IWidget $widget ): void

// Get widget by name
$widget = $registry->get( string $name ): ?IWidget

// Check if widget exists
$exists = $registry->has( string $name ): bool

// Get all registered widgets
$widgets = $registry->all(): array

Shortcode Parser

Location: Neuron\Cms\Services\Content\ShortcodeParser

Custom Handlers:

You can register custom shortcode handlers without creating a full widget:

use Neuron\Cms\Services\Content\ShortcodeParser;

$parser = new ShortcodeParser();

$parser->register( 'youtube', function($attrs ) {
    $videoId = $attrs['id'] ?? '';
    if (empty( $videoId )) {
        return '';
    }

    return "
        <div class='ratio ratio-16x9'>
            <iframe src='https://www.youtube.com/embed/{$videoId}' allowfullscreen></iframe>
        </div>
    ";
});

// Use: [youtube id="dQw4w9WgXcQ"]

SEO Best Practices

Slug Generation

Both posts and pages auto-generate SEO-friendly slugs from titles:

Algorithm:

  1. Convert to lowercase
  2. Replace non-alphanumeric characters (except hyphens) with hyphens
  3. Collapse multiple consecutive hyphens into one
  4. Trim hyphens from start and end

Non-ASCII Fallback:

For titles containing only non-ASCII characters (e.g., Chinese "你好", Arabic "مرحبا", Russian "Привет"), the normal slug generation would produce an empty string. To prevent routing errors and database constraint violations, the system generates a unique fallback slug using uniqid():

Examples:

Title Generated Slug
"Hello World" hello-world
"Getting Started Guide" getting-started-guide
"10 Tips & Tricks!" 10-tips-tricks
"你好世界" (Chinese) page-65a3b2c1f4e8d (fallback)
"مرحبا بك" (Arabic) post-65a3b2c1f4e8d (fallback)
"Hello 你好 World" hello-world (mixed, keeps ASCII)

Custom Slugs:

You can override auto-generated slugs by providing a custom slug when creating or updating content. Custom slugs are not validated for ASCII-only content, so ensure they are URL-safe.

Meta Tags

Posts:

Pages:

Sitemap Generation

Generate XML sitemap with all published content:

$posts = $postRepository->getPublished( 1000 );
$pages = $pageRepository->getPublished( 1000 );

// Generate sitemap XML...

Canonical URLs

Always use absolute URLs for canonical tags:

<link rel="canonical" href="<?= $siteUrl ?>/blog/post/<?= $post->getSlug() ?>">

Content Versioning

Future Enhancement: Content versioning is not yet implemented but could include:


Media Management

Image Uploads

Configure upload endpoint for Editor.js image tool:

// In Admin controller
public function uploadImage( Request $request ): string
{
    $file = $request->files( 'image' );

    // Validate
    if (!in_array( $file['type'], ['image/jpeg', 'image/png', 'image/gif'] )) {
        return json_encode( ['success' => 0, 'message' => 'Invalid file type'] );
    }

    // Save to storage
    $filename = uniqid( 'img_' ) . '.' . pathinfo( $file['name'], PATHINFO_EXTENSION );
    $path = '/uploads/' . date( 'Y/m/' ) . $filename;

    move_uploaded_file( $file['tmp_name'], PUBLIC_PATH . $path );

    return json_encode( [
        'success' => 1,
        'file' => [
            'url' => $path
        ]
    ] );
}

Security Best Practices

Embedding JSON in JavaScript

When embedding server-side JSON data in JavaScript (e.g., loading Editor.js content), always use proper escaping to prevent XSS vulnerabilities and syntax errors:

❌ Unsafe (DO NOT DO THIS):

<script>
const existingContent = <?= $page->getContentRaw() ?>; // DANGEROUS!
</script>

Problems with unsafe approach:

✅ Safe Approach:

<script>
// 1. Encode as JavaScript string with HTML hex flags
const existingContentJson = <?= json_encode( $page->getContentRaw(),
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;

// 2. Parse on client side with error handling
let existingContent;
try {
    existingContent = JSON.parse( existingContentJson );
} catch( error )
{
    console.error( 'Failed to parse existing content:', error );
    existingContent = { blocks: [] }; // Fallback
}

// 3. Use safely parsed data
const editor = new EditorJS( {
    data: existingContent,
    // ... other config
} );
</script>

Why this is safe:

Content Sanitization

Always sanitize user-generated HTML content:

use Neuron\Cms\Services\Content\EditorJsRenderer;

$renderer = new EditorJsRenderer( $shortcodeParser );
$html = $renderer->render( $page->getContent());

// The EditorJsRenderer automatically sanitizes HTML by:
// 1. Escaping user input with htmlspecialchars()
// 2. Allowing only safe HTML tags in specific blocks
// 3. Stripping dangerous attributes (onclick, onerror, etc.)
// 4. Validating header levels (clamped to 1-6 range)

Input Validation in Renderers

When accepting structured data (like Editor.js blocks), always validate values before using them in HTML:

❌ Unsafe - Direct Interpolation:

$level = $data['level'] ?? 2;
return "<h{$level}>text</h{$level}>";  // DANGEROUS!

Problems:

✅ Safe - Validated Integer:

// Coerce to int and clamp to valid range
$rawLevel = $data['level'] ?? 2;
$level = max( 1, min(6, intval($rawLevel )));
return "<h{$level}>text</h{$level}>";  // SAFE!

How it works:

SQL Injection Prevention

Always use parameterized queries (already enforced by repositories):

// ✅ Safe - parameterized query
$stmt = $pdo->prepare( 'SELECT * FROM pages WHERE slug = :slug' );
$stmt->execute( ['slug' => $slug] );

// ❌ Unsafe - DO NOT DO THIS
$stmt = $pdo->query( "SELECT * FROM pages WHERE slug = '$slug'" );

CSRF Protection

All state-changing forms include CSRF tokens (already implemented):

<form method="POST" action="<?= route_path( 'admin_pages_store' ) ?>">
    <?= csrf_field() ?> <!-- REQUIRED for POST/PUT/DELETE -->
    <!-- ... form fields ... -->
</form>

Authorization Checks

Always verify user permissions in controllers:

// Check if user can edit this page
if ($page->getAuthorId() !== $user->getId() && !$user->isAdmin()) {
    $this->redirect( 'admin_pages', [], ['error', 'Unauthorized'] );
}

Troubleshooting

Pages Not Rendering

Issue: Page returns 404 Solution:

Shortcodes Not Working

Issue: Shortcode displays as text [latest-posts] Solution:

Editor.js Content Not Saving

Issue: Content disappears after save Solution:

Slug Already Exists Error

Issue: Cannot create page/post with existing slug Solution:

Foreign Key Constraint Error

Issue: Cannot delete user who authored content Solution:


Performance Optimization

Caching

Enable view caching for pages:

// In config
'cache' => [
    'enabled' => true,
    'ttl' => 3600, // 1 hour
    'storage' => 'file',
    'path' => 'storage/cache/views'
]

Eager Loading

Avoid N+1 queries by eager loading relationships:

// In PostRepository
$posts = $this->findAllWithRelations( ['author', 'categories', 'tags'] );

Database Indexes

All high-frequency queries have indexes:


Related Documentation