Initial commit: Site Structure module for Drupal

Drupal module that provides hierarchical site structure management
with support for sections, categories, and content items. Includes
path aliases with tokens, breadcrumb integration, and admin interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 19:55:00 -03:00
commit 8a42a6f1c1
31 changed files with 2633 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.content_page.body
- field.field.node.content_page.field_parent_page
- field.field.node.content_page.field_site_section
- node.type.content_page
module:
- dynamic_entity_reference
- path
- text
id: node.content_page.default
targetEntityType: node
bundle: content_page
mode: default
content:
body:
type: text_textarea_with_summary
weight: 2
region: content
settings:
rows: 9
summary_rows: 3
placeholder: ''
show_summary: false
third_party_settings: { }
created:
type: datetime_timestamp
weight: 10
region: content
settings: { }
third_party_settings: { }
field_parent_page:
type: dynamic_entity_reference_default
weight: 0
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
field_site_section:
type: options_select
weight: 1
region: content
settings: { }
third_party_settings: { }
path:
type: path
weight: 30
region: content
settings: { }
third_party_settings: { }
promote:
type: boolean_checkbox
weight: 15
region: content
settings:
display_label: true
third_party_settings: { }
status:
type: boolean_checkbox
weight: 120
region: content
settings:
display_label: true
third_party_settings: { }
sticky:
type: boolean_checkbox
weight: 16
region: content
settings:
display_label: true
third_party_settings: { }
title:
type: string_textfield
weight: -5
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
uid:
type: entity_reference_autocomplete
weight: 5
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
hidden: { }

View File

@@ -0,0 +1,83 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.section_page.body
- field.field.node.section_page.field_site_section
- node.type.section_page
module:
- path
- text
id: node.section_page.default
targetEntityType: node
bundle: section_page
mode: default
content:
body:
type: text_textarea_with_summary
weight: 1
region: content
settings:
rows: 9
summary_rows: 3
placeholder: ''
show_summary: false
third_party_settings: { }
created:
type: datetime_timestamp
weight: 10
region: content
settings: { }
third_party_settings: { }
field_site_section:
type: options_select
weight: 0
region: content
settings: { }
third_party_settings: { }
path:
type: path
weight: 30
region: content
settings: { }
third_party_settings: { }
promote:
type: boolean_checkbox
weight: 15
region: content
settings:
display_label: true
third_party_settings: { }
status:
type: boolean_checkbox
weight: 120
region: content
settings:
display_label: true
third_party_settings: { }
sticky:
type: boolean_checkbox
weight: 16
region: content
settings:
display_label: true
third_party_settings: { }
title:
type: string_textfield
weight: -5
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
uid:
type: entity_reference_autocomplete
weight: 5
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
hidden: { }

View File

@@ -0,0 +1,30 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.content_page.body
- field.field.node.content_page.field_parent_page
- field.field.node.content_page.field_site_section
- node.type.content_page
module:
- text
id: node.content_page.default
targetEntityType: node
bundle: content_page
mode: default
content:
body:
type: text_default
label: hidden
settings: { }
third_party_settings: { }
weight: 0
region: content
links:
settings: { }
third_party_settings: { }
weight: 100
region: content
hidden:
field_parent_page: true
field_site_section: true

View File

@@ -0,0 +1,28 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.section_page.body
- field.field.node.section_page.field_site_section
- node.type.section_page
module:
- text
id: node.section_page.default
targetEntityType: node
bundle: section_page
mode: default
content:
body:
type: text_default
label: hidden
settings: { }
third_party_settings: { }
weight: 0
region: content
links:
settings: { }
third_party_settings: { }
weight: 100
region: content
hidden:
field_site_section: true

View File

@@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
- node.type.content_page
module:
- text
id: node.content_page.body
field_name: body
entity_type: node
bundle: content_page
label: Body
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
display_summary: true
required_summary: false
field_type: text_with_summary

View File

@@ -0,0 +1,44 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_parent_page
- node.type.content_page
- node.type.section_page
- taxonomy.vocabulary.site_section
module:
- dynamic_entity_reference
id: node.content_page.field_parent_page
field_name: field_parent_page
entity_type: node
bundle: content_page
label: 'Parent Page'
description: 'Select the parent entity. The site section will be inherited automatically based on the parent type.'
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
entity_type_ids:
- node
- taxonomy_term
node:
handler: 'default:node'
handler_settings:
target_bundles:
content_page: content_page
section_page: section_page
sort:
field: title
direction: asc
auto_create: false
taxonomy_term:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
site_section: site_section
sort:
field: name
direction: asc
auto_create: false
field_type: dynamic_entity_reference

View File

@@ -0,0 +1,29 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_site_section
- node.type.content_page
- taxonomy.vocabulary.site_section
module:
- taxonomy
id: node.content_page.field_site_section
field_name: field_site_section
entity_type: node
bundle: content_page
label: 'Site Section'
description: 'For root pages, select the section. For child pages, this field is filled automatically.'
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
site_section: site_section
sort:
field: name
direction: asc
auto_create: false
field_type: entity_reference

View File

@@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
- node.type.section_page
module:
- text
id: node.section_page.body
field_name: body
entity_type: node
bundle: section_page
label: Body
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
display_summary: true
required_summary: false
field_type: text_with_summary

View File

@@ -0,0 +1,29 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_site_section
- node.type.section_page
- taxonomy.vocabulary.site_section
module:
- taxonomy
id: node.section_page.field_site_section
field_name: field_site_section
entity_type: node
bundle: section_page
label: 'Site Section'
description: 'Select the site section this page belongs to.'
required: true
translatable: false
default_value: { }
default_value_callback: ''
settings:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
site_section: site_section
sort:
field: name
direction: asc
auto_create: false
field_type: entity_reference

View File

@@ -0,0 +1,18 @@
langcode: en
status: true
dependencies:
module:
- dynamic_entity_reference
- node
id: node.field_parent_page
field_name: field_parent_page
entity_type: node
type: dynamic_entity_reference
settings: { }
module: dynamic_entity_reference
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
module:
- node
- taxonomy
id: node.field_site_section
field_name: field_site_section
entity_type: node
type: entity_reference
settings:
target_type: taxonomy_term
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,13 @@
langcode: en
status: true
dependencies: { }
third_party_settings:
pathauto:
enabled: true
name: 'Content Page'
type: content_page
description: 'Pages with hierarchical parent-child structure for content organization.'
help: 'For root pages, fill in the Site Section manually. For child pages, select the parent page and the section will be inherited automatically.'
new_revision: true
preview_mode: 1
display_submitted: true

View File

@@ -0,0 +1,13 @@
langcode: en
status: true
dependencies: { }
third_party_settings:
pathauto:
enabled: true
name: 'Section Page'
type: section_page
description: 'Pages organized by site section, with URLs and breadcrumbs based on taxonomy.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: true

View File

@@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- node
- site_structure
id: content_page
label: 'Content Page'
type: 'canonical_entities:node'
pattern: '[node:site-section-path]/[node:title]'
selection_logic: and
selection_criteria:
c4fb8cde-f2d5-5bf5-0e99-303706509174:
id: entity_bundle:node
negate: false
context_mapping:
node: node
bundles:
content_page: content_page
weight: 0

View File

@@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- node
- site_structure
id: section_page
label: 'Section Page'
type: 'canonical_entities:node'
pattern: '[node:site-section-path]/[node:title]'
selection_logic: and
selection_criteria:
b3eb7bda-e1c4-4ae4-9d88-292695498063:
id: entity_bundle:node
negate: false
context_mapping:
node: node
bundles:
section_page: section_page
weight: 0

View File

