Skip to content

Latest commit

 

History

History
1650 lines (1286 loc) · 47.5 KB

File metadata and controls

1650 lines (1286 loc) · 47.5 KB

PagePart System Documentation

Table of Contents

  1. Overview
  2. Architecture
  3. Core Components
  4. How It Works
  5. Theme Integration
  6. Usage Examples
  7. Seed Management & Updates
  8. Multi-Tenancy & Tenant Isolation
  9. Pros and Cons Analysis
  10. Recommendations for Improvement

Overview

The PagePart system in Property Web Builder is a content management architecture that enables dynamic, multi-locale page composition using Liquid templates and editable content blocks. It allows non-technical users to edit structured content through a visual editor while maintaining consistent layouts defined by templates.

Key Concept

PageParts separate structure (templates) from content (strings, images, HTML), enabling:

  • Multi-language content without duplicating templates
  • Visual editing of page sections without touching code
  • Reusable content across different pages
  • Theme-agnostic content storage

Architecture

Database Schema

The system consists of three primary tables:

1. pwb_page_parts

Defines the structure and configuration of each page section.

# Key columns:
page_part_key       # Unique identifier (e.g., "landing_hero", "our_agency")
page_slug           # Associated page (e.g., "home", "about-us") or "website" for global parts
website_id          # Tenant isolation - each page_part belongs to a specific website
template            # Liquid template HTML
editor_setup        # JSON configuration for the editor UI
block_contents      # JSON storing actual content per locale
is_rails_part       # Boolean - if true, uses Rails partial instead of template
show_in_editor      # Boolean - visibility in admin editor
order_in_editor     # Sort order in admin UI
theme_name          # Optional future use for theme-specific templates
locale              # Optional future use for locale-specific templates

2. pwb_page_contents (Join Model)

Acts as a join table between pages/websites and content, storing display metadata.

# Key columns:
page_id             # Associated page (optional - can be website-level)
website_id          # Associated website (optional)
content_id          # Associated content
page_part_key       # Links to pwb_page_parts
sort_order          # Display order on page
visible_on_page     # Boolean - show/hide content
is_rails_part       # Boolean - whether this uses a Rails component

Why a join model?

  • Allows the same content to appear on multiple pages with different visibility/sort settings
  • Provides flexibility for future content reuse scenarios
  • Separates content ownership from presentation concerns

3. pwb_contents

Stores the actual rendered HTML content in multiple locales.

# Key columns:
page_part_key       # Links back to page part definition
website_id          # Associated website
raw                 # Default locale HTML content
raw_en, raw_es, etc # Globalized columns for each locale

Entity Relationships

