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 @@ +
+
+

Create Page

+ Back to Pages +
+ +
+ + +
+
+
+
+
Page Content
+
+
+
+ + + The main heading of your page +
+ +
+ +
+ /pages/ + +
+ URL-friendly version. Leave blank to auto-generate from title. +
+ +
+ +
+ + + Use shortcodes for dynamic content: [latest-posts limit="5"] or [contact-form] + +
+
+
+
+ +
+
+
+
Publish Settings
+
+
+
+ + + Only published pages are visible to visitors +
+ +
+ + +
+
+
+ +
+
+
SEO
+
+
+
+ + + 60 chars max. Leave blank to use page title. +
+ +
+ + + 160 chars max. Appears in search results. +
+ +
+ + + Comma-separated +
+
+
+ + + + Cancel + +
+
+
+
+ + + + + + + + + + + + diff --git a/resources/views/admin/pages/edit.php b/resources/views/admin/pages/edit.php new file mode 100644 index 0000000..4724585 --- /dev/null +++ b/resources/views/admin/pages/edit.php @@ -0,0 +1,208 @@ +
+
+

Edit Page: getTitle()) ?>

+ Back to Pages +
+ +
+ + + +
+
+
+
+
Page Content
+
+
+
+ + + The main heading of your page +
+ +
+ +
+ /pages/ + +
+ URL-friendly version +
+ +
+ +
+ + + Use shortcodes for dynamic content: [latest-posts limit="5"] or [contact-form] + +
+
+
+
+ +
+
+
+
Publish Settings
+
+
+
+ + + Only published pages are visible to visitors +
+ +
+ + +
+ + getPublishedAt()): ?> +
+ Published:
+ getPublishedAt()->format('M j, Y \a\t g:i A') ?> +
+ + +
+ Created: getCreatedAt()?->format('M j, Y') ?? 'N/A' ?>
+ Updated: getUpdatedAt()?->format('M j, Y') ?? 'Never' ?>
+ Views: getViewCount() ?> +
+
+
+ +
+
+
SEO
+
+
+
+ + + 60 chars max. Leave blank to use page title. +
+ +
+ + + 160 chars max. Appears in search results. +
+ +
+ + + Comma-separated +
+
+
+ + + isPublished()): ?> + + View Page + + + + Cancel + +
+
+
+
+ + + + + + + + + + + + diff --git a/resources/views/admin/pages/index.php b/resources/views/admin/pages/index.php new file mode 100644 index 0000000..2106512 --- /dev/null +++ b/resources/views/admin/pages/index.php @@ -0,0 +1,106 @@ +
+
+

Pages

+ Create New Page +
+ + + + + + + + + +
+
+ +
+

No pages yet.