@@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- site_structure
- taxonomy
id: site_section_term
label: 'Site Section Term'
type: 'canonical_entities:taxonomy_term'
pattern: '[term:hierarchy-path]'
selection_logic: and
selection_criteria:
d5fc9eaf-f3e6-6cg6-1f00-414817610285:
id: entity_bundle:taxonomy_term
negate: false
context_mapping:
taxonomy_term: taxonomy_term
bundles:
site_section: site_section
weight: 0

View File

@@ -0,0 +1,11 @@
allowed_parent_targets:
- entity_type: node
bundle: content_page
- entity_type: node
bundle: section_page
- entity_type: taxonomy_term
bundle: site_section
- entity_type: user
bundle: user
- entity_type: group
bundle: '*'

View File

@@ -0,0 +1,7 @@
langcode: en
status: true
dependencies: { }
name: 'Site Section'
vid: site_section
description: 'Main hierarchical structure for site organization.'
weight: 0

View File

@@ -0,0 +1,252 @@
langcode: en
status: true
dependencies:
config:
- node.type.content_page
module:
- node
- user
id: child_pages
label: 'Child Pages'
module: views
description: 'Lists the child pages of a content_page.'
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
id: default
display_title: Default
display_plugin: default
position: 0
display_options:
title: 'Child Pages'
fields:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: title
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
pager:
type: none
options:
offset: 0
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
empty:
area_text_custom:
id: area_text_custom
table: views
field: area_text_custom
relationship: none
group_type: group
admin_label: ''
plugin_id: text_custom
empty: true
content: 'No child pages found.'
tokenize: false
sorts:
created:
id: created
table: node_field_data
field: created
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: created
plugin_id: date
order: ASC
expose:
label: ''
field_identifier: ''
exposed: false
granularity: second
arguments:
field_parent_page_target_id:
id: field_parent_page_target_id
table: node__field_parent_page
field: field_parent_page_target_id
relationship: none
group_type: group
admin_label: ''
plugin_id: numeric
default_action: default
exception:
value: all
title_enable: false
title: All
title_enable: false
title: ''
default_argument_type: node
default_argument_options: { }
summary_options:
base_path: ''
count: true
override: false
items_per_page: 25
summary:
sort_order: asc
number_of_records: 0
format: default_summary
specify_validation: true
validate:
type: 'entity:node'
fail: 'not found'
validate_options:
bundles:
content_page: content_page
access: true
operation: view
multiple: 0
break_phrase: false
not: false
filters:
status:
id: status
table: node_field_data
field: status
entity_type: node
entity_field: status
plugin_id: boolean
value: '1'
group: 1
expose:
operator: ''
type:
id: type
table: node_field_data
field: type
entity_type: node
entity_field: type
plugin_id: bundle
value:
content_page: content_page
group: 1
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
row:
type: fields
options:
default_field_elements: true
inline: { }
separator: ''
hide_empty: false
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: { }
relationships: { }
header: { }
footer: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags: { }
block_child_pages:
id: block_child_pages
display_title: 'Block: Child Pages'
display_plugin: block
position: 1
display_options:
display_extenders: { }
block_description: 'Child Pages'
block_category: 'Site Structure'
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@@ -0,0 +1,16 @@
site_structure.settings:
type: config_object
label: 'Site Structure settings'
mapping:
allowed_parent_targets:
type: sequence
label: 'Allowed parent targets'
sequence:
type: mapping
mapping:
entity_type:
type: string
label: 'Entity type'
bundle:
type: string
label: 'Bundle'

