Skip to content

Source Maps

Source maps are the key mechanism that enables visual editing. They trace every rendered HTML element back to its exact origin in the database.

A source map is an immutable value object that answers: “Where did this piece of content come from?”

graph LR
    A[Database Record] -->|Section::resolve| B[BlockData]
    B -->|getSourceMap| C[CmsSourceMap]
    C -->|toJson| D[data-cms-source]
    D -->|parsed by| E[VisualEditor]
    E -->|loads| F[InlineEditForm]
graph TD
    subgraph "Database"
        Page["Page: id=5, slug='about'"]
        Section["Section: id=12, slug='hero'"]
        Content["Content JSON: [{type:'hero', data:{...}}]"]
    end
    
    subgraph "Resolution"
        SR["Section::resolve()"]
        BCol["BlockCollection"]
        BD["BlockData"]
    end
    
    subgraph "Frontend"
        LC["Livewire Component"]
        HTML["HTML element with data-cms-source"]
    end
    
    Page -->|has| Section
    Section -->|stores| Content
    Content -->|resolved by| SR
    SR -->|produces| BCol
    BCol -->|contains| BD
    BD -->|carries| SM["Source Map"]
    BD -->|rendered by| LC
    LC -->|outputs| HTML

JFA\VeFilamentCMSLivewire\CmsSourceMap is a final readonly class with these properties:

PropertyTypeDescription
sectionId?intThe section’s database ID
sectionSlug?stringThe section’s slug
blockType?stringBlock type (e.g., ‘hero’)
blockIndex?intIndex in the content array
pageId?intThe page’s database ID
field?stringSpecific field name (added by trait)
fieldType?stringField type: text, textarea, rich_text, image, repeater
itemIndex?intFor repeater items
parentBlockIndex?intParent block for nested repeaters
// In Section::resolve()
foreach ($content as $index => $block) {
$blockData = new GenericBlockData($block['data']);
$sourceMap = new CmsSourceMap(
sectionId: $this->id,
sectionSlug: $this->slug,
blockType: $block['type'],
blockIndex: $index,
pageId: $pageId,
);
$blockData = $blockData->withSourceMap($sourceMap);
}
class Hero extends SectionComponent
{
use InteractsWithVisualEditing;
public string $label = '';
public string $headline = '';
public array $cmsSourceMap = [];
// autoHydrate() handles $label and $headline
protected function initializeVisualEditing(): void
{
$block = $this->block?->first();
if ($block !== null) {
$this->cmsSourceMap = $block->getSourceMap();
}
}
protected function getCmsSourceMap(): array
{
return $this->cmsSourceMap;
}
// No resolveFieldValue() needed — base class provides default
}

renderField() is provided by the InteractsWithVisualEditing trait. It works automatically:

{{-- In your Blade view: --}}
<h1>{!! $this->renderField('headline', 'text') !!}</h1>
{{-- When visual editing is active, this outputs: --}}
{{-- <span data-cms-source='{"sectionId":12,"field":"headline","fieldType":"text",...}'>Build Amazing Sites</span> --}}
{{-- When visual editing is inactive, it outputs plain text: --}}
{{-- Build Amazing Sites --}}

The method handles source map building, field type injection, and label resolution automatically.

{
"sectionId": 12,
"sectionSlug": "hero",
"blockType": "hero",
"blockIndex": 0,
"pageId": 5,
"field": "headline",
"fieldType": "text",
"fieldLabel": "Headline"
}

This JSON is embedded in HTML:

<span data-cms-source='{"sectionId":12,"sectionSlug":"hero",...}'>
Build Amazing Sites
</span>

When editing mode is active, JavaScript scans for data-cms-source attributes:

// Pseudocode
const editableElements = document.querySelectorAll('[data-cms-source]');
editableElements.forEach(el => {
el.addEventListener('click', () => {
const sourceMap = JSON.parse(el.dataset.cmsSource);
Livewire.dispatch('openEditor', sourceMap);
});
});
// VisualEditor::openEditor()
public function openEditor(array $sourceMap): void
{
$this->activeSourceMap = $sourceMap;
$this->isModalOpen = true;
// Open Filament slide-over modal
$this->dispatch('open-modal', id: 'edit-content');
}
// InlineEditForm::loadFromSourceMap()
public function loadFromSourceMap(): void
{
$map = CmsSourceMap::fromJson(json_encode($this->sourceMap));
$this->section = Section::find($map->sectionId);
$block = $this->section->content[$map->blockIndex];
$this->value = $block['data'][$map->field];
}
// InlineEditForm::save()
public function save(): void
{
$map = CmsSourceMap::fromJson(json_encode($this->sourceMap));
// Update the specific field in the content array
$content = $this->section->getRawContent();
$content[$map->blockIndex]['data'][$map->field] = $this->value;
$this->section->update(['content' => $content]);
// Notify frontend to refresh
$this->dispatch('contentUpdated', sectionId: $map->sectionId);
}
// InteractsWithVisualEditing trait
public function refreshFromCMS(mixed $sectionId): void
{
if (! $this->isVisualEditingActive()) {
return;
}
$sourceMapSectionId = $this->getCmsSourceMap()['section_id'] ?? null;
if ($sourceMapSectionId != $sectionId) {
return;
}
$section = Section::find($sectionId);
$this->block = $section->resolve();
$this->blockData = $this->block?->toArray() ?? [];
$this->autoHydrate();
}

Source maps solve a critical problem: how do we know which database field a rendered HTML element corresponds to?

Without source maps, visual editing would require:

  • Complex DOM traversal heuristics
  • Fragile CSS selector matching
  • Assumptions about HTML structure

With source maps:

  • Every element carries its own provenance metadata
  • No guessing or heuristics needed
  • Works with any HTML structure
  • Supports nested repeaters and complex layouts

CmsSourceMap is immutable. You create scoped variants without mutating the original:

$baseMap = new CmsSourceMap(
sectionId: 12,
sectionSlug: 'hero',
blockType: 'hero',
blockIndex: 0,
pageId: 5,
);
// Scope to a specific field
$fieldMap = $baseMap->forField('headline', 'text');
// Adds: field='headline', fieldType='text'
// Scope to a repeater item
$itemMap = $baseMap->forRepeaterItem(2);
// Adds: itemIndex=2, parentBlockIndex=0

InteractsWithVisualEditing includes normalizeSourceMapKeys() which converts camelCase keys from buildSourceMap() to snake_case for InlineEditForm:

// buildSourceMap() generates:
['sectionId' => 12, 'blockType' => 'hero', 'pageId' => 5]
// normalizeSourceMapKeys() converts to:
['section_id' => 12, 'block_type' => 'hero', 'page_id' => 5]

This happens automatically — no manual normalization needed.

  • Use renderField() instead of raw {{ }} for editable content
  • Initialize $this->cmsSourceMap in initializeVisualEditing()
  • Use wire:key with source map hash on InlineEditForm to prevent schema caching
  • Source maps are lightweight — no performance concern