+ Create Your First Page +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleSlugStatusAuthorTemplateViewsUpdatedActions
+ getTitle()) ?> + + /pages/getSlug()) ?> + + + getStatus()), ENT_QUOTES, 'UTF-8') ?> + + + getAuthor() + ? htmlspecialchars($page->getAuthor()->getUsername(), ENT_QUOTES, 'UTF-8') + : 'N/A' ?> + getTemplate()) ?>getViewCount() ?> + getUpdatedAt()): ?> + getUpdatedAt()->format('M j, Y') ?> + + Never + + +
+ + Edit + + isPublished()): ?> + + View + + +
+ + + +
+
+
+
+ +
+
+
diff --git a/resources/views/pages/show.php b/resources/views/pages/show.php new file mode 100644 index 0000000..2772e65 --- /dev/null +++ b/resources/views/pages/show.php @@ -0,0 +1,165 @@ +
+
+ + +
+ +
+ + +
+
+ + + + + + diff --git a/src/Cms/Controllers/Admin/Pages.php b/src/Cms/Controllers/Admin/Pages.php new file mode 100644 index 0000000..9341588 --- /dev/null +++ b/src/Cms/Controllers/Admin/Pages.php @@ -0,0 +1,395 @@ +get( 'Settings' ); + + // Initialize repository + $this->_pageRepository = new DatabasePageRepository( $settings ); + + // Initialize services + $this->_pageCreator = new Creator( $this->_pageRepository ); + $this->_pageUpdater = new Updater( $this->_pageRepository ); + $this->_pageDeleter = new Deleter( $this->_pageRepository ); + } + + /** + * List all pages + * @param Request $request + * @return string + * @throws \Exception + */ + public function index( Request $request ): string + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + // Generate CSRF token + $sessionManager = $this->getSessionManager(); + $csrfToken = new CsrfToken( $sessionManager ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + + // Get all pages or filter by author if not admin + if( $user->isAdmin() || $user->isEditor() ) + { + $pages = $this->_pageRepository->all(); + } + else + { + $pages = $this->_pageRepository->getByAuthor( $user->getId() ); + } + + $viewData = [ + 'Title' => 'Pages | ' . $this->getName(), + 'Description' => 'Manage pages', + 'User' => $user, + 'pages' => $pages, + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]; + + return $this->renderHtml( + HttpResponseStatus::OK, + $viewData, + 'pages/index', + 'admin' + ); + } + + /** + * Show create page form + * @param Request $request + * @return string + * @throws \Exception + */ + public function create( Request $request ): string + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + // Generate CSRF token + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + + $viewData = [ + 'Title' => 'Create Page | ' . $this->getName(), + 'Description' => 'Create a new page', + 'User' => $user + ]; + + return $this->renderHtml( + HttpResponseStatus::OK, + $viewData, + 'pages/create', + 'admin' + ); + } + + /** + * Store new page + * @param Request $request + * @return never + * @throws \Exception + */ + public function store( Request $request ): never + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + // Validate CSRF token + $csrfToken = new CsrfToken( $this->getSessionManager() ); + $submittedToken = $request->post( 'csrf_token', '' ); + + if( !$csrfToken->validate( $submittedToken ) ) + { + Log::warning( "CSRF validation failed for page creation by user {$user->getId()}" ); + $this->redirect( 'admin_pages_create', [], ['error', 'Invalid security token. Please try again.'] ); + } + + try + { + // Get form data + $title = $request->post( 'title', '' ); + $slug = $request->post( 'slug', '' ); + $content = $request->post( 'content', '{"blocks":[]}' ); + $template = $request->post( 'template', Page::TEMPLATE_DEFAULT ); + $metaTitle = $request->post( 'meta_title', '' ); + $metaDescription = $request->post( 'meta_description', '' ); + $metaKeywords = $request->post( 'meta_keywords', '' ); + $status = $request->post( 'status', Page::STATUS_DRAFT ); + + // Create page using service + $page = $this->_pageCreator->create( + $title, + $content, + $user->getId(), + $status, + $slug ?: null, + $template, + $metaTitle ?: null, + $metaDescription ?: null, + $metaKeywords ?: null + ); + + if( !$page ) + { + Log::error( "Page creation failed for user {$user->getId()}, title: {$title}" ); + $this->redirect( 'admin_pages_create', [], ['error', 'Failed to create page. Please try again.'] ); + } + + Log::info( "Page created successfully: ID {$page->getId()}, title: {$title}, by user {$user->getId()}" ); + $this->redirect( 'admin_pages', [], ['success', 'Page created successfully'] ); + } + catch( \Exception $e ) + { + Log::error( "Exception during page creation by user {$user->getId()}: {$e->getMessage()}", [ + 'exception' => $e, + 'trace' => $e->getTraceAsString() + ] ); + $this->redirect( 'admin_pages_create', [], ['error', 'Failed to create page. Please try again.'] ); + } + } + + /** + * Show edit page form + * @param Request $request + * @return string + * @throws \Exception + */ + public function edit( Request $request ): string + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + $pageId = (int)$request->getRouteParameter( 'id' ); + $page = $this->_pageRepository->findById( $pageId ); + + if( !$page ) + { + $this->redirect( 'admin_pages', [], ['error', 'Page not found'] ); + } + + // Check permissions + if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + { + Log::warning( "Unauthorized edit attempt by user {$user->getId()} on page {$pageId}" ); + $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to edit this page'] ); + } + + // Generate CSRF token + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + + $viewData = [ + 'Title' => 'Edit Page | ' . $this->getName(), + 'Description' => 'Edit page', + 'User' => $user, + 'page' => $page + ]; + + return $this->renderHtml( + HttpResponseStatus::OK, + $viewData, + 'pages/edit', + 'admin' + ); + } + + /** + * Update page + * @param Request $request + * @return never + * @throws \Exception + */ + public function update( Request $request ): never + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + $pageId = (int)$request->getRouteParameter( 'id' ); + $page = $this->_pageRepository->findById( $pageId ); + + if( !$page ) + { + $this->redirect( 'admin_pages', [], ['error', 'Page not found'] ); + } + + // Check permissions + if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + { + Log::warning( "Unauthorized page update attempt: User {$user->getId()} tried to edit page {$pageId}" ); + $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to edit this page'] ); + } + + // Validate CSRF token + $csrfToken = new CsrfToken( $this->getSessionManager() ); + $submittedToken = $request->post( 'csrf_token', '' ); + + if( !$csrfToken->validate( $submittedToken ) ) + { + Log::warning( "CSRF validation failed for page update: Page {$pageId}, user {$user->getId()}" ); + $this->redirect( 'admin_pages_edit', ['id' => $pageId], ['error', 'Invalid security token. Please try again.'] ); + } + + try + { + // Get form data + $title = $request->post( 'title', '' ); + $slug = $request->post( 'slug', '' ); + $content = $request->post( 'content', '{"blocks":[]}' ); + $template = $request->post( 'template', Page::TEMPLATE_DEFAULT ); + $metaTitle = $request->post( 'meta_title', '' ); + $metaDescription = $request->post( 'meta_description', '' ); + $metaKeywords = $request->post( 'meta_keywords', '' ); + $status = $request->post( 'status', Page::STATUS_DRAFT ); + + // Update page using service + $success = $this->_pageUpdater->update( + $page, + $title, + $content, + $status, + $slug ?: null, + $template, + $metaTitle ?: null, + $metaDescription ?: null, + $metaKeywords ?: null + ); + + if( !$success ) + { + Log::error( "Page update failed: Page {$pageId}, user {$user->getId()}, title: {$title}" ); + $this->redirect( 'admin_pages_edit', ['id' => $pageId], ['error', 'Failed to update page. Please try again.'] ); + } + + Log::info( "Page updated successfully: Page {$pageId}, title: {$title}, by user {$user->getId()}" ); + $this->redirect( 'admin_pages', [], ['success', 'Page updated successfully'] ); + } + catch( \Exception $e ) + { + Log::error( "Exception during page update: Page {$pageId}, user {$user->getId()}: {$e->getMessage()}", [ + 'exception' => $e, + 'trace' => $e->getTraceAsString() + ] ); + $this->redirect( 'admin_pages_edit', ['id' => $pageId], ['error', 'Failed to update page. Please try again.'] ); + } + } + + /** + * Delete page + * @param Request $request + * @return never + */ + public function destroy( Request $request ): never + { + $user = Registry::getInstance()->get( 'Auth.User' ); + + if( !$user ) + { + throw new \RuntimeException( 'Authenticated user not found' ); + } + + $pageId = (int)$request->getRouteParameter( 'id' ); + $page = $this->_pageRepository->findById( $pageId ); + + if( !$page ) + { + $this->redirect( 'admin_pages', [], ['error', 'Page not found'] ); + } + + // Check permissions + if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + { + Log::warning( "Unauthorized page deletion attempt: User {$user->getId()} tried to delete page {$pageId}" ); + $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to delete this page'] ); + } + + // Validate CSRF token + $csrfToken = new CsrfToken( $this->getSessionManager() ); + $submittedToken = $request->post( 'csrf_token', '' ); + + if( !$csrfToken->validate( $submittedToken ) ) + { + Log::warning( "CSRF validation failed for page deletion: Page {$pageId}, user {$user->getId()}" ); + $this->redirect( 'admin_pages', [], ['error', 'Invalid security token. Please try again.'] ); + } + + try + { + $pageTitle = $page->getTitle(); // Store for logging before deletion + + $success = $this->_pageDeleter->delete( $page ); + + if( !$success ) + { + Log::error( "Page deletion failed: Page {$pageId}, user {$user->getId()}" ); + $this->redirect( 'admin_pages', [], ['error', 'Failed to delete page. Please try again.'] ); + } + + Log::info( "Page deleted successfully: Page {$pageId}, title: {$pageTitle}, by user {$user->getId()}" ); + $this->redirect( 'admin_pages', [], ['success', 'Page deleted successfully'] ); + } + catch( \Exception $e ) + { + Log::error( "Exception during page deletion: Page {$pageId}, user {$user->getId()}: {$e->getMessage()}", [ + 'exception' => $e, + 'trace' => $e->getTraceAsString() + ] ); + $this->redirect( 'admin_pages', [], ['error', 'Failed to delete page. Please try again.'] ); + } + } +} diff --git a/src/Cms/Controllers/Admin/Tags.php b/src/Cms/Controllers/Admin/Tags.php index c893b37..c4503b9 100644 --- a/src/Cms/Controllers/Admin/Tags.php +++ b/src/Cms/Controllers/Admin/Tags.php @@ -226,6 +226,10 @@ public function destroy( Request $request ): never /** * Generate slug from name + * + * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), + * generates a fallback slug using uniqid(). + * * @param string $name * @return string * @throws \Exception @@ -235,6 +239,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/Controllers/Pages.php b/src/Cms/Controllers/Pages.php new file mode 100644 index 0000000..02ab8f4 --- /dev/null +++ b/src/Cms/Controllers/Pages.php @@ -0,0 +1,90 @@ +get( 'Settings' ); + + // Initialize repository + $this->_pageRepository = new DatabasePageRepository( $settings ); + + // Initialize renderer with shortcode support + $postRepository = new DatabasePostRepository( $settings ); + $widgetRenderer = new WidgetRenderer( $postRepository ); + $shortcodeParser = new ShortcodeParser( $widgetRenderer ); + $this->_renderer = new EditorJsRenderer( $shortcodeParser ); + } + + /** + * Display a page by slug + * + * @param Request $request + * @return string + * @throws NotFound + */ + public function show( Request $request ): string + { + $slug = $request->getRouteParameter( 'slug', '' ); + $page = $this->_pageRepository->findBySlug( $slug ); + + if( !$page || !$page->isPublished() ) + { + throw new NotFound( 'Page not found' ); + } + + // Increment view count + $this->_pageRepository->incrementViewCount( $page->getId() ); + + // Render content from Editor.js JSON + $content = $page->getContent(); + $contentHtml = $this->_renderer->render( $content ); + + // Use meta title if available, otherwise use page title + $metaTitle = $page->getMetaTitle() ?: $page->getTitle(); + $pageTitle = $metaTitle . ' | ' . $this->getName(); + + return $this->renderHtml( + HttpResponseStatus::OK, + [ + 'Page' => $page, + 'ContentHtml' => $contentHtml, + 'Title' => $pageTitle, + 'Description' => $page->getMetaDescription() ?: $this->getDescription(), + 'MetaKeywords' => $page->getMetaKeywords() + ], + 'show' + ); + } +} diff --git a/src/Cms/Events/PageCreatedEvent.php b/src/Cms/Events/PageCreatedEvent.php new file mode 100644 index 0000000..65c6e3f --- /dev/null +++ b/src/Cms/Events/PageCreatedEvent.php @@ -0,0 +1,25 @@ +_page = $page; + } + + public function getPage(): Page + { + return $this->_page; + } +} diff --git a/src/Cms/Events/PageDeletedEvent.php b/src/Cms/Events/PageDeletedEvent.php new file mode 100644 index 0000000..675cf59 --- /dev/null +++ b/src/Cms/Events/PageDeletedEvent.php @@ -0,0 +1,25 @@ +_page = $page; + } + + public function getPage(): Page + { + return $this->_page; + } +} diff --git a/src/Cms/Events/PagePublishedEvent.php b/src/Cms/Events/PagePublishedEvent.php new file mode 100644 index 0000000..aaffe6b --- /dev/null +++ b/src/Cms/Events/PagePublishedEvent.php @@ -0,0 +1,25 @@ +_page = $page; + } + + public function getPage(): Page + { + return $this->_page; + } +} diff --git a/src/Cms/Events/PageUpdatedEvent.php b/src/Cms/Events/PageUpdatedEvent.php new file mode 100644 index 0000000..def1a5a --- /dev/null +++ b/src/Cms/Events/PageUpdatedEvent.php @@ -0,0 +1,25 @@ +_page = $page; + } + + public function getPage(): Page + { + return $this->_page; + } +} diff --git a/src/Cms/Models/Page.php b/src/Cms/Models/Page.php new file mode 100644 index 0000000..ae0bf3f --- /dev/null +++ b/src/Cms/Models/Page.php @@ -0,0 +1,468 @@ +_createdAt = new DateTimeImmutable(); + } + + /** + * Get page ID + */ + public function getId(): ?int + { + return $this->_id; + } + + /** + * Set page ID + */ + public function setId( int $id ): self + { + $this->_id = $id; + return $this; + } + + /** + * Get title + */ + public function getTitle(): string + { + return $this->_title; + } + + /** + * Set title + */ + public function setTitle( string $title ): self + { + $this->_title = $title; + return $this; + } + + /** + * Get slug + */ + public function getSlug(): string + { + return $this->_slug; + } + + /** + * Set slug + */ + public function setSlug( string $slug ): self + { + $this->_slug = $slug; + return $this; + } + + /** + * Get content as array (decoded Editor.js JSON) + */ + public function getContent(): array + { + return json_decode( $this->_contentRaw, true ) ?? ['blocks' => []]; + } + + /** + * Get raw content JSON string + */ + public function getContentRaw(): string + { + return $this->_contentRaw; + } + + /** + * Set content from Editor.js JSON string + */ + public function setContent( string $jsonContent ): self + { + $this->_contentRaw = $jsonContent; + return $this; + } + + /** + * Set content from array (will be JSON encoded) + * @param array $content Content array to encode + * @return self + * @throws \JsonException If JSON encoding fails + */ + public function setContentArray( array $content ): self + { + $encoded = json_encode( $content ); + + if( $encoded === false ) + { + $error = json_last_error_msg(); + throw new \JsonException( "Failed to encode content array to JSON: {$error}" ); + } + + $this->_contentRaw = $encoded; + return $this; + } + + /** + * Get template + */ + public function getTemplate(): string + { + return $this->_template; + } + + /** + * Set template + */ + public function setTemplate( string $template ): self + { + $this->_template = $template; + return $this; + } + + /** + * Get meta title (for SEO) + */ + public function getMetaTitle(): ?string + { + return $this->_metaTitle; + } + + /** + * Set meta title + */ + public function setMetaTitle( ?string $metaTitle ): self + { + $this->_metaTitle = $metaTitle; + return $this; + } + + /** + * Get meta description (for SEO) + */ + public function getMetaDescription(): ?string + { + return $this->_metaDescription; + } + + /** + * Set meta description + */ + public function setMetaDescription( ?string $metaDescription ): self + { + $this->_metaDescription = $metaDescription; + return $this; + } + + /** + * Get meta keywords (for SEO) + */ + public function getMetaKeywords(): ?string + { + return $this->_metaKeywords; + } + + /** + * Set meta keywords + */ + public function setMetaKeywords( ?string $metaKeywords ): self + { + $this->_metaKeywords = $metaKeywords; + return $this; + } + + /** + * Get author ID + */ + public function getAuthorId(): int + { + return $this->_authorId; + } + + /** + * Set author ID + */ + public function setAuthorId( int $authorId ): self + { + $this->_authorId = $authorId; + return $this; + } + + /** + * Get author + */ + public function getAuthor(): ?User + { + return $this->_author; + } + + /** + * Set author + */ + public function setAuthor( ?User $author ): self + { + $this->_author = $author; + if( $author && $author->getId() ) + { + $this->_authorId = $author->getId(); + } + return $this; + } + + /** + * Get status + */ + public function getStatus(): string + { + return $this->_status; + } + + /** + * Set status + */ + public function setStatus( string $status ): self + { + $this->_status = $status; + return $this; + } + + /** + * Check if page is published + */ + public function isPublished(): bool + { + return $this->_status === self::STATUS_PUBLISHED; + } + + /** + * Check if page is draft + */ + public function isDraft(): bool + { + return $this->_status === self::STATUS_DRAFT; + } + + /** + * Get published date + */ + public function getPublishedAt(): ?DateTimeImmutable + { + return $this->_publishedAt; + } + + /** + * Set published date + */ + public function setPublishedAt( ?DateTimeImmutable $publishedAt ): self + { + $this->_publishedAt = $publishedAt; + return $this; + } + + /** + * Get view count + */ + public function getViewCount(): int + { + return $this->_viewCount; + } + + /** + * Set view count + */ + public function setViewCount( int $viewCount ): self + { + $this->_viewCount = $viewCount; + return $this; + } + + /** + * Increment view count + */ + public function incrementViewCount(): self + { + $this->_viewCount++; + return $this; + } + + /** + * Get created timestamp + */ + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->_createdAt; + } + + /** + * Set created timestamp + */ + public function setCreatedAt( DateTimeImmutable $createdAt ): self + { + $this->_createdAt = $createdAt; + return $this; + } + + /** + * Get updated timestamp + */ + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->_updatedAt; + } + + /** + * Set updated timestamp + */ + public function setUpdatedAt( ?DateTimeImmutable $updatedAt ): self + { + $this->_updatedAt = $updatedAt; + return $this; + } + + /** + * Create Page from array data + * + * @param array $data Associative array of page data + * @return static + * @throws Exception + */ + public static function fromArray( array $data ): static + { + $page = new self(); + + if( isset( $data['id'] ) ) + { + $page->setId( (int)$data['id'] ); + } + + $page->setTitle( $data['title'] ?? '' ); + $page->setSlug( $data['slug'] ?? '' ); + + // Handle content (could be JSON string or array) + if( isset( $data['content'] ) ) + { + if( is_string( $data['content'] ) ) + { + $page->setContent( $data['content'] ); + } + elseif( is_array( $data['content'] ) ) + { + $page->setContentArray( $data['content'] ); + } + } + + $page->setTemplate( $data['template'] ?? self::TEMPLATE_DEFAULT ); + $page->setMetaTitle( $data['meta_title'] ?? null ); + $page->setMetaDescription( $data['meta_description'] ?? null ); + $page->setMetaKeywords( $data['meta_keywords'] ?? null ); + $page->setAuthorId( (int)($data['author_id'] ?? 0) ); + $page->setStatus( $data['status'] ?? self::STATUS_DRAFT ); + $page->setViewCount( (int)($data['view_count'] ?? 0) ); + + if( isset( $data['published_at'] ) && $data['published_at'] ) + { + $page->setPublishedAt( + is_string( $data['published_at'] ) + ? new DateTimeImmutable( $data['published_at'] ) + : $data['published_at'] + ); + } + + if( isset( $data['created_at'] ) && $data['created_at'] ) + { + $page->setCreatedAt( + is_string( $data['created_at'] ) + ? new DateTimeImmutable( $data['created_at'] ) + : $data['created_at'] + ); + } + + if( isset( $data['updated_at'] ) && $data['updated_at'] ) + { + $page->setUpdatedAt( + is_string( $data['updated_at'] ) + ? new DateTimeImmutable( $data['updated_at'] ) + : $data['updated_at'] + ); + } + + // Relationships + if( isset( $data['author'] ) && $data['author'] instanceof User ) + { + $page->setAuthor( $data['author'] ); + } + + return $page; + } + + /** + * Convert page to array + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->_id, + 'title' => $this->_title, + 'slug' => $this->_slug, + 'content' => $this->_contentRaw, + 'template' => $this->_template, + 'meta_title' => $this->_metaTitle, + 'meta_description' => $this->_metaDescription, + 'meta_keywords' => $this->_metaKeywords, + 'author_id' => $this->_authorId, + 'status' => $this->_status, + 'published_at' => $this->_publishedAt?->format( 'Y-m-d H:i:s' ), + 'view_count' => $this->_viewCount, + 'created_at' => $this->_createdAt?->format( 'Y-m-d H:i:s' ), + 'updated_at' => $this->_updatedAt?->format( 'Y-m-d H:i:s' ), + ]; + } +} diff --git a/src/Cms/Repositories/DatabasePageRepository.php b/src/Cms/Repositories/DatabasePageRepository.php new file mode 100644 index 0000000..fbd65f9 --- /dev/null +++ b/src/Cms/Repositories/DatabasePageRepository.php @@ -0,0 +1,336 @@ +_pdo = ConnectionFactory::createFromSettings( $settings ); + } + + /** + * Find page by ID + */ + public function findById( int $id ): ?Page + { + $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE id = ? LIMIT 1" ); + $stmt->execute( [ $id ] ); + + $row = $stmt->fetch(); + + if( !$row ) + { + return null; + } + + return $this->mapRowToPage( $row ); + } + + /** + * Find page by slug + */ + public function findBySlug( string $slug ): ?Page + { + $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE slug = ? LIMIT 1" ); + $stmt->execute( [ $slug ] ); + + $row = $stmt->fetch(); + + if( !$row ) + { + return null; + } + + return $this->mapRowToPage( $row ); + } + + /** + * Create a new page + */ + public function create( Page $page ): Page + { + // Check for duplicate slug + if( $this->findBySlug( $page->getSlug() ) ) + { + throw new Exception( 'Slug already exists' ); + } + + $stmt = $this->_pdo->prepare( + "INSERT INTO pages ( + title, slug, content, template, meta_title, meta_description, + meta_keywords, author_id, status, published_at, view_count, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + $page->getTitle(), + $page->getSlug(), + $page->getContentRaw(), + $page->getTemplate(), + $page->getMetaTitle(), + $page->getMetaDescription(), + $page->getMetaKeywords(), + $page->getAuthorId(), + $page->getStatus(), + $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, + $page->getViewCount(), + $page->getCreatedAt()->format( 'Y-m-d H:i:s' ), + (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) + ]); + + $page->setId( (int)$this->_pdo->lastInsertId() ); + + return $page; + } + + /** + * Update an existing page + */ + public function update( Page $page ): bool + { + if( !$page->getId() ) + { + return false; + } + + // Check for duplicate slug (excluding current page) + $existingBySlug = $this->findBySlug( $page->getSlug() ); + if( $existingBySlug && $existingBySlug->getId() !== $page->getId() ) + { + throw new Exception( 'Slug already exists' ); + } + + $stmt = $this->_pdo->prepare( + "UPDATE pages SET + title = ?, + slug = ?, + content = ?, + template = ?, + meta_title = ?, + meta_description = ?, + meta_keywords = ?, + author_id = ?, + status = ?, + published_at = ?, + view_count = ?, + updated_at = ? + WHERE id = ?" + ); + + $result = $stmt->execute([ + $page->getTitle(), + $page->getSlug(), + $page->getContentRaw(), + $page->getTemplate(), + $page->getMetaTitle(), + $page->getMetaDescription(), + $page->getMetaKeywords(), + $page->getAuthorId(), + $page->getStatus(), + $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, + $page->getViewCount(), + (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), + $page->getId() + ]); + + return $result; + } + + /** + * Delete a page + */ + public function delete( int $id ): bool + { + $stmt = $this->_pdo->prepare( "DELETE FROM pages WHERE id = ?" ); + $stmt->execute( [ $id ] ); + + return $stmt->rowCount() > 0; + } + + /** + * Get all pages + */ + public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): array + { + $sql = "SELECT * FROM pages"; + $params = []; + + if( $status ) + { + $sql .= " WHERE status = ?"; + $params[] = $status; + } + + $sql .= " ORDER BY created_at DESC"; + + if( $limit > 0 ) + { + $sql .= " LIMIT ? OFFSET ?"; + $params[] = $limit; + $params[] = $offset; + } + + $stmt = $this->_pdo->prepare( $sql ); + $stmt->execute( $params ); + $rows = $stmt->fetchAll(); + + return array_map( [ $this, 'mapRowToPage' ], $rows ); + } + + /** + * Get published pages + */ + public function getPublished( int $limit = 0, int $offset = 0 ): array + { + return $this->all( Page::STATUS_PUBLISHED, $limit, $offset ); + } + + /** + * Get draft pages + */ + public function getDrafts(): array + { + return $this->all( Page::STATUS_DRAFT ); + } + + /** + * Get pages by author + */ + public function getByAuthor( int $authorId, ?string $status = null ): array + { + $sql = "SELECT * FROM pages WHERE author_id = ?"; + $params = [ $authorId ]; + + if( $status ) + { + $sql .= " AND status = ?"; + $params[] = $status; + } + + $sql .= " ORDER BY created_at DESC"; + + $stmt = $this->_pdo->prepare( $sql ); + $stmt->execute( $params ); + $rows = $stmt->fetchAll(); + + return array_map( [ $this, 'mapRowToPage' ], $rows ); + } + + /** + * Count total pages + */ + public function count( ?string $status = null ): int + { + $sql = "SELECT COUNT(*) as total FROM pages"; + $params = []; + + if( $status ) + { + $sql .= " WHERE status = ?"; + $params[] = $status; + } + + $stmt = $this->_pdo->prepare( $sql ); + $stmt->execute( $params ); + $row = $stmt->fetch(); + + return (int)$row['total']; + } + + /** + * Increment page view count + */ + public function incrementViewCount( int $id ): bool + { + $stmt = $this->_pdo->prepare( "UPDATE pages SET view_count = view_count + 1 WHERE id = ?" ); + $stmt->execute( [ $id ] ); + + return $stmt->rowCount() > 0; + } + + /** + * Map database row to Page object + * + * @param array $row Database row + * @return Page + */ + private function mapRowToPage( array $row ): Page + { + $data = [ + 'id' => (int)$row['id'], + 'title' => $row['title'], + 'slug' => $row['slug'], + 'content' => $row['content'], + 'template' => $row['template'], + 'meta_title' => $row['meta_title'], + 'meta_description' => $row['meta_description'], + 'meta_keywords' => $row['meta_keywords'], + 'author_id' => (int)$row['author_id'], + 'status' => $row['status'], + 'view_count' => (int)$row['view_count'], + 'published_at' => $row['published_at'] ?? null, + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'] ?? null, + ]; + + $page = Page::fromArray( $data ); + + // Load relationships + $page->setAuthor( $this->loadAuthor( $page->getAuthorId() ) ); + + return $page; + } + + /** + * Load author for a page + * + * @param int $authorId + * @return User|null + */ + private function loadAuthor( int $authorId ): ?User + { + try + { + $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); + $stmt->execute( [ $authorId ] ); + $row = $stmt->fetch(); + + if( !$row ) + { + return null; + } + + return User::fromArray( $row ); + } + catch( \PDOException $e ) + { + // Users table may not exist in test environments + return null; + } + } +} diff --git a/src/Cms/Repositories/IPageRepository.php b/src/Cms/Repositories/IPageRepository.php new file mode 100644 index 0000000..e911059 --- /dev/null +++ b/src/Cms/Repositories/IPageRepository.php @@ -0,0 +1,104 @@ +_shortcodeParser = $shortcodeParser; + } + + /** + * Render Editor.js JSON data to HTML + * + * @param array $editorData Editor.js JSON data (decoded) + * @return string Rendered HTML + */ + public function render( array $editorData ): string + { + $html = ''; + + foreach( $editorData['blocks'] ?? [] as $block ) + { + $html .= $this->renderBlock( $block ); + } + + return $html; + } + + /** + * Render a single block + * + * @param array $block Block data + * @return string Rendered HTML + */ + private function renderBlock( array $block ): string + { + $type = $block['type'] ?? 'paragraph'; + $data = $block['data'] ?? []; + + return match( $type ) + { + 'header' => $this->renderHeader( $data ), + 'paragraph' => $this->renderParagraph( $data ), + 'list' => $this->renderList( $data ), + 'image' => $this->renderImage( $data ), + 'quote' => $this->renderQuote( $data ), + 'code' => $this->renderCode( $data ), + 'delimiter' => $this->renderDelimiter( $data ), + 'raw' => $this->renderRaw( $data ), + default => $this->renderUnknown( $type ) + }; + } + + /** + * Render header block + */ + private function renderHeader( array $data ): string + { + // Sanitize header level: coerce to int and clamp to valid range (1-6) + $rawLevel = $data['level'] ?? 2; + $level = max( 1, min( 6, intval( $rawLevel ) ) ); + + $text = $this->parseInlineContent( $data['text'] ?? '' ); + + return "{$text}\n"; + } + + /** + * Render paragraph block + */ + private function renderParagraph( array $data ): string + { + $text = $this->parseInlineContent( $data['text'] ?? '' ); + + return "