erDiagram
    PWB_PAGES ||--o{ PWB_PAGE_PARTS : "has many (via page_slug)"
    PWB_PAGES ||--o{ PWB_PAGE_CONTENTS : "has many"
    PWB_WEBSITES ||--o{ PWB_PAGE_PARTS : "has many (page_slug='website')"
    PWB_WEBSITES ||--o{ PWB_PAGE_CONTENTS : "has many"
    PWB_PAGE_PARTS ||--o{ PWB_PAGE_CONTENTS : "defines structure for"
    PWB_PAGE_CONTENTS }o--|| PWB_CONTENTS : "belongs to"
    PWB_CONTENTS ||--o{ PWB_CONTENT_PHOTOS : "has many"
    
    PWB_PAGES {
        string slug PK
        json details
        string page_title
        string link_title
    }
    
    PWB_PAGE_PARTS {
        int id PK
        string page_part_key
        string page_slug FK
        int website_id FK
        text template
        json editor_setup
        json block_contents
        boolean is_rails_part
    }
    
    PWB_PAGE_CONTENTS {
        int id PK
        int page_id FK
        int website_id FK
        int content_id FK
        string page_part_key
        int sort_order
        boolean visible_on_page
    }
    
    PWB_CONTENTS {
        int id PK
        string page_part_key
        text raw
        text raw_en
        text raw_es
    }
Loading

Core Components

1. PagePart Model

File: app/models/pwb/page_part.rb

class PagePart < ApplicationRecord
  belongs_to :page, optional: true, foreign_key: "page_slug", primary_key: "slug"
  belongs_to :website, optional: true  # Tenant isolation
  
  # Loads configuration from YAML seed files
  def self.create_from_seed_yml(yml_file_name)
    # Creates PagePart from db/yml_seeds/page_parts/*.yml
  end
end

Responsibilities:

  • Stores template and editor configuration
  • Belongs to either a Page (via page_slug) or Website (page_slug = "website")
  • Loaded from YAML configuration files during seeding
  • Tenant-scoped: Each website has its own isolated page_parts

Multi-Tenancy: For tenant-isolated operations, use the scoped model:

# app/models/pwb_tenant/page_part.rb
module PwbTenant
  class PagePart < Pwb::PagePart
    include PwbTenant::ScopedModel  # Automatic website_id scoping
  end
end

# Usage:
PwbTenant::PagePart.all  # Returns only current website's page_parts

2. PagePartManager Service

File: app/services/pwb/page_part_manager.rb

The manager class orchestrates all page_part operations.

class PagePartManager
  attr_accessor :page_part_key, :page_part, :container
  
  def initialize(page_part_key, container)
    # container = Page or Website instance
    self.container = container
    self.page_part = container.get_page_part(page_part_key)
  end
end

Key Methods:

Method Purpose
find_or_create_content Creates Content record and PageContent join model
find_or_create_join_model Ensures PageContent record exists (critical for Rails parts)
seed_container_block_content Populates content from YAML seed files
update_page_part_content Updates block contents and regenerates HTML
set_default_page_content_order_and_visibility Sets initial display settings
rebuild_page_content Parses Liquid template with block contents to generate HTML

Workflow Example:

# 1. Initialize manager
manager = PagePartManager.new("landing_hero", home_page)

# 2. Seed content from YAML
seed_content = {
  "landing_title_a" => "Welcome to Our Agency",
  "landing_content_a" => "<p>Your trusted real estate partner</p>",
  "landing_img" => "path/to/image.jpg"
}
manager.seed_container_block_content("en", seed_content)

# 3. Result:
# - block_contents['en'] stored in PagePart
# - Liquid template rendered with block data
# - HTML saved to Content.raw_en

3. Page and Website Models

Both Page and Website act as containers for page_parts:

class Page < ApplicationRecord
  has_many :page_parts, foreign_key: "page_slug", primary_key: "slug"
  has_many :page_contents
  has_many :contents, through: :page_contents
  has_many :ordered_visible_page_contents, -> { ordered_visible }
  
  def get_page_part(page_part_key)
    page_parts.where(page_part_key: page_part_key).first
  end
end

class Website < ApplicationRecord
  has_many :page_contents
  has_many :contents, through: :page_contents
  has_many :ordered_visible_page_contents, -> { ordered_visible }
  
  def page_parts
    PagePart.where(page_slug: "website")
  end
  
  def get_page_part(page_part_key)
    page_parts.where(page_part_key: page_part_key).first
  end
end

Note: Website-level page_parts (e.g., footer) have page_slug = "website" and are accessible across all pages.

4. Content and PageContent Models

Content stores rendered HTML:

class Content < ApplicationRecord
  belongs_to :website, optional: true
  has_many :content_photos, dependent: :destroy
  has_many :page_contents
  has_many :pages, through: :page_contents
  
  translates :raw  # Globalize gem for i18n
end

PageContent join model:

class PageContent < ApplicationRecord
  belongs_to :page, optional: true
  belongs_to :content, optional: true
  belongs_to :website, optional: true
  
  validates_presence_of :page_part_key
  
  scope :ordered_visible, -> { 
    includes(content: :translations)
      .where(visible_on_page: true)
      .order("sort_order asc")
  }
end

How It Works

Content Lifecycle

graph TD
    A[YAML Config File] -->|1. Seed| B[PagePart Record]
    B -->|2. PagePartManager.seed_container_block_content| C[PagePart.block_contents JSON]
    C -->|3. Liquid Template Parsing| D[Rendered HTML]
    D -->|4. Save| E[Content.raw_locale]
    B -->|5. Create Join| F[PageContent Record]
    F -->|6. Link| E
    
    G[Admin Editor] -->|7. Update blocks| C
    C -->|8. Rebuild| D
    
    H[Page View] -->|9. Load ordered_visible_page_contents| F
    F -->|10. Retrieve| E
    E -->|11. Display| I[User Sees Content]
Loading

Step-by-Step Workflow

1. Seeding Phase (Initial Setup)

# db/yml_seeds/page_parts/home__landing_hero.yml
- page_slug: home
  page_part_key: landing_hero
  editor_setup:
    editorBlocks:
      - - label: "landing_title_a"
          isSingleLineText: "true"
      - - label: "landing_img"
          isImage: "true"
  template: |
    <div class="hero">
      <h1>{{ page_part["landing_title_a"]["content"] }}</h1>
      <img src="{{ page_part["landing_img"]["content"] }}" />
    </div>

Seeder Process:

  1. PagesSeeder.seed_page_parts! creates PagePart records
  2. ContentsSeeder.seed_contents! populates actual content:
    page_part_manager = PagePartManager.new("landing_hero", home_page)
    page_part_manager.seed_container_block_content("en", seed_content)
    page_part_manager.set_default_page_content_order_and_visibility

2. Content Editing (Admin Panel)

API Endpoint: Api::V1::PageController#save_page_fragment

# POST /api/v1/page/save_fragment
# Params:
{
  page_slug: "home",
  fragment_details: {
    page_part_key: "landing_hero",
    locale: "en",
    blocks: {
      landing_title_a: { content: "New Title" },
      landing_img: { content: "https://example.com/image.jpg" }
    }
  }
}

# Controller logic:
page_part_manager = PagePartManager.new(page_part_key, container)
result = page_part_manager.update_page_part_content(locale, fragment_details)
# Returns: { json_fragment_block: {...}, fragment_html: "..." }

Under the Hood:

  1. Updates PagePart.block_contents[locale] with new values
  2. Renders Liquid template with updated blocks
  3. Saves rendered HTML to Content.raw_[locale]
  4. Returns both JSON and HTML to admin client

3. Rendering (Public View)

Controller: PagesController#show_page

@page = current_website.pages.find_by_slug("home")
@page.ordered_visible_page_contents.each do |page_content|
  @content_to_show.push page_content.content.raw
end

View Helper: ComponentHelper#page_part

<!-- app/views/pwb/pages/show.html.erb -->
<% @page.ordered_visible_page_contents.each do |page_content| %>
  <%= page_part(page_content) %>
<% end %>

Partial: _generic_page_part.html.erb

<div class="col-md-12">
  <%== content %>
</div>

Theme Integration

Theme Structure

Themes are located in app/themes/ and can override default components:

app/themes/
├── default/
│   └── views/pwb/components/
│       └── _generic_page_part.html.erb
├── berlin/
│   └── views/pwb/components/
│       └── _generic_page_part.html.erb (theme-specific wrapper)
└── [theme_name]/
    ├── config.yml                    # Theme metadata
    └── views/pwb/components/
        └── _generic_page_part.html.erb

How Themes Work with PageParts

  1. Content is Theme-Agnostic: PagePart templates and content are stored independently of themes
  2. Wrapper Control: Themes customize the _generic_page_part.html.erb wrapper that surrounds rendered content
  3. Future Support: The schema includes theme_name and locale columns for potential theme/locale-specific templates (not currently implemented)

Example Theme Override:

<!-- themes/berlin/_generic_page_part.html.erb -->
<section class="berlin-section">
  <div class="container">
    <%== content %>
  </div>
</section>

Rails Parts vs. Template Parts

The system supports two types of page parts:

Type is_rails_part Rendering Method
Template Part false Renders Liquid template stored in PagePart.template
Rails Part true Renders Rails partial from app/views/pwb/components/[page_part_key].html.erb

Rails Part Example:

# page_part_key: "search_cmpt"
# is_rails_part: true
# Template: (empty)

# Renders: app/views/pwb/components/_search_cmpt.html.erb

Use Case: Rails parts are used for dynamic components that need server-side logic (e.g., search forms, property listings).


Usage Examples

Example 1: Creating a New Page Section

Step 1: Create YAML configuration

# db/yml_seeds/page_parts/about-us__our_team.yml
- page_slug: about-us
  page_part_key: our_team
  block_contents: {}
  order_in_editor: 3
  show_in_editor: true
  editor_setup:
    tabTitleKey: "pageSections.ourTeam.title"
    tabInfoKey: "pageSections.ourTeam.info"
    default_sort_order: 3
    default_visible_on_page: true
    editorBlocks:
      - - label: "team_title"
          isSingleLineText: "true"
      - - label: "team_description"
          isMultipleLineText: "true"
      - - label: "team_photo"
          isImage: "true"
  template: |
    <section class="team-section">
      <h2>{{ page_part["team_title"]["content"] }}</h2>
      <p>{{ page_part["team_description"]["content"] }}</p>
      <img src="{{ page_part["team_photo"]["content"] }}" alt="Team" />
    </section>

Step 2: Seed the database

rails db:seed
# Or manually:
Pwb::PagePart.create_from_seed_yml("about-us__our_team.yml")

Step 3: Populate content

# db/yml_seeds/content_translations/en.yml
en:
  about-us:
    our_team:
      team_title: "Meet Our Team"
      team_description: "We are a dedicated group of professionals..."
      team_photo: "db/seeds/images/team.jpg"

Example 2: Website-Level Footer

# db/yml_seeds/page_parts/website__footer_content_html.yml
- page_slug: website  # Available on all pages
  page_part_key: footer_content_html
  template: |
    <footer>
      <div class="footer-content">
        {{ page_part["main_content"]["content"] }}
      </div>
    </footer>

Access in Layout:

# app/controllers/pwb/application_controller.rb
before_action :set_footer_content

def set_footer_content
  footer_page_content = current_website&.ordered_visible_page_contents&.find_by_page_part_key("footer_content_html")
  @footer_content = footer_page_content.present? ? footer_page_content.content : OpenStruct.new
end

Example 3: Multi-Locale Content

# Update Spanish content
manager = PagePartManager.new("landing_hero", home_page)

es_content = {
  "landing_title_a" => "Bienvenido a Nuestra Agencia",
  "landing_content_a" => "<p>Su socio inmobiliario de confianza</p>"
}

manager.seed_container_block_content("es", es_content)

# Access in view (auto-switches based on I18n.locale)
home_page.contents.find_by_page_part_key("landing_hero").raw
# => Returns raw_es if I18n.locale == :es

Seed Management & Updates

The system relies on YAML seed files to define the structure and initial content of page parts. When templates or default content need to be updated, a specific workflow ensures these changes are propagated to the database and the rendered HTML is rebuilt.

Seed File Locations

  • Page Part Definitions (Templates): db/yml_seeds/page_parts/*.yml
  • Content Translations (Text): db/yml_seeds/content_translations/*.yml

Update Workflow

  1. Modify Templates: Edit the Liquid HTML in the relevant page_parts YAML file.
  2. Modify Content: Edit the text content in the relevant content_translations YAML file.
  3. Run Update Task: Execute the Rake task to apply changes.
bundle exec rake pwb:db:update_page_parts

The pwb:db:update_page_parts Task

This task performs the following actions:

  1. Iterates through all YAML files in db/yml_seeds/page_parts.
  2. Updates the Pwb::PagePart record in the database with the new configuration and template.
  3. Iterates through all available locales.
  4. Loads the corresponding content translation YAML.
  5. Re-seeds the block content for the page part.
  6. Triggers Pwb::PagePartManager#rebuild_page_content to regenerate the HTML stored in Pwb::Content.
  7. Clears the Rails cache.

This ensures that changes to the static YAML files are immediately reflected in the dynamic database-driven content system.


Multi-Tenancy & Tenant Isolation

Overview

The PagePart system is fully tenant-scoped to ensure complete data isolation between different websites/tenants. Each tenant has their own independent set of page_parts, preventing cross-tenant data contamination.

Tenant-Scoped Models

The following models are tenant-isolated via the PwbTenant namespace:

PwbTenant::PagePart

module PwbTenant
  class PagePart < Pwb::PagePart
    include PwbTenant::ScopedModel
    # Automatically scoped to current website via default_scope
  end
end

PwbTenant::PageContent

module PwbTenant
  class PageContent < Pwb::PageContent
    include PwbTenant::ScopedModel
    # Automatically scoped to current website via default_scope
  end
end

PwbTenant::Page and PwbTenant::Content are also tenant-scoped.

How Tenant Scoping Works

The PwbTenant::ScopedModel concern provides automatic tenant isolation:

module PwbTenant
  module ScopedModel
    included do
      # Automatic scope to current website
      default_scope { where(website_id: Pwb::Current.website&.id) }
      
      # Auto-assign website on creation
      before_validation :set_current_website
    end
  end
end

Benefits:

  • All queries automatically filtered by website_id
  • New records automatically assigned to current website
  • Prevents accidental cross-tenant data access
  • Eliminates need for manual where(website_id: ...) clauses

Usage Examples

Creating Tenant-Scoped PageParts

# Set current website context
Pwb::Current.website = Website.find_by_subdomain('tenant-a')

# Create page_part - automatically assigned to tenant-a
page_part = PwbTenant::PagePart.create!(
  page_part_key: "custom_hero",
  page_slug: "home",
  template: "<h1>{{ page_part['title']['content'] }}</h1>",
  editor_setup: { editorBlocks: [[{ label: "title" }]] }
)
# => website_id automatically set to tenant-a's ID

# Switch context
Pwb::Current.website = Website.find_by_subdomain('tenant-b')

# Query only returns tenant-b's page_parts
PwbTenant::PagePart.all  # => Only tenant-b's records
PwbTenant::PagePart.find_by(page_part_key: "custom_hero")  # => nil (tenant-a's record not visible)

PagePartManager with Tenant Scoping

# PagePartManager works with both scoped and unscoped models
Pwb::Current.website = current_website

page = PwbTenant::Page.find_by_slug("home")  # Tenant-scoped
manager = Pwb::PagePartManager.new("landing_hero", page)

# Manager operates within tenant context
manager.seed_container_block_content("en", content_data)
# => Creates Content with website_id from current context

Migration Strategy

When the website_id column was added to pwb_page_parts, a comprehensive migration handled existing data:

Migration: AddWebsiteIdToPageParts

  1. Page-level page_parts: Assigned website_id based on their associated page's website_id
  2. Website-level page_parts (page_slug = "website"): Duplicated across all tenants
  3. Indexes: Updated unique constraints to include website_id
# Example backfill logic
UPDATE pwb_page_parts pp
SET website_id = (
  SELECT p.website_id
  FROM pwb_pages p
  WHERE pp.page_slug = p.slug
  LIMIT 1
)
WHERE pp.page_slug != 'website';

Data Isolation Guarantees

Guaranteed Isolation:

  • Each tenant has independent PagePart records
  • Templates can be customized per tenant
  • No cross-tenant visibility in queries
  • Bulk operations stay within tenant boundary

Prevented Issues:

  • Tenant A cannot modify Tenant B's templates
  • Deleting Tenant A's page_parts doesn't affect Tenant B
  • Admin UI shows only current tenant's page_parts

Best Practices

  1. Always use tenant-scoped models in application code:

    # ✅ Good - Uses tenant scope
    PwbTenant::PagePart.where(page_slug: "home")
    
    # ❌ Avoid - Bypasses tenant scope
    Pwb::PagePart.where(website_id: current_website.id, page_slug: "home")
  2. Set context explicitly in background jobs:

    class SomeJob < ApplicationJob
      def perform(website_id)
        website = Website.find(website_id)
        Pwb::Current.website = website
        
        # Now tenant-scoped models work correctly
        PwbTenant::PagePart.all
      end
    end
  3. Use unscoped models only for admin/cross-tenant operations:

    # Super admin viewing all page_parts across all tenants
    Pwb::PagePart.all  # No tenant filtering

Pros and Cons Analysis

✅ Pros

1. Separation of Concerns

  • Templates are versioned in code (YAML files)
  • Content is managed in the database
  • Non-technical users can edit content without touching templates

2. Multi-Language Support

  • Single template serves all locales
  • Content stored per-locale using Globalize gem
  • No template duplication across languages

3. Flexible Content Reuse

  • Join model (PageContent) allows same content on multiple pages
  • Different visibility/sort settings per page
  • Website-level page_parts (footer, header) accessible everywhere

4. Theme Independence

  • Content survives theme changes
  • Themes only control wrappers, not content structure
  • Future-ready for theme-specific template variants

5. Structured Editing

  • editor_setup defines form fields for admin UI
  • Type safety (isSingleLineText, isImage, isHtml)
  • Prevents malformed content

6. Liquid Template Safety

  • Limited template language prevents arbitrary code execution
  • Designers can create templates without Rails knowledge
  • Server-side rendering (unlike client-side template engines)

7. Rails Part Flexibility

  • Can mix static content parts with dynamic Rails components
  • is_rails_part flag allows progressive migration

❌ Cons

1. High Complexity

  • Three database tables for a single page section
  • Difficult for new developers to understand
  • Join model indirection adds mental overhead

2. Performance Concerns

  • Multiple database queries per page render:
    Page  PageParts  PageContents  Contents (with translations)
  • N+1 query risk if not using includes(:content, :translations)
  • Liquid parsing adds overhead (though cacheable)

3. Tight Coupling to Liquid

  • Migrating to another template engine would require refactoring all templates
  • Limited debugging capabilities (Liquid errors are opaque)
  • No modern JavaScript framework integration (React, Vue)

4. Database Schema Redundancy

  • page_part_key duplicated across three tables
  • is_rails_part in both PagePart and PageContent
  • website_id optional in multiple tables creates ambiguity

5. Seeding Complexity

  • Two-step seeding process (structure → content)
  • YAML files spread across two directories:
    • db/yml_seeds/page_parts/ (structure)
    • db/yml_seeds/content_translations/ (content)
  • Easy to get out of sync

6. Limited Validation

  • No schema validation for editor_setup JSON
  • block_contents structure not enforced
  • Broken templates fail at render time, not creation time

7. Poor Developer Experience

  • No type safety or autocomplete
  • Testing requires full Rails stack
  • Editing YAML files less ergonomic than code

8. Underutilized Features

  • theme_name and locale columns on PagePart are unused
  • Join model flexibility (content reuse) rarely needed in practice
  • Over-engineered for common use cases

9. Content Migration Challenges

  • Changing template structure requires migrating block_contents JSON
  • No versioning of templates or content
  • Risky to refactor existing page parts

10. Hidden Dependencies

  • PagePartManager has implicit dependencies on:
    • Globalize gem for translations
    • Liquid gem for parsing
    • Specific JSON structure in editor_setup
  • Hard to mock/test in isolation

Recommendations for Improvement

🎯 High-Priority Improvements

1. Simplify the Data Model

Problem: Three tables (PagePart, PageContent, Content) create unnecessary complexity.

Recommendation: Consider merging PageContent and Content into a single PageSection model:

class PageSection < ApplicationRecord
  belongs_to :page, optional: true
  belongs_to :website, optional: true
  belongs_to :page_part_definition  # Renamed PagePart
  
  # Combines PageContent + Content columns:
  translates :rendered_html
  json :block_data  # Replaces PagePart.block_contents
  integer :sort_order
  boolean :visible
end

Benefits:

  • Fewer joins in queries
  • Clearer ownership model
  • Easier to reason about

Trade-off: Loses theoretical content reuse capability (rarely used in practice).


2. Add Schema Validation

Problem: editor_setup and block_contents JSON fields have no validation.

Recommendation: Use JSON Schema validation:

class PagePart < ApplicationRecord
  EDITOR_SETUP_SCHEMA = {
    type: "object",
    required: ["editorBlocks"],
    properties: {
      editorBlocks: {
        type: "array",
        items: {
          type: "array",
          items: {
            type: "object",
            required: ["label"],
            properties: {
              label: { type: "string" },
              isSingleLineText: { type: "string" },
              isImage: { type: "string" },
              isHtml: { type: "string" }
            }
          }
        }
      }
    }
  }
  
  validates :editor_setup, presence: true, json: { schema: EDITOR_SETUP_SCHEMA }
end

Benefits:

  • Catch configuration errors early
  • Self-documenting schema
  • IDE autocomplete support (with JSON Schema tooling)

3. Introduce Caching

Problem: Rendering page parts on every request is inefficient.

Recommendation: Implement fragment caching:

# app/views/pwb/pages/show.html.erb
<% @page.ordered_visible_page_contents.each do |page_content| %>
  <% cache ["page_part", page_content.id, page_content.content.updated_at, I18n.locale] do %>
    <%= page_part(page_content) %>
  <% end %>
<% end %>

Benefits:

  • Faster page loads
  • Reduced database queries
  • Liquid parsing only happens once

Additional: Pre-render all locales on content save:

class PagePartManager
  after_save :prerender_all_locales
  
  def prerender_all_locales
    I18n.available_locales.each do |locale|
      I18n.with_locale(locale) do
        rebuild_page_content(locale)
      end
    end
  end
end

4. Modernize Template System

Problem: Liquid is limiting for complex UIs; no client-side interactivity.

Recommendation: Hybrid approach with component-based rendering:

# Option A: Keep Liquid for simple content, add Vue/React for complex parts
class PagePart < ApplicationRecord
  enum render_engine: { liquid: 0, vue: 1, react: 2, rails: 3 }
end

# Option B: Use a modern template language like Handlebars
# Option C: Move to a headless CMS approach with JSON API

Example Vue Component Integration:

- page_part_key: property_search
  render_engine: vue
  vue_component: "PropertySearchWidget"
  props_schema:
    max_results: { type: "integer", default: 10 }
    show_map: { type: "boolean", default: true }

5. Improve Developer Experience

Problem: Editing YAML files and testing is cumbersome.

Recommendations:

A) Type-Safe DSL for PagePart Creation:

# lib/pwb/page_part_dsl.rb
class PagePartDSL
  def self.define(page_slug, key, &block)
    builder = new(page_slug, key)
    builder.instance_eval(&block)
    builder.create!
  end
  
  def text_block(label, single_line: false)
    @blocks << { label: label, isSingleLineText: single_line }
  end
  
  def image_block(label)
    @blocks << { label: label, isImage: true }
  end
  
  def template(&block)
    @template = block.call
  end
end

# Usage:
PagePartDSL.define("home", "landing_hero") do
  text_block "landing_title_a", single_line: true
  image_block "landing_img"
  
  template do
    <<~HTML
      <div class="hero">
        <h1>{{ page_part["landing_title_a"]["content"] }}</h1>
        <img src="{{ page_part["landing_img"]["content"] }}" />
      </div>
    HTML
  end
end

B) Rails Generators:

rails generate page_part home landing_hero \
  --text landing_title_a \
  --image landing_img \
  --html landing_content_a
  
# Creates:
# - db/yml_seeds/page_parts/home__landing_hero.yml
# - spec/system/page_parts/landing_hero_spec.rb
# - Adds entry to content_translations YAML

C) Better Testing Utilities:

# spec/support/page_part_helpers.rb
RSpec.shared_context "page_part_setup" do
  def create_page_part(page_slug, key, **options)
    PagePart.create!(
      page_slug: page_slug,
      page_part_key: key,
      template: options[:template] || default_template,
      editor_setup: options[:editor_setup] || default_editor_setup,
      **options
    )
  end
  
  def seed_page_part_content(container, key, locale, **blocks)
    manager = PagePartManager.new(key, container)
    manager.seed_container_block_content(locale, blocks)
  end
end

# Usage:
RSpec.describe "Landing Hero" do
  include_context "page_part_setup"
  
  it "renders title and image" do
    page_part = create_page_part("home", "landing_hero")
    seed_page_part_content(home_page, "landing_hero", "en",
      landing_title_a: "Welcome",
      landing_img: "image.jpg"
    )
    
    expect(home_page.contents.first.raw).to include("Welcome")
  end
end

6. Add Migration Tooling

Problem: Changing templates breaks existing block_contents data.

Recommendation: Create migration helpers:

# lib/pwb/page_part_migrator.rb
class PagePartMigrator
  def self.rename_block(page_part_key, old_label, new_label)
    PagePart.where(page_part_key: page_part_key).find_each do |part|
      part.block_contents.each do |locale, blocks|
        if blocks["blocks"][old_label]
          blocks["blocks"][new_label] = blocks["blocks"].delete(old_label)
        end
      end
      part.save!
    end
  end
  
  def self.add_default_block(page_part_key, label, default_content)
    # Similar logic to add new blocks to existing records
  end
end

# Usage in migration:
class RenameHeroTitleBlock < ActiveRecord::Migration[7.0]
  def up
    PagePartMigrator.rename_block("landing_hero", "title_a", "hero_title")
  end
end

7. Enhance REST API

Problem: Current REST API in PageController is functional but could be more comprehensive and consistent.

Recommendation: Improve the existing REST API with better resource structure and response formats:

# app/controllers/pwb/api/v1/page_parts_controller.rb
module Pwb
  class Api::V1::PagePartsController < ApplicationApiController
    # GET /api/v1/page_parts?page_slug=home
    def index
      container = find_container(params[:page_slug])
      page_parts = container.page_parts.includes(:page_contents)
      
      render json: {
        page_parts: page_parts.map { |pp| serialize_page_part(pp) }
      }
    end
    
    # GET /api/v1/page_parts/:page_part_key?page_slug=home&locale=en
    def show
      container = find_container(params[:page_slug])
      page_part = container.get_page_part(params[:page_part_key])
      
      render json: {
        page_part: serialize_page_part(page_part, params[:locale])
      }
    end
    
    # PATCH /api/v1/page_parts/:page_part_key
    def update
      container = find_container(params[:page_slug])
      manager = PagePartManager.new(params[:page_part_key], container)
      
      result = manager.update_page_part_content(
        params[:locale],
        params.permit(:blocks, :locale, :page_part_key)
      )
      
      render json: {
        success: true,
        block_contents: result[:json_fragment_block],
        rendered_html: result[:fragment_html],
        updated_at: Time.current
      }
    rescue => e
      render json: { success: false, error: e.message }, status: :unprocessable_entity
    end
    
    private
    
    def find_container(page_slug)
      page_slug == "website" ? current_website : current_website.pages.find_by_slug!(page_slug)
    end
    
    def serialize_page_part(page_part, locale = nil)
      {
        page_part_key: page_part.page_part_key,
        page_slug: page_part.page_slug,
        editor_setup: page_part.editor_setup,
        block_contents: page_part.block_contents,
        show_in_editor: page_part.show_in_editor,
        order_in_editor: page_part.order_in_editor,
        is_rails_part: page_part.is_rails_part
      }.tap do |hash|
        if locale
          content = page_part.container.contents.find_by_page_part_key(page_part.page_part_key)
          hash[:rendered_html] = content&.send("raw_#{locale}")
        end
      end
    end
  end
end

Additional REST API Improvements:

A) Add Bulk Operations:

# PATCH /api/v1/page_parts/bulk_update
def bulk_update
  results = params[:updates].map do |update|
    container = find_container(update[:page_slug])
    manager = PagePartManager.new(update[:page_part_key], container)
    manager.update_page_part_content(update[:locale], update)
  end
  
  render json: { success: true, updated_count: results.size }
end

B) Add Content Validation Endpoint:

# POST /api/v1/page_parts/:page_part_key/validate
def validate
  page_part = PagePart.find_by!(page_part_key: params[:page_part_key])
  errors = validate_block_contents(params[:blocks], page_part.editor_setup)
  
  render json: {
    valid: errors.empty?,
    errors: errors
  }
end

C) Improve Error Responses:

# Use consistent error format across all endpoints
def render_error(message, status: :unprocessable_entity)
  render json: {
    success: false,
    error: {
      message: message,
      code: status,
      timestamp: Time.current.iso8601
    }
  }, status: status
end

Benefits:

  • RESTful resource-oriented design
  • Consistent response formats
  • Better error handling
  • Support for bulk operations
  • Easier to test and document
  • Compatible with OpenAPI/Swagger specs

8. Documentation and Tooling

Problem: Current system is poorly documented.

Recommendations:

A) Interactive Admin Guide:

  • Add /admin/page-parts/guide with examples
  • Show available page_parts and their editor configurations
  • Preview templates with sample data

B) Automated Documentation:

# lib/tasks/page_parts.rake
namespace :page_parts do
  desc "Generate documentation for all page parts"
  task docs: :environment do
    PagePart.find_each do |part|
      puts "\n## #{part.page_slug}/#{part.page_part_key}"
      puts "\nTemplate:"
      puts "```html"
      puts part.template
      puts "```"
      puts "\nEditor Blocks:"
      part.editor_setup["editorBlocks"].flatten.each do |block|
        puts "- #{block['label']} (#{block.keys.join(', ')})"
      end
    end
  end
end

C) Visual Template Editor:

  • Build admin UI to edit templates with live preview
  • Syntax highlighting for Liquid
  • Autocomplete for available block labels

9. Performance Monitoring

Problem: No visibility into page_part rendering performance.

Recommendation: Add instrumentation:

# app/services/pwb/page_part_manager.rb
def rebuild_page_content(locale)
  ActiveSupport::Notifications.instrument("page_part.rebuild", 
    page_part_key: page_part_key, 
    locale: locale) do
    # existing logic
  end
end

# config/initializers/page_part_monitoring.rb
ActiveSupport::Notifications.subscribe("page_part.rebuild") do |name, start, finish, id, payload|
  duration = finish - start
  Rails.logger.info "PagePart #{payload[:page_part_key]} (#{payload[:locale]}) rendered in #{duration}ms"
  
  # Send to monitoring service (New Relic, DataDog, etc.)
  if duration > 100
    Rails.logger.warn "Slow page_part render detected!"
  end
end

10. Versioning and Rollback

Problem: No history when content is updated.

Recommendation: Use PaperTrail or custom versioning:

class PagePart < ApplicationRecord
  has_paper_trail on: [:update], only: [:block_contents, :template]
end

class Content < ApplicationRecord
  has_paper_trail on: [:update], only: [:raw, :raw_en, :raw_es]
end

# Admin UI can show version history and restore

🔮 Future Considerations

  1. Headless CMS Migration: Consider using Strapi, Contentful, or Sanity for better editing UX
  2. Component Libraries: Build a library of reusable page_part templates
  3. A/B Testing: Add variant support to test different content versions
  4. Permissions: Add role-based access control for editing specific page_parts
  5. Preview Mode: Allow previewing unpublished changes before going live
  6. CLI Tools: Create command-line tools for bulk operations on page_parts

Conclusion

The PagePart system is a powerful but complex content management solution that successfully separates structure from content. While it provides excellent multi-language support and flexibility, it suffers from over-engineering and poor developer experience.

Key Takeaways:

  • ✅ Great for multi-locale content editing
  • ✅ Theme-independent content storage
  • ✅ Safe templating with Liquid
  • ❌ Too many database tables and joins
  • ❌ Steep learning curve
  • ❌ Performance concerns without caching

Recommended Path Forward:

  1. Short-term: Add caching, validation, and better documentation
  2. Medium-term: Simplify data model, improve developer tooling
  3. Long-term: Consider modern alternatives (headless CMS, component-based systems)

The system works well for its current use case but would benefit significantly from the improvements outlined above.


Best Practices: Semantic HTML & Theme CSS

To maintain a clean separation of concerns and ensure themes are easily customizable, we recommend the following pattern for PageParts:

1. Semantic HTML in Templates

Avoid using inline styles or framework-specific utility classes (like Tailwind or Bootstrap) directly in your Liquid templates. Instead, use semantic class names that describe the content.

Bad:

<div class="bg-gray-900 h-[600px] flex items-center">
  <h1 class="text-4xl font-bold text-white">Title</h1>
</div>

Good:

<div class="hero-section">
  <h1 class="hero-title">Title</h1>
</div>

2. Styling in Theme CSS

Define the visual appearance in the theme's CSS file (e.g., app/views/pwb/custom_css/_bristol.css.erb). This allows you to use theme variables and media queries effectively.

/* app/views/pwb/custom_css/_bristol.css.erb */
.hero-section {
  background-color: #111827;
  height: 600px;
  display: flex;
  align-items: center;
}

.hero-title {
  font-size: 2.25rem;
  color: white;
}

3. Updating PageParts

When you modify a PagePart template in db/yml_seeds/page_parts/, you must run the update rake task to apply changes to the database:

bundle exec rake pwb:db:update_page_parts

This task iterates through the seed files and updates the corresponding PagePart records in the database.


Locale Handling

Overview

The application uses two distinct locale formats for different purposes:

Format Examples Purpose
Full Locale en-UK, en-US, pt-BR, es-MX User-facing: URLs, settings, locale selectors
Base Locale en, pt, es, fr Internal: Content storage, block_contents keys

This distinction is important because:

  • Users may configure region-specific locales (e.g., en-UK vs en-US)
  • Content is stored by language, not region (both en-UK and en-US share the same en content)
  • The block_contents JSON in PageParts uses base locale keys

The LocaleHelper Module

File: app/helpers/locale_helper.rb

The LocaleHelper module provides consistent locale handling across the application:

# Include in controllers or views that need locale conversion
include LocaleHelper

# Convert full locale to base locale for content access
locale_to_base("en-UK")  # => "en"
locale_to_base("pt-BR")  # => "pt"
locale_to_base("es")     # => "es"

# Build locale details for UI display
build_locale_details(["en-UK", "es", "pt-BR"])
# => [
#      { full: "en-UK", base: "en", label: "English (UK)" },
#      { full: "es", base: "es", label: "Spanish" },
#      { full: "pt-BR", base: "pt", label: "Portuguese (Brazil)" }
#    ]

# Get unique base locales for content queries
supported_locales_for_content(["en-UK", "en-US", "es"])
# => ["en", "es"]  (duplicates removed)

Key Methods

Method Input Output Use Case
locale_to_base "en-UK" "en" Access block_contents[locale]
locale_variant "en-UK" "UK" Extract region code
build_locale_details ["en-UK"] [{full:, base:, label:}] Build locale selector UI
supported_locales_for_content ["en-UK", "en-US"] ["en"] Deduplicate for queries
base_to_full_locale "en", ["en-UK"] "en-UK" Reverse lookup
locale_for_url_path "es" "es" Build locale-prefixed URLs

Usage in Controllers

When working with PageParts and locale-specific content, follow this pattern:

class PagePartsController < ApplicationController
  include LocaleHelper

  before_action :set_locale_details

  def edit
    @block_contents = @page_part.block_contents || {}
    # Access content using base locale
    locale_blocks = @block_contents[@current_locale_base]&.dig('blocks') || {}
  end

  def update
    # Store content using base locale
    manager = Pwb::PagePartManager.new(@page_part.page_part_key, @page)
    manager.update_page_part_content(@current_locale_base, fragment_block)

    # Redirect using full locale for URL consistency
    redirect_to edit_path(locale: @current_locale_full)
  end

  private

  def set_locale_details
    supported = current_website.supported_locales.presence || ['en']
    @locale_details = build_locale_details(supported)

    # Full locale from params (for URLs)
    @current_locale_full = params[:locale].presence || supported.first || 'en'

    # Base locale for content access
    @current_locale_base = locale_to_base(@current_locale_full)

    # Current locale detail hash for UI
    @current_locale_detail = @locale_details.find { |d| d[:full] == @current_locale_full } ||
                             @locale_details.find { |d| d[:base] == @current_locale_base } ||
                             @locale_details.first
  end
end

Usage in Views

Locale Tabs:

<% @locale_details.each do |locale_detail| %>
  <% is_current = locale_detail[:full] == @current_locale_full %>
  <%= link_to edit_path(locale: locale_detail[:full]),
              class: is_current ? 'active' : '' do %>
    <%= locale_detail[:label] %>
  <% end %>
<% end %>

Accessing Block Contents:

<%# CORRECT: Use base locale to access content %>
<% locale_blocks = @block_contents[@current_locale_base]&.dig('blocks') || {} %>

<%# WRONG: Don't use full locale - content won't be found %>
<% locale_blocks = @block_contents[@current_locale_full]&.dig('blocks') || {} %>

Building Locale-Prefixed URLs:

<%
  # Include locale prefix for non-English locales
  locale_prefix = @current_locale_base != 'en' ? "/#{@current_locale_base}" : ""
  page_path = @page.slug == 'home' ? '/' : "/p/#{@page.slug}"
  public_page_url = "#{request.base_url}#{locale_prefix}#{page_path}"
%>

Common Pitfalls

1. Using Full Locale for Content Access

Wrong:

# params[:locale] might be "en-UK" but block_contents uses "en"
locale_blocks = @block_contents[params[:locale]]  # nil!

Correct:

base_locale = locale_to_base(params[:locale])
locale_blocks = @block_contents[base_locale]  # Works!

2. Forgetting to Normalize in PagePartManager

Wrong:

# Passing "en-UK" creates a new key instead of updating "en"
manager.update_page_part_content("en-UK", data)

Correct:

# Normalize to base locale first
manager.update_page_part_content(locale_to_base("en-UK"), data)

3. Inconsistent Locale in URLs vs Content

Ensure URLs use full locales for user clarity, while content operations use base locales:

# URL params: ?locale=en-UK (full)
# Content key: block_contents["en"] (base)
# Redirect: redirect_to path(locale: @current_locale_full)

Testing

The LocaleHelper has comprehensive specs in spec/helpers/locale_helper_spec.rb:

bundle exec rspec spec/helpers/locale_helper_spec.rb

Key test cases:

  • Full locale conversion (en-UKen)
  • Base locale passthrough (eses)
  • Nil/blank handling (returns "en")
  • Symbol locale handling
  • Locale detail building with labels
  • Duplicate removal in supported_locales_for_content

Migration Notes

If you're updating existing code that used full locales for content access:

  1. Identify affected controllers/views:

    grep -r "block_contents\[" app/
    grep -r "@current_locale\]" app/views/
  2. Add LocaleHelper:

    include LocaleHelper
  3. Replace direct locale access:

    # Before
    @block_contents[@current_locale]
    
    # After
    @block_contents[locale_to_base(@current_locale)]
  4. Update instance variables:

    • Use @current_locale_full for URLs and params
    • Use @current_locale_base for content access
    • Use @locale_details for building locale selectors