= htmlspecialchars($Page->getTitle()) ?>
+ + getPublishedAt()): ?> ++
diff --git a/resources/config/routes.yaml b/resources/config/routes.yaml index 68655a0..e875b3d 100644 --- a/resources/config/routes.yaml +++ b/resources/config/routes.yaml @@ -214,6 +214,43 @@ routes: controller: Neuron\Cms\Controllers\Admin\Tags@destroy filter: auth + # Page Management + admin_pages: + method: GET + route: /admin/pages + controller: Neuron\Cms\Controllers\Admin\Pages@index + filter: auth + + admin_pages_create: + method: GET + route: /admin/pages/create + controller: Neuron\Cms\Controllers\Admin\Pages@create + filter: auth + + admin_pages_store: + method: POST + route: /admin/pages + controller: Neuron\Cms\Controllers\Admin\Pages@store + filter: auth + + admin_pages_edit: + method: GET + route: /admin/pages/:id/edit + controller: Neuron\Cms\Controllers\Admin\Pages@edit + filter: auth + + admin_pages_update: + method: PUT + route: /admin/pages/:id + controller: Neuron\Cms\Controllers\Admin\Pages@update + filter: auth + + admin_pages_destroy: + method: DELETE + route: /admin/pages/:id + controller: Neuron\Cms\Controllers\Admin\Pages@destroy + filter: auth + # Homepage home: method: GET @@ -251,6 +288,12 @@ routes: route: /rss controller: Neuron\Cms\Controllers\Blog@feed + # Public Pages + page_show: + method: GET + route: /pages/:slug + controller: Neuron\Cms\Controllers\Pages@show + # Member Registration Routes register: method: GET diff --git a/resources/database/migrate/20250113000000_create_pages_table.php b/resources/database/migrate/20250113000000_create_pages_table.php new file mode 100644 index 0000000..bc66ad4 --- /dev/null +++ b/resources/database/migrate/20250113000000_create_pages_table.php @@ -0,0 +1,37 @@ +table( 'pages' ); + + $table->addColumn( 'title', 'string', [ 'limit' => 255, 'null' => false ] ) + ->addColumn( 'slug', 'string', [ 'limit' => 255, 'null' => false ] ) + ->addColumn( 'content', 'text', [ 'null' => false, 'comment' => 'Editor.js JSON content' ] ) + ->addColumn( 'template', 'string', [ 'limit' => 50, 'default' => 'default' ] ) + ->addColumn( 'meta_title', 'string', [ 'limit' => 255, 'null' => true ] ) + ->addColumn( 'meta_description', 'text', [ 'null' => true ] ) + ->addColumn( 'meta_keywords', 'string', [ 'limit' => 255, 'null' => true ] ) + ->addColumn( 'author_id', 'integer', [ 'null' => false ] ) + ->addColumn( 'status', 'string', [ 'limit' => 20, 'default' => 'draft' ] ) + ->addColumn( 'published_at', 'timestamp', [ 'null' => true ] ) + ->addColumn( 'view_count', 'integer', [ 'default' => 0 ] ) + ->addColumn( 'created_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP' ] ) + ->addColumn( 'updated_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP', 'update' => 'CURRENT_TIMESTAMP' ] ) + ->addIndex( [ 'slug' ], [ 'unique' => true ] ) + ->addIndex( [ 'author_id' ] ) + ->addIndex( [ 'status' ] ) + ->addIndex( [ 'published_at' ] ) + ->addForeignKey( 'author_id', 'users', 'id', [ 'delete' => 'CASCADE', 'update' => 'CASCADE' ] ) + ->create(); + } +} diff --git a/resources/database/migrations/CreateCategoriesTable.php b/resources/database/migrate/20250114000000_create_categories_table.php similarity index 100% rename from resources/database/migrations/CreateCategoriesTable.php rename to resources/database/migrate/20250114000000_create_categories_table.php diff --git a/resources/database/migrations/CreateTagsTable.php b/resources/database/migrate/20250115000000_create_tags_table.php similarity index 100% rename from resources/database/migrations/CreateTagsTable.php rename to resources/database/migrate/20250115000000_create_tags_table.php diff --git a/resources/database/migrations/CreatePostsTable.php b/resources/database/migrate/20250116000000_create_posts_table.php similarity index 100% rename from resources/database/migrations/CreatePostsTable.php rename to resources/database/migrate/20250116000000_create_posts_table.php diff --git a/resources/database/migrations/CreatePostCategoriesTable.php b/resources/database/migrate/20250117000000_create_post_categories_table.php similarity index 100% rename from resources/database/migrations/CreatePostCategoriesTable.php rename to resources/database/migrate/20250117000000_create_post_categories_table.php diff --git a/resources/database/migrations/CreatePostTagsTable.php b/resources/database/migrate/20250118000000_create_post_tags_table.php similarity index 100% rename from resources/database/migrations/CreatePostTagsTable.php rename to resources/database/migrate/20250118000000_create_post_tags_table.php diff --git a/resources/views/admin/pages/create.php b/resources/views/admin/pages/create.php new file mode 100644 index 0000000..83cd8d6 --- /dev/null +++ b/resources/views/admin/pages/create.php @@ -0,0 +1,195 @@ +
No pages yet.
+ Create Your First Page +| Title | +Slug | +Status | +Author | +Template | +Views | +Updated | +Actions | +
|---|---|---|---|---|---|---|---|
| + = htmlspecialchars($page->getTitle()) ?> + | +
+ /pages/= htmlspecialchars($page->getSlug()) ?>
+ |
+ + + = htmlspecialchars(ucfirst($page->getStatus()), ENT_QUOTES, 'UTF-8') ?> + + | ++ = $page->getAuthor() + ? htmlspecialchars($page->getAuthor()->getUsername(), ENT_QUOTES, 'UTF-8') + : 'N/A' ?> + | += htmlspecialchars($page->getTemplate()) ?> | += $page->getViewCount() ?> | ++ getUpdatedAt()): ?> + = $page->getUpdatedAt()->format('M j, Y') ?> + + Never + + | ++ + | +
{$text}
\n"; + } + + /** + * Render list block + */ + private function renderList( array $data ): string + { + $style = $data['style'] ?? 'unordered'; + $items = $data['items'] ?? []; + + $tag = $style === 'ordered' ? 'ol' : 'ul'; + + $html = "<{$tag} class='mb-3'>\n"; + foreach( $items as $item ) + { + $html .= "\n"; + $html .= "\n"; + + return $html; + } + + /** + * Render code block + */ + private function renderCode( array $data ): string + { + $code = htmlspecialchars( $data['code'] ?? '' ); + + return "{$text}
\n"; + if( $caption ) + { + $html .= " \n"; + } + $html .= "
{$code}\n";
+ }
+
+ /**
+ * Render delimiter block
+ */
+ private function renderDelimiter( array $data ): string
+ {
+ return "';
+
+ return strip_tags( $html, $allowedTags );
+ }
+}
diff --git a/src/Cms/Services/Content/ShortcodeParser.php b/src/Cms/Services/Content/ShortcodeParser.php
new file mode 100644
index 0000000..63ff1b9
--- /dev/null
+++ b/src/Cms/Services/Content/ShortcodeParser.php
@@ -0,0 +1,178 @@
+_widgetRenderer = $widgetRenderer;
+ }
+
+ /**
+ * Register a custom shortcode handler
+ *
+ * @param string $shortcode The shortcode name
+ * @param callable $handler Function that receives attributes and returns HTML
+ */
+ public function register( string $shortcode, callable $handler ): void
+ {
+ $this->_customHandlers[$shortcode] = $handler;
+ }
+
+ /**
+ * Unregister a shortcode
+ *
+ * @param string $shortcode The shortcode name
+ */
+ public function unregister( string $shortcode ): void
+ {
+ unset( $this->_customHandlers[$shortcode] );
+ }
+
+ /**
+ * Check if shortcode is registered
+ *
+ * @param string $shortcode The shortcode name
+ * @return bool
+ */
+ public function hasShortcode( string $shortcode ): bool
+ {
+ return isset( $this->_customHandlers[$shortcode] )
+ || $this->hasBuiltInShortcode( $shortcode );
+ }
+
+ /**
+ * Parse shortcodes in content
+ *
+ * @param string $content Content with shortcodes like [calendar id="main" max="10"]
+ * @return string Content with shortcodes replaced by rendered widgets
+ */
+ public function parse( string $content ): string
+ {
+ // Match [shortcode attr="value" attr2="value2"]
+ // Supports hyphens in shortcode names and attribute names
+ $pattern = '/\[([\w-]+)((?:\s+[\w-]+=["\'][^"\']*["\'])*)\]/';
+
+ return preg_replace_callback( $pattern, function( $matches )
+ {
+ $shortcode = $matches[1];
+ $attrString = $matches[2] ?? '';
+
+ // Parse attributes
+ $attrs = $this->parseAttributes( $attrString );
+
+ // Render
+ return $this->renderShortcode( $shortcode, $attrs );
+
+ }, $content );
+ }
+
+ /**
+ * Parse attribute string into array
+ *
+ * @param string $attrString String like: id="main" max="10"
+ * @return array Associative array of attributes
+ */
+ private function parseAttributes( string $attrString ): array
+ {
+ $attrs = [];
+
+ // Match attr="value" or attr='value'
+ // Supports hyphens in attribute names (e.g., data-id="value")
+ preg_match_all( '/([\w-]+)=["\']([^"\']*)["\']/', $attrString, $matches, PREG_SET_ORDER );
+
+ foreach( $matches as $match )
+ {
+ $key = $match[1];
+ $value = $match[2];
+
+ // Convert string booleans
+ if( $value === 'true' )
+ {
+ $value = true;
+ }
+ elseif( $value === 'false' )
+ {
+ $value = false;
+ }
+ // Convert numeric strings
+ elseif( is_numeric( $value ) )
+ {
+ $value = strpos( $value, '.' ) !== false ? (float)$value : (int)$value;
+ }
+
+ $attrs[$key] = $value;
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * Render a specific shortcode
+ *
+ * @param string $shortcode Shortcode name
+ * @param array $attrs Parsed attributes
+ * @return string Rendered HTML or error comment
+ */
+ private function renderShortcode( string $shortcode, array $attrs ): string
+ {
+ // Check custom handlers first
+ if( isset( $this->_customHandlers[$shortcode] ) )
+ {
+ try
+ {
+ return call_user_func( $this->_customHandlers[$shortcode], $attrs );
+ }
+ catch( \Exception $e )
+ {
+ error_log( "Custom shortcode error [{$shortcode}]: " . $e->getMessage() );
+ return "";
+ }
+ }
+
+ // Fall back to built-in shortcodes
+ if( $this->_widgetRenderer )
+ {
+ try
+ {
+ return match( $shortcode )
+ {
+ 'latest-posts' => $this->_widgetRenderer->render( 'latest-posts', $attrs ),
+ default => ""
+ };
+ }
+ catch( \Exception $e )
+ {
+ error_log( "Shortcode error [{$shortcode}]: " . $e->getMessage() );
+ return "";
+ }
+ }
+
+ return "";
+ }
+
+ /**
+ * Check if shortcode is a built-in widget
+ *
+ * @param string $shortcode Shortcode name
+ * @return bool
+ */
+ private function hasBuiltInShortcode( string $shortcode ): bool
+ {
+ return $shortcode === 'latest-posts';
+ }
+}
diff --git a/src/Cms/Services/Page/Creator.php b/src/Cms/Services/Page/Creator.php
new file mode 100644
index 0000000..4465b1d
--- /dev/null
+++ b/src/Cms/Services/Page/Creator.php
@@ -0,0 +1,96 @@
+_pageRepository = $pageRepository;
+ }
+
+ /**
+ * Create a new page
+ *
+ * @param string $title Page title
+ * @param string $content Editor.js JSON content
+ * @param int $authorId Author user ID
+ * @param string $status Page status (draft, published)
+ * @param string|null $slug Optional custom slug (auto-generated if not provided)
+ * @param string $template Template name
+ * @param string|null $metaTitle SEO meta title
+ * @param string|null $metaDescription SEO meta description
+ * @param string|null $metaKeywords SEO meta keywords
+ * @return Page
+ */
+ public function create(
+ string $title,
+ string $content,
+ int $authorId,
+ string $status,
+ ?string $slug = null,
+ string $template = Page::TEMPLATE_DEFAULT,
+ ?string $metaTitle = null,
+ ?string $metaDescription = null,
+ ?string $metaKeywords = null
+ ): Page
+ {
+ $page = new Page();
+ $page->setTitle( $title );
+ $page->setSlug( $slug ?: $this->generateSlug( $title ) );
+ $page->setContent( $content );
+ $page->setTemplate( $template );
+ $page->setMetaTitle( $metaTitle );
+ $page->setMetaDescription( $metaDescription );
+ $page->setMetaKeywords( $metaKeywords );
+ $page->setAuthorId( $authorId );
+ $page->setStatus( $status );
+ $page->setCreatedAt( new DateTimeImmutable() );
+
+ // Business rule: auto-set published date for published pages
+ if( $status === Page::STATUS_PUBLISHED )
+ {
+ $page->setPublishedAt( new DateTimeImmutable() );
+ }
+
+ return $this->_pageRepository->create( $page );
+ }
+
+ /**
+ * Generate URL-friendly slug from title
+ *
+ * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"),
+ * generates a fallback slug using uniqid().
+ *
+ * @param string $title
+ * @return string
+ */
+ private function generateSlug( string $title ): string
+ {
+ $slug = strtolower( trim( $title ) );
+ $slug = preg_replace( '/[^a-z0-9-]/', '-', $slug );
+ $slug = preg_replace( '/-+/', '-', $slug );
+ $slug = trim( $slug, '-' );
+
+ // Fallback for titles with no ASCII characters
+ if( $slug === '' )
+ {
+ $slug = 'page-' . uniqid();
+ }
+
+ return $slug;
+ }
+}
diff --git a/src/Cms/Services/Page/Deleter.php b/src/Cms/Services/Page/Deleter.php
new file mode 100644
index 0000000..879aa8e
--- /dev/null
+++ b/src/Cms/Services/Page/Deleter.php
@@ -0,0 +1,39 @@
+_pageRepository = $pageRepository;
+ }
+
+ /**
+ * Delete a page
+ *
+ * @param Page $page Page to delete
+ * @return bool True if deleted successfully
+ */
+ public function delete( Page $page ): bool
+ {
+ if( !$page->getId() )
+ {
+ return false;
+ }
+
+ return $this->_pageRepository->delete( $page->getId() );
+ }
+}
diff --git a/src/Cms/Services/Page/Updater.php b/src/Cms/Services/Page/Updater.php
new file mode 100644
index 0000000..dd3c3b0
--- /dev/null
+++ b/src/Cms/Services/Page/Updater.php
@@ -0,0 +1,74 @@
+_pageRepository = $pageRepository;
+ }
+
+ /**
+ * Update an existing page
+ *
+ * @param Page $page Page to update
+ * @param string $title New title
+ * @param string $content New Editor.js JSON content
+ * @param string $status New status
+ * @param string|null $slug New slug (optional)
+ * @param string $template Template name
+ * @param string|null $metaTitle SEO meta title
+ * @param string|null $metaDescription SEO meta description
+ * @param string|null $metaKeywords SEO meta keywords
+ * @return bool True if updated successfully
+ */
+ public function update(
+ Page $page,
+ string $title,
+ string $content,
+ string $status,
+ ?string $slug = null,
+ string $template = Page::TEMPLATE_DEFAULT,
+ ?string $metaTitle = null,
+ ?string $metaDescription = null,
+ ?string $metaKeywords = null
+ ): bool
+ {
+ $page->setTitle( $title );
+ $page->setContent( $content );
+ $page->setStatus( $status );
+ $page->setTemplate( $template );
+ $page->setMetaTitle( $metaTitle );
+ $page->setMetaDescription( $metaDescription );
+ $page->setMetaKeywords( $metaKeywords );
+
+ if( $slug )
+ {
+ $page->setSlug( $slug );
+ }
+
+ // Business rule: set published date when status changes to published
+ if( $status === Page::STATUS_PUBLISHED && !$page->getPublishedAt() )
+ {
+ $page->setPublishedAt( new DateTimeImmutable() );
+ }
+
+ $page->setUpdatedAt( new DateTimeImmutable() );
+
+ return $this->_pageRepository->update( $page );
+ }
+}
diff --git a/src/Cms/Services/Post/Creator.php b/src/Cms/Services/Post/Creator.php
index 666adde..afe0df6 100644
--- a/src/Cms/Services/Post/Creator.php
+++ b/src/Cms/Services/Post/Creator.php
@@ -88,6 +88,9 @@ public function create(
/**
* Generate URL-friendly slug from title
*
+ * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"),
+ * generates a fallback slug using uniqid().
+ *
* @param string $title
* @return string
*/
@@ -96,6 +99,14 @@ private function generateSlug( string $title ): string
$slug = strtolower( trim( $title ) );
$slug = preg_replace( '/[^a-z0-9-]/', '-', $slug );
$slug = preg_replace( '/-+/', '-', $slug );
- return trim( $slug, '-' );
+ $slug = trim( $slug, '-' );
+
+ // Fallback for titles with no ASCII characters
+ if( $slug === '' )
+ {
+ $slug = 'post-' . uniqid();
+ }
+
+ return $slug;
}
}
diff --git a/src/Cms/Services/Post/Updater.php b/src/Cms/Services/Post/Updater.php
index e7d1796..3ed0eb5 100644
--- a/src/Cms/Services/Post/Updater.php
+++ b/src/Cms/Services/Post/Updater.php
@@ -85,6 +85,9 @@ public function update(
/**
* Generate URL-friendly slug from title
*
+ * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"),
+ * generates a fallback slug using uniqid().
+ *
* @param string $title
* @return string
*/
@@ -93,6 +96,14 @@ private function generateSlug( string $title ): string
$slug = strtolower( trim( $title ) );
$slug = preg_replace( '/[^a-z0-9-]/', '-', $slug );
$slug = preg_replace( '/-+/', '-', $slug );
- return trim( $slug, '-' );
+ $slug = trim( $slug, '-' );
+
+ // Fallback for titles with no ASCII characters
+ if( $slug === '' )
+ {
+ $slug = 'post-' . uniqid();
+ }
+
+ return $slug;
}
}
diff --git a/src/Cms/Services/Tag/Creator.php b/src/Cms/Services/Tag/Creator.php
index b85b87b..6135088 100644
--- a/src/Cms/Services/Tag/Creator.php
+++ b/src/Cms/Services/Tag/Creator.php
@@ -40,6 +40,9 @@ public function create( string $name, ?string $slug = null ): Tag
/**
* Generate URL-friendly slug from name
*
+ * For names with only non-ASCII characters (e.g., "你好", "مرحبا"),
+ * generates a fallback slug using uniqid().
+ *
* @param string $name
* @return string
*/
@@ -48,6 +51,14 @@ private function generateSlug( string $name ): string
$slug = strtolower( trim( $name ) );
$slug = preg_replace( '/[^a-z0-9-]/', '-', $slug );
$slug = preg_replace( '/-+/', '-', $slug );
- return trim( $slug, '-' );
+ $slug = trim( $slug, '-' );
+
+ // Fallback for names with no ASCII characters
+ if( $slug === '' )
+ {
+ $slug = 'tag-' . uniqid();
+ }
+
+ return $slug;
}
}
diff --git a/src/Cms/Services/Widget/IWidget.php b/src/Cms/Services/Widget/IWidget.php
new file mode 100644
index 0000000..75be4c6
--- /dev/null
+++ b/src/Cms/Services/Widget/IWidget.php
@@ -0,0 +1,43 @@
+ description]
+ */
+ public function getAttributes(): array;
+}
diff --git a/src/Cms/Services/Widget/Widget.php b/src/Cms/Services/Widget/Widget.php
new file mode 100644
index 0000000..d037df5
--- /dev/null
+++ b/src/Cms/Services/Widget/Widget.php
@@ -0,0 +1,74 @@
+";
+ }
+
+ extract( $data );
+ ob_start();
+ include $template;
+ return ob_get_clean();
+ }
+
+ /**
+ * Sanitize HTML to prevent XSS
+ *
+ * @param string $html
+ * @return string
+ */
+ protected function sanitizeHtml( string $html ): string
+ {
+ $allowedTags = '