{$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 .= "
  • " . $this->parseInlineContent( $item ) . "
  • \n"; + } + $html .= "\n"; + + return $html; + } + + /** + * Render image block + */ + private function renderImage( array $data ): string + { + $url = htmlspecialchars( $data['file']['url'] ?? '' ); + $caption = htmlspecialchars( $data['caption'] ?? '' ); + $stretched = $data['stretched'] ?? false; + $withBorder = $data['withBorder'] ?? false; + $withBackground = $data['withBackground'] ?? false; + + $imgClass = 'img-fluid'; + if( $stretched ) + { + $imgClass .= ' w-100'; + } + if( $withBorder ) + { + $imgClass .= ' border'; + } + + $figureClass = 'my-4'; + if( $withBackground ) + { + $figureClass .= ' bg-light p-3'; + } + + $html = "
    \n"; + $html .= " {$caption}\n"; + if( $caption ) + { + $html .= "
    {$caption}
    \n"; + } + $html .= "
    \n"; + + return $html; + } + + /** + * Render quote block + */ + private function renderQuote( array $data ): string + { + $text = htmlspecialchars( $data['text'] ?? '' ); + $caption = htmlspecialchars( $data['caption'] ?? '' ); + $alignment = $data['alignment'] ?? 'left'; + + $alignmentClass = match( $alignment ) + { + 'center' => 'text-center', + 'right' => 'text-end', + default => '' + }; + + $html = "
    \n"; + $html .= "

    {$text}

    \n"; + if( $caption ) + { + $html .= " \n"; + } + $html .= "
    \n"; + + return $html; + } + + /** + * Render code block + */ + private function renderCode( array $data ): string + { + $code = htmlspecialchars( $data['code'] ?? '' ); + + return "
    {$code}
    \n"; + } + + /** + * Render delimiter block + */ + private function renderDelimiter( array $data ): string + { + return "
    \n"; + } + + /** + * Render raw HTML block + */ + private function renderRaw( array $data ): string + { + $html = $data['html'] ?? ''; + + // Sanitize HTML to prevent XSS + return $this->sanitizeHtml( $html ) . "\n"; + } + + /** + * Render unknown block type + */ + private function renderUnknown( string $type ): string + { + return "\n"; + } + + /** + * Parse inline content (may contain shortcodes or simple HTML) + * + * @param string $content + * @return string + */ + private function parseInlineContent( string $content ): string + { + // Check for shortcodes + if( $this->_shortcodeParser && str_contains( $content, '[' ) ) + { + return $this->_shortcodeParser->parse( $content ); + } + + // Otherwise, sanitize and return + return $this->sanitizeHtml( $content ); + } + + /** + * Sanitize HTML to prevent XSS while allowing safe tags + * + * @param string $html + * @return string + */ + private function sanitizeHtml( string $html ): string + { + // Allow common inline HTML tags + $allowedTags = ''; + + 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 = '