feat: [Content Hub] PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, TagPage#234
feat: [Content Hub] PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, TagPage#234
Conversation
…age, CaseStudyPage, TagPage - NewsHolder: parent page for all content types, CMS grid fields, unified feed - ArticlePage: date, subtitle, author metadata, tags, related articles query - PressReleasePage: embargo, media contact, boilerplate, origin location - CaseStudyPage: Client name, Challenge/Solution/Results, quote, client logo - Tag: shared taxonomy with auto-slug, belongs_many_many to all content types - TagPage: tag-based content archive page Relates to #89
There was a problem hiding this comment.
Pull request overview
Adds a “Content Hub” feature set to the SilverStripe site by introducing new PageTypes (NewsHolder + content detail pages) and a shared Tag taxonomy, plus related routing/config.
Changes:
- Introduces new Content Hub PageTypes:
NewsHolder,ArticlePage,PressReleasePage,CaseStudyPage, andTagPage. - Adds a
TagDataObject for shared tagging across content types. - Adds
app/_config/contenthub.ymlfor routing and a custom permission code.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| app/src/ContentHub/NewsHolder.php | New hub/parent page for managing and listing content types |
| app/src/ContentHub/ArticlePage.php | New Article PageType with metadata, tags, excerpt, and related-articles query |
| app/src/ContentHub/PressReleasePage.php | New Press Release PageType with embargo/contact metadata and tags |
| app/src/ContentHub/CaseStudyPage.php | New Case Study PageType with client/story fields and (intended) metrics/tags |
| app/src/ContentHub/Tag.php | New taxonomy DataObject with auto-generated slug |
| app/src/ContentHub/TagPage.php | New Tag archive PageType that lists articles by tag slug |
| app/_config/contenthub.yml | Adds a /news route rule and a new permission definition |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return ArticlePage::get()->limit($limit); | ||
| } | ||
|
|
||
| $tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first(); | ||
|
|
||
| if (!$tag) { | ||
| return ArticlePage::get()->limit($limit); |
There was a problem hiding this comment.
TaggedArticles() returns all ArticlePage records when TagSlug is empty or doesn’t resolve to a Tag. That seems inconsistent with this PageType’s purpose (a tag archive) and can unintentionally expose unrelated content and create heavy queries. Consider returning an empty list (or a clear 404/validation error) when no valid tag is configured.
| return ArticlePage::get()->limit($limit); | |
| } | |
| $tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first(); | |
| if (!$tag) { | |
| return ArticlePage::get()->limit($limit); | |
| // No tag configured for this page: return an empty list instead of all articles | |
| return ArticlePage::get()->where('1 = 0'); | |
| } | |
| $tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first(); | |
| if (!$tag) { | |
| // Tag slug does not resolve to an existing tag: return an empty list | |
| return ArticlePage::get()->where('1 = 0'); |
| if (!$this->Slug && $this->Title) { | ||
| $this->Slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $this->Title)); | ||
| } |
There was a problem hiding this comment.
Slug generation can produce leading/trailing hyphens and multiple consecutive hyphens (e.g., titles starting/ending with punctuation). Consider trimming hyphens and collapsing repeats so the saved Slug is consistently URL-safe. Also consider whether the slug should update when the Title changes (while still allowing manual overrides).
| $fields->addFieldToTab('Root.Distribution', [ | ||
| DateField::create('EmbargoDate', 'Embargo Date/Time (optional)'), | ||
| TextField::create('MediaContact', 'Media Contact Name'), | ||
| TextField::create('MediaEmail', 'Media Email'), | ||
| HTMLEditorField::create('Boilerplate', 'Company Boilerplate')->setRows(4), | ||
| ]); |
There was a problem hiding this comment.
EmbargoDate is defined as a Datetime but is scaffolded with DateField, which drops the time component. Use a datetime-capable CMS field (and ensure the value saved/compared includes the intended time) so embargo behavior matches the schema and UI label (“Date/Time”).
| $fields->addFieldToTab('Root.Main', TreeMultiselectField::create('Tags', 'Tags', Tag::class)); | ||
|
|
There was a problem hiding this comment.
TreeMultiselectField is intended for hierarchical/tree data (e.g., SiteTree/Hierarchy). Tag is a plain DataObject, so this field type is unlikely to render/select tags correctly in the CMS. Consider using a relation-appropriate field (e.g., CheckboxSetField, GridField relation editor, or a tag UI like silverstripe/tagfield) so editors can actually manage the many_many Tags.
| private static $has_one = [ | ||
| 'PressImage' => Image::class, | ||
| ]; |
There was a problem hiding this comment.
PressImage is declared as a has_one, but no CMS field is added for it in getCMSFields(), so authors won’t be able to upload/select an image. The codebase already uses UploadField for Image relations (e.g., SEOExtension), so consider adding an UploadField for PressImage (and any required ownership config) to make the relation usable.
| private static $has_one = [ | ||
| 'FeaturedImage' => Image::class, | ||
| ]; |
There was a problem hiding this comment.
FeaturedImage is declared as a has_one, but no CMS field is added for it in getCMSFields(), so editors won’t be able to upload/select an image. The project already uses UploadField for Image relations (see SEOExtension), so consider adding an UploadField for FeaturedImage (and any required ownership config) to make this usable.
| return ArticlePage::get() | ||
| ->filter(['ID:not' => $this->ID]) | ||
| ->innerJoin( | ||
| 'ArticlePage_Tags', | ||
| '"ArticlePage_Tags"."ArticlePageID" = "SiteTree"."ID"' | ||
| ) | ||
| ->where(sprintf( | ||
| '"ArticlePage_Tags"."TagID" IN (%s)', | ||
| implode(',', array_map('intval', $tagIds)) | ||
| )) | ||
| ->sort('"Date" DESC') | ||
| ->groupBy('"SiteTree"."ID"') |
There was a problem hiding this comment.
RelatedArticles() hardcodes the many_many join table name (ArticlePage_Tags) and builds a manual SQL join/where. This is brittle (naming can differ with namespacing/config) and bypasses ORM conveniences. Prefer expressing this via ORM relation filters (e.g., filtering on Tags.ID) and using distinct()/deduping via ORM to avoid the manual join.
| return ArticlePage::get() | |
| ->filter(['ID:not' => $this->ID]) | |
| ->innerJoin( | |
| 'ArticlePage_Tags', | |
| '"ArticlePage_Tags"."ArticlePageID" = "SiteTree"."ID"' | |
| ) | |
| ->where(sprintf( | |
| '"ArticlePage_Tags"."TagID" IN (%s)', | |
| implode(',', array_map('intval', $tagIds)) | |
| )) | |
| ->sort('"Date" DESC') | |
| ->groupBy('"SiteTree"."ID"') | |
| return static::get() | |
| ->exclude('ID', $this->ID) | |
| ->filter(['Tags.ID' => $tagIds]) | |
| ->distinct() | |
| ->sort('"Date" DESC') |
| SilverStripe\Core\Injector\Injector: | ||
| SilverStripe\Control\Director: | ||
| properties: | ||
| rules: | ||
| 'news//$Action/$ID/$Name': | ||
| Controller: App\ContentHub\NewsHolderController |
There was a problem hiding this comment.
Routing rules are configured under SilverStripe\Core\Injector\Injector here, but elsewhere in the codebase routing is declared directly under SilverStripe\Control\Director: rules: (e.g., app/_config/routes.yml and app/_config/about_routes.yml). This config block likely won’t be picked up as a route definition. Move the rule under SilverStripe\Control\Director: at the top level to match existing routing config.
| SilverStripe\Core\Injector\Injector: | |
| SilverStripe\Control\Director: | |
| properties: | |
| rules: | |
| 'news//$Action/$ID/$Name': | |
| Controller: App\ContentHub\NewsHolderController | |
| SilverStripe\Control\Director: | |
| rules: | |
| 'news//$Action/$ID/$Name': | |
| Controller: App\ContentHub\NewsHolderController |
| properties: | ||
| rules: | ||
| 'news//$Action/$ID/$Name': | ||
| Controller: App\ContentHub\NewsHolderController |
There was a problem hiding this comment.
The route maps to App\ContentHub\NewsHolderController, which extends PageController (a ContentController subclass that normally expects a backing SiteTree record). In this codebase, routes that point at PageTypes map to the Page class (e.g., contact: ContactPage in app/_config/routes.yml), not the controller. Consider mapping the route to the NewsHolder PageType (or to a standalone Controller if this is meant to be recordless).
| Controller: App\ContentHub\NewsHolderController | |
| Controller: App\ContentHub\NewsHolder |
| ]); | ||
|
|
||
| $fields->addFieldToTab('Root.Main', TreeMultiselectField::create('Tags', 'Tags', Tag::class)); | ||
|
|
||
| return $fields; |
There was a problem hiding this comment.
TreeMultiselectField is a tree UI intended for hierarchical data; Tag is a plain DataObject. Even once the Tags relation exists, this field type is unlikely to work correctly for selecting tags. Consider switching to a relation-appropriate selector (CheckboxSetField/GridField/tagfield) so tags can be managed reliably in the CMS.
…Calendar - ArticleCategory: hierarchical categorization with tree structure (ParentID, many_many < Articles), URL slugs - AuthorProfile: author bios with social links and profile images - EditorialCalendar: content scheduling with status tracking (Idea → Planned → In Progress → In Review → Scheduled → Published), assignee, priority, due/publish dates, many_many to Categories + Authors Note: Tag model is handled separately in PR #234 and can be added as a many_many relation once merged. Relates to #86
…Calendar, ContentBrief - ArticleCategory: hierarchical categorization with tree structure (ParentID, many_many relation to articles), URL slugs - AuthorProfile: author bios with social links and profile images - EditorialCalendar: content scheduling with status tracking (Idea → Planned → In Progress → In Review → Scheduled → Published → Archived), assignee, priority, due/publish dates, many_many to Categories + Authors, getIsOverdue()/getIsPublished() helpers - ContentBrief: pre-production content planning with objectives, audience, key points, relates to EditorialCalendar via has_one Note: Tag model is handled separately in PR #234 and can be added as a many_many relation once merged. Relates to #86
…Calendar (#236) * feat: [Content Hub] Models: ArticleCategory, AuthorProfile, EditorialCalendar, ContentBrief - ArticleCategory: hierarchical categorization with tree structure (ParentID, many_many relation to articles), URL slugs - AuthorProfile: author bios with social links and profile images - EditorialCalendar: content scheduling with status tracking (Idea → Planned → In Progress → In Review → Scheduled → Published → Archived), assignee, priority, due/publish dates, many_many to Categories + Authors, getIsOverdue()/getIsPublished() helpers - ContentBrief: pre-production content planning with objectives, audience, key points, relates to EditorialCalendar via has_one Note: Tag model is handled separately in PR #234 and can be added as a many_many relation once merged. Relates to #86 * feat: [Content Hub] Add Tag model and Color field to ArticleCategory (#86) - Add Tag model with Title, Slug, UsageCount - Auto-generate slug from title - Auto-calculate usage count from related articles - Add Color field to ArticleCategory for UI tagging - Update ArticleCategory CMS fields with Color picker --------- Co-authored-by: AutoPipe Builder <builder@autopipe.test>
Fixes #89
NewsHolder (Parent Page)
allowed_children: ArticlePage, PressReleasePage, CaseStudyPageLatestArticles(),LatestPressReleases(),LatestCaseStudies()methodsArticlePage
getExcerpt($length)for listing cardsRelatedArticles($limit)via shared tag overlap queryIsFeaturedflag for homepage promotionPressReleasePage
getIsEmbargoed()helperCaseStudyPage
Tag
belongs_many_manyreverse relationsTagPage
TagSlugTaggedArticles($limit)queries by tagConfig
app/_config/contenthub.ymlwith ContentHub permission definitionRelates to #89 (Content Hub epic)