Organize your Discourse instance by using projects to separate and structure categories and topics.
A project is a top-level category (no parent) that serves as a container for subcategories. If projects_private is enabled, only read-restricted top-level categories are considered projects.
- Project identification: Top-level categories are automatically treated as projects
- Project serialization: Categories and topics include their parent project in API responses
- Recursive ancestry: Categories can look up their project via ancestor traversal
When projects_hide_projects_from_categories_page is enabled:
- The
/categoriespage displays project subcategories (level 1) instead of projects themselves - Subcategories appear as top-level items with a project badge showing their parent project
- The
/c/:slug/subcategoriespage continues to show subcategories normally
When projects_sort_categories_alphabetically is enabled:
- Categories are sorted alphabetically (case-insensitive) instead of by position
- Applies to both the global category cache and category list pages
- Project banner: Displays a banner on project pages (configurable routes and nesting levels)
- Project dropdown: Adds a project selector in breadcrumbs
- Sidebar customization: Highlights project links, optionally hides category section
- Logo placeholder: Shows placeholder for projects without custom logos
- Styled links: Alternative styling for project links
| Setting | Default | Description |
|---|---|---|
projects_enabled |
true |
Activate projects |
projects_private |
true |
Only read-restricted top-level categories are projects |
projects_sort_categories_alphabetically |
true |
Sort categories alphabetically instead of by position |
projects_hide_projects_from_categories_page |
false |
Show project subcategories instead of projects on /categories |
projects_addon |
true |
Show project addon next to category on topic list |
projects_banner |
true |
Show banner on project pages |
projects_banner_sticky |
true |
Make project banner sticky |
projects_banner_min_level |
0 |
Minimum nesting level for banner display |
projects_banner_max_level |
4 |
Maximum nesting level for banner display |
projects_banner_routes |
discovery|subcategories|... |
Routes where banner is shown |
projects_category_requires_parent |
true |
New categories require a parent |
projects_hide_navigation_menu_categories |
false |
Hide categories section in navigation menu |
projects_highlight_projects_sidebar_link |
true |
Highlight projects link in sidebar |
projects_show_logo_placeholder |
true |
Show placeholder for projects without logo |
projects_styled_links |
true |
Use alternative style for project links |
projects_home_logo_to_projects |
false |
Make homepage logo link to projects page |
A category is considered a project if:
- It has no parent category (
parent_category_id IS NULL) - It is not the "Uncategorized" category
- If
projects_privateis enabled: it must be read-restricted
Extends the Category model with:
- Scopes:
Category.projectsreturns all project categories - Methods:
category.project?- Returns true if this category is a projectcategory.project- Returns the parent project for subcategoriescategory.ancestors- Returns all ancestor categories using recursive SQL
Registers two modifiers:
-
:site_all_categories_cache_query- Sorts the global category cache alphabetically -
:category_list_find_categories_query- Handles/categoriespage filtering:- When
projects_hide_projects_from_categories_pageis enabled - Builds an Arel subquery to find project IDs
- Filters to show only direct children of projects
- Re-applies
secured(guardian)for permission checks
- When
# Simplified logic
project_ids_subquery = categories.project(categories[:id])
.where(categories[:parent_category_id].eq(nil))
.where(categories[:id].not_eq(SiteSetting.uncategorized_category_id))
query.where(categories[:parent_category_id].in(project_ids_subquery))Adds project data to API responses:
basic_categoryserializer:is_projectandprojectfieldstopic_list_itemserializer:projectfield
When projects_hide_projects_from_categories_page is enabled:
- Overrides
CategoryList.categoriesFromstatic method - On the
/categoriespage (no parent category):- Preserves original
parent_category_idin_original_parent_category_id - Sets
parent_category_idtonullso categories render as top-level
- Preserves original
- Registers a value transformer to add
project-subcategory-as-top-levelCSS class
// Categories appear as top-level but retain original parent reference
const modifiedResult = {
...result,
category_list: {
...result.category_list,
categories: result.category_list.categories.map((c) => ({
...c,
_original_parent_category_id: c.parent_category_id,
parent_category_id: null,
})),
},
};Plugin outlet connector that:
- Checks if current route is
discovery.categoriesordiscovery.subcategories - Renders project badge below category title when category has a project
discourse-projects/
├── plugin.rb # Plugin entry point, serializers, routes
├── config/
│ ├── settings.yml # Plugin settings definitions
│ └── locales/
│ └── server.en.yml # Setting descriptions
├── lib/
│ ├── engine.rb # Rails engine configuration
│ └── discourse_projects/
│ ├── category_extension.rb # Category model extensions
│ ├── category_sorting.rb # Category list filtering/sorting
│ ├── topic_extension.rb # Topic model extensions
│ └── application_layout_preloader_extension.rb
└── assets/
├── stylesheets/
│ └── common/
│ └── index.scss # Plugin styles
└── javascripts/discourse/
├── api-initializers/
│ ├── categories-as-top-level.js # CategoryList override
│ └── add-category-column.gjs
├── connectors/
│ ├── below-category-title-link/
│ │ └── project-badge.gjs # Project badge component
│ ├── above-main-container/
│ ├── bread-crumbs-left/
│ └── ...
├── components/
│ ├── project-banner.gjs
│ ├── project-dropdown.js
│ └── ...
├── models/
│ └── Project.js
├── services/
│ └── project.js
├── helpers/
│ └── project-link.js
└── initializers/
└── ...
-
Internal state access: The backend uses
instance_variable_getto access@optionsand@guardianfromCategoryList. These are internal implementation details that could change. -
Static method override: The frontend overrides
CategoryList.categoriesFrom, which is not a documented extension point. Discourse upgrades may require adjustments. -
Data mutation: Setting
parent_category_idtonullin the frontend is a workaround. Other UI components relying on this field may behave unexpectedly.
- Test after Discourse upgrades: The modifier hooks and method overrides may break
- Monitor deprecation warnings: Discourse may introduce official extension points
- Consider upstream contributions: Propose proper hooks for category list customization
| Type | Name | Purpose |
|---|---|---|
| Modifier | :site_all_categories_cache_query |
Sort global category cache |
| Modifier | :category_list_find_categories_query |
Filter/sort category list |
| API | api.modifyClass('model:category-list') |
Override CategoryList behavior |
| API | api.registerValueTransformer |
Add CSS classes to category rows |
| Outlet | below-category-title-link |
Inject project badge |
| Serializer | add_to_serializer |
Add project data to responses |
| Route | Controller | Description |
|---|---|---|
/new-subcategory/:parent |
categories#show |
Create subcategory form |
/c/*category_slug/categories |
categories#find_by_slug |
Subcategories index |
/c/*category_slug/members |
categories#find_by_slug |
Project members |
This plugin is built on the Discourse Plugin Skeleton.
To rebase your fork with the latest changes from upstream:
git remote add upstream git@github.com:discourse/discourse-plugin-skeleton.git
git fetch upstream
git rebase upstream/main