345
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,345 @@
# Site Structure - Design Document
## Overview
The `site_structure` module implements a hierarchical editorial structure and contextual navigation for institutional sites in Drupal 11. The module was designed for higher education institutions but is generic enough for other institutional contexts.
## Problem
In Drupal, content is added in a flat manner (non-hierarchical), and menus are traditionally used to organize content into hierarchical structures. This creates user experience problems because content editors typically do not have permission to administer menus.
## Solution
The module implements two complementary structures:
1. **Taxonomic Structure** (primary): Organization based on hierarchical taxonomy vocabulary
2. **Parent-Child Structure** (subsidiary): Direct hierarchy between pages for specific cases, supporting multiple entity types as parents
---
## Conceptual Model
```
┌─────────────────────────────────────────────────────────────────────┐
│ PRIMARY STRUCTURE: site_section Taxonomy │
│ │
│ - Governs the overall organization of site information │
│ - Defines breadcrumbs and URLs (via Pathauto) │
│ - Example: Undergraduate > Courses > Software Engineering │
│ - Applied to content type: section_page │
├─────────────────────────────────────────────────────────────────────┤
│ SUBSIDIARY STRUCTURE: Parent-Child (field_parent_page) │
│ │
│ - For specific cases: manuals, guides, sequential documentation │
│ - Provides contextual navigation (Child pages View) │
│ - Automatically inherits site section from parent (node/taxonomy) │
│ - For user/group parents, context is the entity itself │
│ - Applied to content type: content_page │
│ - Supports multiple parent types: content_page, section_page, │
│ taxonomy terms, users, groups (configurable) │
├─────────────────────────────────────────────────────────────────────┤
│ page (core Drupal) │
│ │
│ - Remains untouched for generic use │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Components
### 1. Taxonomy Vocabulary: `site_section`
| Property | Value |
|----------|-------|
| Machine name | `site_section` |
| Name | Site Section |
| Hierarchical | Yes |
| Description | Main hierarchical structure for site organization |
**Usage**: This vocabulary defines site sections. Terms can be nested to create hierarchies (e.g., "Undergraduate" > "Courses" > "Engineering").
### 2. Content Type: `section_page`
| Property | Value |
|----------|-------|
| Machine name | `section_page` |
| Name | Section Page |
| Description | Pages organized by site section |
**Fields**:
| Field | Type | Required | Cardinality | Description |
|-------|------|----------|-------------|-------------|
| `title` | String | Yes | 1 | Page title (core) |
| `body` | Text (formatted, long, with summary) | No | 1 | Page content |
| `field_site_section` | Entity reference (taxonomy) | Yes | 1 | Site section |
### 3. Content Type: `content_page`
| Property | Value |
|----------|-------|
| Machine name | `content_page` |
| Name | Content Page |
| Description | Pages with hierarchical parent-child structure |
**Fields**:
| Field | Type | Required | Cardinality | Description |
|-------|------|----------|-------------|-------------|
| `title` | String | Yes | 1 | Page title (core) |
| `body` | Text (formatted, long, with summary) | No | 1 | Page content |
| `field_parent_page` | Dynamic entity reference | No | 1 | Parent entity (configurable types) |
| `field_site_section` | Entity reference (taxonomy) | Conditional | 1 | Site section (inherited or manual) |
**Rules for `field_site_section` in content_page**:
- If `field_parent_page` points to a `taxonomy_term`: uses the term itself as site section
- If `field_parent_page` points to a `node`: inherits `field_site_section` from parent
- If `field_parent_page` is empty (root page): must be filled manually
### 4. Dynamic Parent Reference
The `field_parent_page` uses the Dynamic Entity Reference module to support multiple entity types as parents. This is configurable via the admin interface.
**Default allowed targets**:
- `node:content_page` - Other content pages
- `node:section_page` - Section pages
- `taxonomy_term:site_section` - Site section terms
- `user:user` - User accounts
- `group:*` - All group types (requires Group module)
**Context behavior by parent type**:
- **Node/Taxonomy**: Content pages inherit `field_site_section` from the parent
- **User**: Content pages are associated with the user profile page (`/user/{id}`). The `field_site_section` is cleared as the context is the user itself.
- **Group**: Content pages are associated with the group. The `field_site_section` is cleared as the context is the group itself.
**Configuration**: `/admin/config/local-modules/site-structure`
### 5. Inheritance Logic
Implemented via `hook_entity_presave()`:
```
When a content_page is saved:
IF field_parent_page is filled:
IF parent is user or group:
Clear field_site_section (context is the entity itself)
ELSE IF parent is taxonomy_term (site_section):
Set field_site_section to the parent term ID
ELSE IF parent is node with field_site_section:
Copy field_site_section from parent node
ELSE:
Validate that field_site_section was filled manually
```
**Additional validations**:
- Prevent self-reference (page cannot be parent of itself)
- Prevent circularity (A → B → C → A) - only for node parents
### 6. Breadcrumb Builder
Custom service that overrides Drupal's default breadcrumb for `section_page` and `content_page`.
**Logic**:
1. Determine the root parent context by traversing the parent chain
2. Build breadcrumb based on context type:
- **User context**: Home > User Name > Parent Nodes > Current Page
- **Group context**: Home > Group Name > Parent Nodes > Current Page
- **Taxonomy context**: Home > Term Hierarchy > Parent Nodes > Current Page
3. For content_page, also add node parents to the breadcrumb
**Examples**:
- Page "Chapter 1" has parent "User Guide" which is a section_page in "Documentation"
- Breadcrumb: Home > Documentation > User Guide > Chapter 1
- Page "My Notes" has parent user "John Doe"
- Breadcrumb: Home > John Doe > My Notes
- Page "Meeting Minutes" has parent group "Research Team"
- Breadcrumb: Home > Research Team > Meeting Minutes
### 7. Pathauto Tokens
Custom token for hierarchical URL generation.
| Token | Description | Example |
|-------|-------------|---------|
| `[node:site-section-path]` | Path based on taxonomy hierarchy | `undergraduate/courses` |
**Suggested URL pattern**: `[node:site-section-path]/[node:title]`
**Result**: `/undergraduate/courses/software-engineering`
### 8. View: `child_pages`
View that lists child pages of the current `content_page`.
| Property | Value |
|----------|-------|
| Display | Block |
| Filter | `field_parent_page` = Current node ID (contextual) |
| Sort | Title (A-Z) or weight (if implemented) |
| Usage | Sidebar navigation in guides/manuals |
### 9. Settings Form
Configuration form at `/admin/config/local-modules/site-structure` that allows administrators to:
- Select which node bundles can be used as parents
- Select which taxonomy vocabularies can be used as parents
- Changes automatically update the field configuration
---
## File Structure
```
site_structure/
├── site_structure.info.yml # Metadata and dependencies
├── site_structure.module # Hooks (presave, tokens)
├── site_structure.install # Installation hooks
├── site_structure.services.yml # Service registration
├── site_structure.routing.yml # Route definitions
├── site_structure.links.menu.yml # Admin menu links
├── config/
│ ├── install/
│ │ ├── site_structure.settings.yml # Default settings
│ │ ├── taxonomy.vocabulary.site_section.yml
│ │ │
│ │ ├── node.type.section_page.yml
│ │ ├── field.storage.node.field_site_section.yml
│ │ ├── field.field.node.section_page.field_site_section.yml
│ │ ├── core.entity_form_display.node.section_page.default.yml
│ │ ├── core.entity_view_display.node.section_page.default.yml
│ │ │
│ │ ├── node.type.content_page.yml
│ │ ├── field.storage.node.field_parent_page.yml # dynamic_entity_reference
│ │ ├── field.field.node.content_page.field_parent_page.yml
│ │ ├── field.field.node.content_page.field_site_section.yml
│ │ ├── core.entity_form_display.node.content_page.default.yml
│ │ ├── core.entity_view_display.node.content_page.default.yml
│ │ │
│ │ ├── pathauto.pattern.section_page.yml
│ │ ├── pathauto.pattern.content_page.yml
│ │ ├── pathauto.pattern.site_section_term.yml
│ │ │
│ │ └── views.view.child_pages.yml
│ │
│ └── schema/
│ └── site_structure.schema.yml # Config schema
├── translations/
│ └── pt-br.po # Portuguese (Brazil) translation
├── src/
│ ├── Breadcrumb/
│ │ └── SectionBreadcrumbBuilder.php
│ │
│ └── Form/
│ └── SiteStructureSettingsForm.php
└── docs/
└── DESIGN.md # This document
```
---
## Dependencies
### Core Modules (Drupal 11)
- `node`
- `taxonomy`
- `views`
- `field`
- `text`
- `user`
### Contrib Modules
- `token` - For custom tokens
- `pathauto` - For automatic URL generation
- `dynamic_entity_reference` - For multi-type parent references
### Optional Modules
- `group` - For group entity support as parent type
---
## Editor Workflow
### Creating a Section Page (section_page)
1. Navigate to Content > Add content > Section Page
2. Fill in title and body
3. Select the appropriate site section
4. Save
5. URL and breadcrumb are generated automatically
### Creating a Content Page (content_page)
1. **Create root page (anchored to taxonomy term)**:
- Add content > Content Page
- In "Parent Page", select a site_section term
- Site section is set automatically from the term
- Save
2. **Create root page (anchored to section_page)**:
- Add content > Content Page
- In "Parent Page", select a section_page
- Site section is inherited from the section_page
- Save
3. **Create child pages**:
- Add content > Content Page
- In "Parent Page", select another content_page
- Site section is inherited automatically
- Save
4. The child pages View appears automatically on parent pages
---
## Administration
### Configuring Allowed Parent Types
1. Navigate to `/admin/config/local-modules/site-structure`
2. Select which content types (nodes) can be used as parents
3. Select which taxonomy vocabularies can be used as parents
4. Save configuration
5. Field configuration is updated automatically
---
## Group Integration
The module supports the Group module for content organization:
- Content pages can have groups as parent entities
- When a content_page has a group as parent, the context is the group itself
- Breadcrumbs show: Home > Group Name > Parent Nodes > Current Page
- `field_site_section` is cleared for group-parented content (context is the group)
- Per-section permissions can be implemented via Groups
**Configuration**: Enable group types in the settings form at `/admin/config/local-modules/site-structure`
---
## Installation Verification
1. `drush en site_structure -y`
2. Verify vocabulary at `/admin/structure/taxonomy`
3. Verify content types at `/admin/structure/types`
4. Create test hierarchical terms
5. Create `section_page` and verify breadcrumb/URL
6. Create root `content_page` with taxonomy term as parent
7. Create child `content_page`, verify inheritance
8. Verify child pages View
9. Test settings form at `/admin/config/local-modules/site-structure`
---
## Changelog
| Version | Date | Description |
|---------|------|-------------|
| 1.0.0 | - | Initial version |
| 1.1.0 | - | Added dynamic_entity_reference support for multi-type parents |
| 1.2.0 | - | Added user and group entity support as parent types |

15
site_structure.info.yml Normal file
View File

@@ -0,0 +1,15 @@
name: 'Site Structure'
type: module
description: 'Implements hierarchical editorial structure and contextual navigation for institutional sites.'
package: Custom
core_version_requirement: ^11
configure: site_structure.settings
dependencies:
- drupal:node
- drupal:taxonomy
- drupal:views
- drupal:field
- drupal:text
- token:token
- pathauto:pathauto
- dynamic_entity_reference:dynamic_entity_reference

206
site_structure.install Normal file
View File

@@ -0,0 +1,206 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Site Structure module.
*/
declare(strict_types=1);
use Drupal\taxonomy\Entity\Term;
/**
* Implements hook_install().
*/
function site_structure_install(): void {
// Create default terms for site_section vocabulary.
_site_structure_create_default_terms();
// Display success message.
\Drupal::messenger()->addStatus(t('Site Structure module installed successfully. Configure Pathauto patterns at /admin/config/search/path/patterns'));
}
/**
* Creates default terms for the site_section vocabulary.
*/
function _site_structure_create_default_terms(): void {
$vocabulary = 'site_section';
// Check if terms already exist (avoid duplication on reinstall).
$existing = \Drupal::entityQuery('taxonomy_term')
->accessCheck(FALSE)
->condition('vid', $vocabulary)
->count()
->execute();
if ($existing > 0) {
return;
}
// Structure: name => children.
$terms_structure = [
'News' => [],
'Events' => [],
'People' => [],
'Institutional' => [
'About',
'Communication',
'Information and Services',
'Team',
'Management',
'Inclusion and Belonging',
],
'Undergraduate' => [
'Statistics',
'Mathematics',
'Applied Mathematics',
'Mathematics Teaching',
],
'Graduate' => [
'Statistics Program',
'Mathematics Program',
'Applied Mathematics Program',
],
'Research' => [],
'Extension' => [],
'Administration' => [],
'Departments' => [
'Statistics Department',
'Mathematics Department',
'Applied Mathematics Department',
],
'Library' => [],
'IT Services' => [],
];
$weight = 0;
foreach ($terms_structure as $parent_name => $children) {
// Create parent term.
$parent_term = Term::create([
'vid' => $vocabulary,
'name' => $parent_name,
'weight' => $weight++,
]);
$parent_term->save();
// Create child terms.
$child_weight = 0;
foreach ($children as $child_name) {
$child_term = Term::create([
'vid' => $vocabulary,
'name' => $child_name,
'parent' => $parent_term->id(),
'weight' => $child_weight++,
]);
$child_term->save();
}
}
}
/**
* Implements hook_uninstall().
*/
function site_structure_uninstall(): void {
$entity_type_manager = \Drupal::entityTypeManager();
// Remove nodes from module's content types.
foreach (['section_page', 'content_page'] as $bundle) {
$nids = \Drupal::entityQuery('node')
->accessCheck(FALSE)
->condition('type', $bundle)
->execute();
if ($nids) {
$nodes = $entity_type_manager->getStorage('node')->loadMultiple($nids);
$entity_type_manager->getStorage('node')->delete($nodes);
\Drupal::messenger()->addWarning(t('Deleted @count @bundle nodes.', [
'@count' => count($nids),
'@bundle' => $bundle,
]));
}
}
// Remove terms from site_section vocabulary.
$tids = \Drupal::entityQuery('taxonomy_term')
->accessCheck(FALSE)
->condition('vid', 'site_section')
->execute();
if ($tids) {
$terms = $entity_type_manager->getStorage('taxonomy_term')->loadMultiple($tids);
$entity_type_manager->getStorage('taxonomy_term')->delete($terms);
}
// Remove configurations in correct order (dependencies first).
$configs_to_delete = [
// Views.
'views.view.child_pages',
// Pathauto patterns.
'pathauto.pattern.section_page',
'pathauto.pattern.content_page',
'pathauto.pattern.site_section_term',
// Entity displays.
'core.entity_form_display.node.section_page.default',
'core.entity_view_display.node.section_page.default',
'core.entity_form_display.node.content_page.default',
'core.entity_view_display.node.content_page.default',
// Field instances.
'field.field.node.section_page.field_site_section',
'field.field.node.section_page.body',
'field.field.node.content_page.field_parent_page',
'field.field.node.content_page.field_site_section',
'field.field.node.content_page.body',
// Field storages (only if not used by other bundles).
'field.storage.node.field_site_section',
'field.storage.node.field_parent_page',
// Node types.
'node.type.section_page',
'node.type.content_page',
// Vocabulary.
'taxonomy.vocabulary.site_section',
];
$config_factory = \Drupal::configFactory();
foreach ($configs_to_delete as $config_name) {
$config = $config_factory->getEditable($config_name);
if (!$config->isNew()) {
$config->delete();
}
}
\Drupal::messenger()->addStatus(t('Site Structure module uninstalled successfully.'));
}
/**
* Implements hook_requirements().
*/
function site_structure_requirements(string $phase): array {
$requirements = [];
if ($phase === 'runtime') {
// Check if site_section vocabulary has terms.
$term_count = \Drupal::entityQuery('taxonomy_term')
->accessCheck(FALSE)
->condition('vid', 'site_section')
->count()
->execute();
if ($term_count === 0) {
$requirements['site_structure_terms'] = [
'title' => t('Site Structure'),
'value' => t('No terms in site_section vocabulary'),
'description' => t('The Site Structure module requires terms in the "Site Section" vocabulary. <a href=":url">Add terms</a>.', [
':url' => '/admin/structure/taxonomy/manage/site_section/add',
]),
'severity' => REQUIREMENT_WARNING,
];
}
else {
$requirements['site_structure_terms'] = [
'title' => t('Site Structure'),
'value' => t('@count terms configured', ['@count' => $term_count]),
'severity' => REQUIREMENT_OK,
];
}
}
return $requirements;
}

View File

@@ -0,0 +1,6 @@
site_structure.settings:
title: 'Site Structure'
description: 'Configure allowed parent entity types for content pages.'
route_name: site_structure.settings
parent: site_tools.admin_config
weight: 0

298
site_structure.module Normal file
View File

@@ -0,0 +1,298 @@
<?php
/**
* @file
* Primary module hooks for Site Structure module.
*/
declare(strict_types=1);
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\TermInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_entity_presave().
*
* Inherits field_site_section from parent to content_page.
* Validates circular reference in field_parent_page.
*/
function site_structure_entity_presave(EntityInterface $entity): void {
if (!$entity instanceof NodeInterface || $entity->bundle() !== 'content_page') {
return;
}
// Check if has parent page.
if (!$entity->hasField('field_parent_page') || $entity->get('field_parent_page')->isEmpty()) {
return;
}
$parent_field = $entity->get('field_parent_page')->first();
if (!$parent_field) {
return;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
return;
}
// Circular reference validation (only for node parents).
if ($parent_entity_type === 'node' && !$entity->isNew() && $parent_id) {
if (_site_structure_creates_circular_reference($entity->id(), $parent_id)) {
\Drupal::messenger()->addError(t('Circular reference detected. A page cannot be a parent of itself or its ancestors.'));
// Remove invalid reference.
$entity->set('field_parent_page', NULL);
return;
}
}
// Handle site section based on parent type.
// For user and group, we don't set field_site_section - the context is the entity itself.
if (in_array($parent_entity_type, ['user', 'group'])) {
// Clear field_site_section as the context is the user/group, not a site section.
$entity->set('field_site_section', NULL);
return;
}
// Inherit field_site_section based on parent type (node or taxonomy_term).
$site_section_id = _site_structure_get_section_from_parent($parent_entity_type, $parent_id);
if ($site_section_id) {
$entity->set('field_site_section', $site_section_id);
}
}
/**
* Gets the site section ID from a parent entity.
*
* @param string $parent_entity_type
* The parent entity type (node or taxonomy_term).
* @param int|string $parent_id
* The parent entity ID.
*
* @return int|string|null
* The site section term ID, or NULL if not found.
*/
function _site_structure_get_section_from_parent(string $parent_entity_type, int|string $parent_id): int|string|null {
$entity_type_manager = \Drupal::entityTypeManager();
if ($parent_entity_type === 'taxonomy_term') {
// If parent is a taxonomy term, verify it's from site_section vocabulary.
$term = $entity_type_manager->getStorage('taxonomy_term')->load($parent_id);
if ($term instanceof TermInterface && $term->bundle() === 'site_section') {
return $term->id();
}
return NULL;
}
if ($parent_entity_type === 'node') {
$parent_node = $entity_type_manager->getStorage('node')->load($parent_id);
if (!$parent_node instanceof NodeInterface) {
return NULL;
}
// If parent has field_site_section, use it.
if ($parent_node->hasField('field_site_section') && !$parent_node->get('field_site_section')->isEmpty()) {
return $parent_node->get('field_site_section')->target_id;
}
}
return NULL;
}
/**
* Gets the parent context information for a content_page.
*
* @param \Drupal\node\NodeInterface $node
* The content_page node.
*
* @return array|null
* An array with 'type' and 'entity' keys, or NULL if no parent.
*/
function _site_structure_get_parent_context(NodeInterface $node): ?array {
if (!$node->hasField('field_parent_page') || $node->get('field_parent_page')->isEmpty()) {
return NULL;
}
$parent_field = $node->get('field_parent_page')->first();
if (!$parent_field) {
return NULL;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
return NULL;
}
$parent = \Drupal::entityTypeManager()
->getStorage($parent_entity_type)
->load($parent_id);
if (!$parent) {
return NULL;
}
return [
'type' => $parent_entity_type,
'entity' => $parent,
];
}
/**
* Checks if setting parent_id as parent of node_id would create circular reference.
*
* @param int|string $node_id
* The ID of the node being edited.
* @param int|string $parent_id
* The ID of the potential parent.
*
* @return bool
* TRUE if it would create circular reference, FALSE otherwise.
*/
function _site_structure_creates_circular_reference(int|string $node_id, int|string $parent_id): bool {
$visited = [];
$current_id = $parent_id;
$node_storage = \Drupal::entityTypeManager()->getStorage('node');
while ($current_id) {
// If we find the original node in the parent chain, it's circular.
if ($current_id == $node_id) {
return TRUE;
}
// Avoid infinite loops in case of corrupted data.
if (isset($visited[$current_id])) {
return TRUE;
}
$visited[$current_id] = TRUE;
// Load the current node and get its parent.
$current_node = $node_storage->load($current_id);
if (!$current_node instanceof NodeInterface ||
!$current_node->hasField('field_parent_page') ||
$current_node->get('field_parent_page')->isEmpty()) {
break;
}
$parent_field = $current_node->get('field_parent_page')->first();
// Only continue checking if parent is also a node.
if (!$parent_field || ($parent_field->target_type ?? NULL) !== 'node') {
break;
}
$current_id = $parent_field->target_id;
}
return FALSE;
}
/**
* Implements hook_token_info().
*/
function site_structure_token_info(): array {
$info = [];
$info['tokens']['node']['site-section-path'] = [
'name' => t('Site Section Path'),
'description' => t('The hierarchical path of the site_section taxonomy (e.g., undergraduate/courses).'),
];
$info['tokens']['term']['hierarchy-path'] = [
'name' => t('Hierarchy Path'),
'description' => t('The hierarchical path of the term including ancestors (e.g., institutional/news).'),
];
return $info;
}
/**
* Implements hook_tokens().
*/
function site_structure_tokens(string $type, array $tokens, array $data, array $options, $bubbleable_metadata): array {
$replacements = [];
if ($type === 'node' && !empty($data['node'])) {
$node = $data['node'];
foreach ($tokens as $name => $original) {
if ($name === 'site-section-path') {
$replacements[$original] = _site_structure_get_section_path($node);
}
}
}
if ($type === 'term' && !empty($data['term'])) {
$term = $data['term'];
foreach ($tokens as $name => $original) {
if ($name === 'hierarchy-path') {
$replacements[$original] = _site_structure_get_term_hierarchy_path($term);
}
}
}
return $replacements;
}
/**
* Gets the hierarchical path of the site section for a node.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return string
* The section path (e.g., "undergraduate/courses") or empty string.
*/
function _site_structure_get_section_path(NodeInterface $node): string {
if (!$node->hasField('field_site_section') || $node->get('field_site_section')->isEmpty()) {
return '';
}
$term_id = $node->get('field_site_section')->target_id;
$term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
$term = $term_storage->load($term_id);
if (!$term) {
return '';
}
// Get all ancestors of the term.
$ancestors = $term_storage->loadAllParents($term_id);
$ancestors = array_reverse($ancestors);
// Build the path using the term names converted to URL.
$path_parts = [];
foreach ($ancestors as $ancestor) {
$path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName());
}
return implode('/', $path_parts);
}
/**
* Gets the hierarchical path of a taxonomy term.
*
* @param \Drupal\taxonomy\TermInterface $term
* The taxonomy term.
*
* @return string
* The hierarchical path (e.g., "institutional/news") or empty string.
*/
function _site_structure_get_term_hierarchy_path(TermInterface $term): string {
$term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
// Get all ancestors of the term (including itself).
$ancestors = $term_storage->loadAllParents($term->id());
$ancestors = array_reverse($ancestors);
// Build the path using the term names converted to URL.
$path_parts = [];
foreach ($ancestors as $ancestor) {
$path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName());
}
return implode('/', $path_parts);
}

View File

@@ -0,0 +1,7 @@
site_structure.settings:
path: '/admin/config/local-modules/site-structure'
defaults:
_form: '\Drupal\site_structure\Form\SiteStructureSettingsForm'
_title: 'Site Structure Settings'
requirements:
_permission: 'administer site configuration'

View File

@@ -0,0 +1,6 @@
services:
site_structure.breadcrumb.section:
class: Drupal\site_structure\Breadcrumb\SectionBreadcrumbBuilder
arguments: ['@entity_type.manager']
tags:
- { name: breadcrumb_builder, priority: 100 }

14
site_structure.tokens.inc Normal file
View File

@@ -0,0 +1,14 @@
<?php
/**
* @file
* Token integration for Site Structure module.
*
* Este arquivo existe para compatibilidade, mas os tokens são definidos
* diretamente em site_structure.module via hook_token_info() e hook_tokens().
*/
declare(strict_types=1);
// Os tokens são implementados em site_structure.module.
// Este arquivo pode ser usado para funções auxiliares adicionais se necessário.

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\Breadcrumb;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Breadcrumb\BreadcrumbBuilderInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\node\NodeInterface;
/**
* Provides a breadcrumb builder for section_page and content_page content types.
*/
class SectionBreadcrumbBuilder implements BreadcrumbBuilderInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* Content types that this builder applies to.
*
* @var array
*/
protected array $applicableBundles = ['section_page', 'content_page'];
/**
* Constructs a SectionBreadcrumbBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function applies(RouteMatchInterface $route_match): bool {
$node = $route_match->getParameter('node');
if ($node instanceof NodeInterface) {
return in_array($node->bundle(), $this->applicableBundles, TRUE);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function build(RouteMatchInterface $route_match): Breadcrumb {
$breadcrumb = new Breadcrumb();
$breadcrumb->addCacheContexts(['route']);
$node = $route_match->getParameter('node');
if (!$node instanceof NodeInterface) {
return $breadcrumb;
}
$breadcrumb->addCacheableDependency($node);
// Add "Home" as first item.
$breadcrumb->addLink(Link::createFromRoute($this->t('Home'), '<front>'));
// Determine the context type for content_page.
$context = $this->getParentContext($node);
if ($context) {
switch ($context['type']) {
case 'user':
// User context: Home > User Name > ... > Current Page.
$this->addUserBreadcrumb($breadcrumb, $context['entity']);
break;
case 'group':
// Group context: Home > Group Name > ... > Current Page.
$this->addGroupBreadcrumb($breadcrumb, $context['entity']);
break;
default:
// Node or taxonomy context: use site section.
if ($node->hasField('field_site_section') && !$node->get('field_site_section')->isEmpty()) {
$term_id = $node->get('field_site_section')->target_id;
$this->addTaxonomyBreadcrumbs($breadcrumb, $term_id);
}
break;
}
}
else {
// No parent context, check for site section directly.
if ($node->hasField('field_site_section') && !$node->get('field_site_section')->isEmpty()) {
$term_id = $node->get('field_site_section')->target_id;
$this->addTaxonomyBreadcrumbs($breadcrumb, $term_id);
}
}
// For content_page, add node parent hierarchy.
if ($node->bundle() === 'content_page') {
$this->addParentBreadcrumbs($breadcrumb, $node);
}
return $breadcrumb;
}
/**
* Gets the root parent context for a content_page.
*
* Traverses up the parent chain to find the root context (user, group, or taxonomy).
*
* @param \Drupal\node\NodeInterface $node
* The content_page node.
*
* @return array|null
* An array with 'type' and 'entity' keys, or NULL if no context.
*/
protected function getParentContext(NodeInterface $node): ?array {
if ($node->bundle() !== 'content_page') {
return NULL;
}
$visited = [];
$current = $node;
while ($current instanceof NodeInterface &&
$current->hasField('field_parent_page') &&
!$current->get('field_parent_page')->isEmpty()) {
$parent_field = $current->get('field_parent_page')->first();
if (!$parent_field) {
break;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
break;
}
// Avoid infinite loops.
$visit_key = $parent_entity_type . ':' . $parent_id;
if (isset($visited[$visit_key])) {
break;
}
$visited[$visit_key] = TRUE;
$parent = $this->entityTypeManager
->getStorage($parent_entity_type)
->load($parent_id);
if (!$parent) {
break;
}
// If parent is user or group, that's our context.
if (in_array($parent_entity_type, ['user', 'group'])) {
return [
'type' => $parent_entity_type,
'entity' => $parent,
];
}
// If parent is taxonomy_term, that's our context (handled via site_section).
if ($parent_entity_type === 'taxonomy_term') {
return [
'type' => 'taxonomy_term',
'entity' => $parent,
];
}
// If parent is a node, continue traversing.
if ($parent instanceof NodeInterface) {
$current = $parent;
}
else {
break;
}
}
return NULL;
}
/**
* Adds user breadcrumb.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\Core\Entity\EntityInterface $user
* The user entity.
*/
protected function addUserBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $user): void {
$breadcrumb->addCacheableDependency($user);
$breadcrumb->addLink($user->toLink());
}
/**
* Adds group breadcrumb.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\Core\Entity\EntityInterface $group
* The group entity.
*/
protected function addGroupBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $group): void {
$breadcrumb->addCacheableDependency($group);
$breadcrumb->addLink($group->toLink());
}
/**
* Adds breadcrumbs based on taxonomy hierarchy.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param int|string $term_id
* The taxonomy term ID.
*/
protected function addTaxonomyBreadcrumbs(Breadcrumb $breadcrumb, int|string $term_id): void {
$term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
$ancestors = $term_storage->loadAllParents($term_id);
$ancestors = array_reverse($ancestors);
foreach ($ancestors as $ancestor) {
$breadcrumb->addCacheableDependency($ancestor);
$breadcrumb->addLink($ancestor->toLink());
}
}
/**
* Adds breadcrumbs based on content_page parent hierarchy.
*
* Only adds node parents to the breadcrumb, not user/group/taxonomy
* which are handled separately.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\node\NodeInterface $node
* The current node.
*/
protected function addParentBreadcrumbs(Breadcrumb $breadcrumb, NodeInterface $node): void {
$parents = $this->getNodeParents($node);
$parents = array_reverse($parents);
foreach ($parents as $parent) {
$breadcrumb->addCacheableDependency($parent);
$breadcrumb->addLink($parent->toLink());
}
}
/**
* Gets all node parents of a content_page.
*
* Stops when encountering a non-node parent (user, group, taxonomy_term).
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return \Drupal\node\NodeInterface[]
* Array of parent nodes, from closest to farthest.
*/
protected function getNodeParents(NodeInterface $node): array {
$parents = [];
$visited = [];
$current = $node;
while ($current instanceof NodeInterface &&
$current->hasField('field_parent_page') &&
!$current->get('field_parent_page')->isEmpty()) {
$parent_field = $current->get('field_parent_page')->first();
if (!$parent_field) {
break;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
break;
}
// Only continue if parent is a node.
if ($parent_entity_type !== 'node') {
break;
}
// Avoid infinite loops.
if (isset($visited[$parent_id])) {
break;
}
$visited[$parent_id] = TRUE;
$parent = $this->entityTypeManager
->getStorage('node')
->load($parent_id);
if (!$parent instanceof NodeInterface) {
break;
}
$parents[] = $parent;
$current = $parent;
}
return $parents;
}
}

View File

@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Configuration form for Site Structure module.
*/
class SiteStructureSettingsForm extends ConfigFormBase {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The entity type bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected EntityTypeBundleInfoInterface $entityTypeBundleInfo;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected ModuleHandlerInterface $moduleHandler;
/**
* Entity types that can be used as parents.
*
* @var array
*/
protected array $supportedEntityTypes = [
'node' => 'Content Types (node)',
'taxonomy_term' => 'Taxonomy Vocabularies (taxonomy_term)',
'user' => 'Users (user)',
'group' => 'Groups (group)',
];
/**
* Constructs a SiteStructureSettingsForm object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
* The entity type bundle info service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
EntityTypeManagerInterface $entity_type_manager,
EntityTypeBundleInfoInterface $entity_type_bundle_info,
ModuleHandlerInterface $module_handler,
) {
parent::__construct($config_factory);
$this->entityTypeManager = $entity_type_manager;
$this->entityTypeBundleInfo = $entity_type_bundle_info;
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('config.factory'),
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('module_handler'),
);
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'site_structure_settings_form';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames(): array {
return ['site_structure.settings'];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('site_structure.settings');
$allowed_targets = $config->get('allowed_parent_targets') ?? [];
// Build a keyed array for easier lookup.
$enabled_targets = [];
foreach ($allowed_targets as $target) {
$key = $target['entity_type'] . ':' . $target['bundle'];
$enabled_targets[$key] = TRUE;
// Handle wildcard bundles.
if ($target['bundle'] === '*') {
$enabled_targets[$target['entity_type'] . ':*'] = TRUE;
}
}
$form['description'] = [
'#type' => 'markup',
'#markup' => '<p>' . $this->t('Select which entity types and bundles can be used as parent for Content Page nodes. This allows creating hierarchical structures where content pages can be children of different entity types.') . '</p>',
];
$form['context_info'] = [
'#type' => 'markup',
'#markup' => '<p>' . $this->t('<strong>Context behavior:</strong><br>
- <em>Node/Taxonomy</em>: Content pages inherit the site section from the parent.<br>
- <em>User</em>: Content pages are associated with the user profile page.<br>
- <em>Group</em>: Content pages are associated with the group.') . '</p>',
];
$form['allowed_parent_targets'] = [
'#type' => 'fieldset',
'#title' => $this->t('Allowed Parent Targets'),
'#tree' => TRUE,
];
foreach ($this->supportedEntityTypes as $entity_type => $label) {
// Check if entity type exists.
if (!$this->entityTypeManager->hasDefinition($entity_type)) {
continue;
}
// Special handling for 'group' - check if module is enabled.
if ($entity_type === 'group' && !$this->moduleHandler->moduleExists('group')) {
continue;
}
$form['allowed_parent_targets'][$entity_type] = [
'#type' => 'details',
'#title' => $this->t($label),
'#open' => TRUE,
];
// User entity type typically has only one bundle.
if ($entity_type === 'user') {
$key = 'user:user';
$form['allowed_parent_targets'][$entity_type]['user'] = [
'#type' => 'checkbox',
'#title' => $this->t('User accounts'),
'#description' => $this->t('Allow content pages to be children of user profiles.'),
'#default_value' => isset($enabled_targets[$key]),
];
continue;
}
$bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type);
// For groups, add an "all types" option.
if ($entity_type === 'group') {
$key = 'group:*';
$form['allowed_parent_targets'][$entity_type]['_all'] = [
'#type' => 'checkbox',
'#title' => $this->t('All group types'),
'#description' => $this->t('Allow all current and future group types as parents.'),
'#default_value' => isset($enabled_targets[$key]),
];
}
foreach ($bundles as $bundle_id => $bundle_info) {
$key = $entity_type . ':' . $bundle_id;
$form['allowed_parent_targets'][$entity_type][$bundle_id] = [
'#type' => 'checkbox',
'#title' => $bundle_info['label'],
'#default_value' => isset($enabled_targets[$key]),
];
// If "all group types" is selected, disable individual checkboxes.
if ($entity_type === 'group') {
$form['allowed_parent_targets'][$entity_type][$bundle_id]['#states'] = [
'disabled' => [
':input[name="allowed_parent_targets[group][_all]"]' => ['checked' => TRUE],
],
];
}
}
}
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$targets = [];
$values = $form_state->getValue('allowed_parent_targets');
foreach ($this->supportedEntityTypes as $entity_type => $label) {
if (empty($values[$entity_type])) {
continue;
}
// Handle "all group types" wildcard.
if ($entity_type === 'group' && !empty($values[$entity_type]['_all'])) {
$targets[] = [
'entity_type' => 'group',
'bundle' => '*',
];
continue;
}
foreach ($values[$entity_type] as $bundle => $enabled) {
// Skip the special "_all" key for groups if not enabled.
if ($bundle === '_all') {
continue;
}
if ($enabled) {
$targets[] = [
'entity_type' => $entity_type,
'bundle' => $bundle,
];
}
}
}
$this->config('site_structure.settings')
->set('allowed_parent_targets', $targets)
->save();
// Update field configuration to reflect new targets.
$this->updateFieldConfiguration($targets);
parent::submitForm($form, $form_state);
}
/**
* Updates the field_parent_page configuration with new targets.
*
* @param array $targets
* The allowed targets.
*/
protected function updateFieldConfiguration(array $targets): void {
$field_config = $this->entityTypeManager
->getStorage('field_config')
->load('node.content_page.field_parent_page');
if (!$field_config) {
return;
}
$settings = $field_config->getSettings();
// Build the entity type settings for dynamic_entity_reference.
$entity_type_ids = [];
$entity_type_settings = [];
foreach ($targets as $target) {
$entity_type = $target['entity_type'];
$bundle = $target['bundle'];
if (!in_array($entity_type, $entity_type_ids)) {
$entity_type_ids[] = $entity_type;
}
if (!isset($entity_type_settings[$entity_type])) {
$sort_field = match($entity_type) {
'taxonomy_term' => 'name',
'user' => 'name',
'group' => 'label',
default => 'title',
};
$entity_type_settings[$entity_type] = [
'handler' => 'default:' . $entity_type,
'handler_settings' => [
'target_bundles' => [],
'sort' => [
'field' => $sort_field,
'direction' => 'asc',
],
'auto_create' => FALSE,
],
];
}
// Handle wildcard bundles.
if ($bundle === '*') {
$entity_type_settings[$entity_type]['handler_settings']['target_bundles'] = NULL;
}
elseif ($entity_type_settings[$entity_type]['handler_settings']['target_bundles'] !== NULL) {
$entity_type_settings[$entity_type]['handler_settings']['target_bundles'][$bundle] = $bundle;
}
}
$settings['entity_type_ids'] = $entity_type_ids;
foreach ($entity_type_settings as $entity_type => $type_settings) {
$settings[$entity_type] = $type_settings;
}
$field_config->setSettings($settings);
$field_config->save();
$this->messenger()->addStatus($this->t('Field configuration updated successfully.'));
}
}

306
translations/pt-br.po Normal file
View File

@@ -0,0 +1,306 @@
# Portuguese (Brazil) translation for Site Structure module.
# Copyright (C) 2024
# This file is distributed under the same license as the Drupal package.
#
msgid ""
msgstr ""
"Project-Id-Version: Site Structure 1.0.0\n"
"POT-Creation-Date: 2024-01-01 00:00+0000\n"
"PO-Revision-Date: 2024-01-01 00:00+0000\n"
"Language-Team: Portuguese, Brazil\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pt-br\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: site_structure.info.yml
msgid "Site Structure"
msgstr "Estrutura do Site"
#: site_structure.info.yml
msgid "Implements hierarchical editorial structure and contextual navigation for institutional sites."
msgstr "Implementa estrutura editorial hierárquica e navegação contextual para sites institucionais."
#: config/install/taxonomy.vocabulary.site_section.yml
msgid "Site Section"
msgstr "Seção do Site"
#: config/install/taxonomy.vocabulary.site_section.yml
msgid "Main hierarchical structure for site organization."
msgstr "Estrutura principal de organização hierárquica do site."
#: config/install/node.type.section_page.yml
msgid "Section Page"
msgstr "Página Institucional"
#: config/install/node.type.section_page.yml
msgid "Pages organized by site section, with URLs and breadcrumbs based on taxonomy."
msgstr "Páginas organizadas por seção do site, com URLs e breadcrumbs baseados na taxonomia."
#: config/install/node.type.content_page.yml
msgid "Content Page"
msgstr "Página de Conteúdo"
#: config/install/node.type.content_page.yml
msgid "Pages with hierarchical parent-child structure for content organization."
msgstr "Páginas com estrutura hierárquica pai-filho para organização de conteúdo."
#: config/install/node.type.content_page.yml
msgid "For root pages, fill in the Site Section manually. For child pages, select the parent page and the section will be inherited automatically."
msgstr "Para páginas raiz, preencha a Seção do Site manualmente. Para páginas filhas, selecione a página pai e a seção será herdada automaticamente."
#: config/install/field.field.node.section_page.field_site_section.yml
#: config/install/field.field.node.content_page.field_site_section.yml
msgid "Select the site section this page belongs to."
msgstr "Selecione a seção do site à qual esta página pertence."
#: config/install/field.field.node.content_page.field_site_section.yml
msgid "For root pages, select the section. For child pages, this field is filled automatically."
msgstr "Para páginas raiz, selecione a seção. Para páginas filhas, este campo é preenchido automaticamente."
#: config/install/field.field.node.content_page.field_parent_page.yml
msgid "Parent Page"
msgstr "Página Pai"
#: config/install/field.field.node.content_page.field_parent_page.yml
msgid "Select the parent entity. The site section will be inherited automatically based on the parent type."
msgstr "Selecione a entidade pai. A seção do site será herdada automaticamente com base no tipo do pai."
#: config/install/field.field.node.section_page.body.yml
#: config/install/field.field.node.content_page.body.yml
msgid "Body"
msgstr "Corpo"
#: config/install/pathauto.pattern.site_section_term.yml
msgid "Site Section Term"
msgstr "Termo de Seção do Site"
#: config/install/views.view.child_pages.yml
msgid "Child Pages"
msgstr "Páginas Filhas"
#: config/install/views.view.child_pages.yml
msgid "Lists the child pages of a content_page."
msgstr "Lista as páginas filhas de uma página de conteúdo."
#: config/install/views.view.child_pages.yml
msgid "No child pages found."
msgstr "Nenhuma página filha encontrada."
#: config/install/views.view.child_pages.yml
msgid "Apply"
msgstr "Aplicar"
#: config/install/views.view.child_pages.yml
msgid "Reset"
msgstr "Limpar"
#: config/install/views.view.child_pages.yml
msgid "Sort by"
msgstr "Ordenar por"
#: config/install/views.view.child_pages.yml
msgid "Asc"
msgstr "Crescente"
#: config/install/views.view.child_pages.yml
msgid "Desc"
msgstr "Decrescente"
#: config/install/views.view.child_pages.yml
msgid "All"
msgstr "Todos"
#: config/install/views.view.child_pages.yml
msgid "Block: Child Pages"
msgstr "Bloco: Páginas Filhas"
#: site_structure.install
msgid "Site Structure module installed successfully. Configure Pathauto patterns at /admin/config/search/path/patterns"
msgstr "Módulo Site Structure instalado com sucesso. Configure os padrões Pathauto em /admin/config/search/path/patterns"
#: site_structure.install
msgid "Site Structure module uninstalled successfully."
msgstr "Módulo Site Structure desinstalado com sucesso."
#: site_structure.install
msgid "Deleted @count @bundle nodes."
msgstr "@count nós do tipo @bundle excluídos."
#: site_structure.install
msgid "No terms in site_section vocabulary"
msgstr "Nenhum termo no vocabulário site_section"
#: site_structure.install
msgid "The Site Structure module requires terms in the \"Site Section\" vocabulary. <a href=\":url\">Add terms</a>."
msgstr "O módulo Site Structure requer termos no vocabulário \"Seção do Site\". <a href=\":url\">Adicionar termos</a>."
#: site_structure.install
msgid "@count terms configured"
msgstr "@count termos configurados"
#: site_structure.module
msgid "Circular reference detected. A page cannot be a parent of itself or its ancestors."
msgstr "Referência circular detectada. Uma página não pode ser pai de si mesma ou de seus ancestrais."
#: site_structure.module
msgid "Site Section Path"
msgstr "Caminho da Seção do Site"
#: site_structure.module
msgid "The hierarchical path of the site_section taxonomy (e.g., undergraduate/courses)."
msgstr "O caminho hierárquico da taxonomia site_section (ex: graduacao/cursos)."
#: site_structure.module
msgid "Hierarchy Path"
msgstr "Caminho Hierárquico"
#: site_structure.module
msgid "The hierarchical path of the term including ancestors (e.g., institutional/news)."
msgstr "O caminho hierárquico do termo incluindo ancestrais (ex: institucional/noticias)."
#: src/Breadcrumb/SectionBreadcrumbBuilder.php
msgid "Home"
msgstr "Início"
#: src/Form/SiteStructureSettingsForm.php
msgid "Site Structure Settings"
msgstr "Configurações da Estrutura do Site"
#: src/Form/SiteStructureSettingsForm.php
msgid "Select which entity types and bundles can be used as parent for Content Page nodes. This allows creating hierarchical structures where content pages can be children of different entity types."
msgstr "Selecione quais tipos de entidade e bundles podem ser usados como pai para nós de Página de Conteúdo. Isso permite criar estruturas hierárquicas onde páginas de conteúdo podem ser filhas de diferentes tipos de entidade."
#: src/Form/SiteStructureSettingsForm.php
msgid "<strong>Context behavior:</strong><br>\n - <em>Node/Taxonomy</em>: Content pages inherit the site section from the parent.<br>\n - <em>User</em>: Content pages are associated with the user profile page.<br>\n - <em>Group</em>: Content pages are associated with the group."
msgstr "<strong>Comportamento de contexto:</strong><br>\n - <em>Node/Taxonomia</em>: Páginas de conteúdo herdam a seção do site do pai.<br>\n - <em>Usuário</em>: Páginas de conteúdo são associadas à página de perfil do usuário.<br>\n - <em>Grupo</em>: Páginas de conteúdo são associadas ao grupo."
#: src/Form/SiteStructureSettingsForm.php
msgid "Allowed Parent Targets"
msgstr "Alvos de Pai Permitidos"
#: src/Form/SiteStructureSettingsForm.php
msgid "Content Types (node)"
msgstr "Tipos de Conteúdo (node)"
#: src/Form/SiteStructureSettingsForm.php
msgid "Taxonomy Vocabularies (taxonomy_term)"
msgstr "Vocabulários de Taxonomia (taxonomy_term)"
#: src/Form/SiteStructureSettingsForm.php
msgid "Users (user)"
msgstr "Usuários (user)"
#: src/Form/SiteStructureSettingsForm.php
msgid "Groups (group)"
msgstr "Grupos (group)"
#: src/Form/SiteStructureSettingsForm.php
msgid "User accounts"
msgstr "Contas de usuário"
#: src/Form/SiteStructureSettingsForm.php
msgid "Allow content pages to be children of user profiles."
msgstr "Permite que páginas de conteúdo sejam filhas de perfis de usuário."
#: src/Form/SiteStructureSettingsForm.php
msgid "All group types"
msgstr "Todos os tipos de grupo"
#: src/Form/SiteStructureSettingsForm.php
msgid "Allow all current and future group types as parents."
msgstr "Permite todos os tipos de grupo atuais e futuros como pais."
#: src/Form/SiteStructureSettingsForm.php
msgid "Field configuration updated successfully."
msgstr "Configuração do campo atualizada com sucesso."
#: site_structure.links.menu.yml
msgid "Configure allowed parent entity types for content pages."
msgstr "Configure os tipos de entidade pai permitidos para páginas de conteúdo."
#: Default taxonomy terms (site_structure.install)
msgid "News"
msgstr "Notícias"
msgid "Events"
msgstr "Eventos"
msgid "People"
msgstr "Pessoas"
msgid "Institutional"
msgstr "Institucional"
msgid "About"
msgstr "Sobre"
msgid "Communication"
msgstr "Comunicação"
msgid "Information and Services"
msgstr "Informações e Serviços"
msgid "Team"
msgstr "Equipe"
msgid "Management"
msgstr "Gestão"
msgid "Inclusion and Belonging"
msgstr "Inclusão e Pertencimento"
msgid "Undergraduate"
msgstr "Graduação"
msgid "Statistics"
msgstr "Estatística"
msgid "Mathematics"
msgstr "Matemática"
msgid "Applied Mathematics"
msgstr "Matemática Aplicada"
msgid "Mathematics Teaching"
msgstr "Licenciatura em Matemática"
msgid "Graduate"
msgstr "Pós-Graduação"
msgid "Statistics Program"
msgstr "Programa de Estatística"
msgid "Mathematics Program"
msgstr "Programa de Matemática"
msgid "Applied Mathematics Program"
msgstr "Programa de Matemática Aplicada"
msgid "Research"
msgstr "Pesquisa"
msgid "Extension"
msgstr "Extensão"
msgid "Administration"
msgstr "Administração"
msgid "Departments"
msgstr "Departamentos"
msgid "Statistics Department"
msgstr "Departamento de Estatística"
msgid "Mathematics Department"
msgstr "Departamento de Matemática"
msgid "Applied Mathematics Department"
msgstr "Departamento de Matemática Aplicada"
msgid "Library"
msgstr "Biblioteca"
msgid "IT Services"
msgstr "Informática"