Layout System Developer Guide#
The spores.garden layout system allows you to create custom renderers for displaying AT Protocol records. This guide explains how to create, register, and use layouts.
Overview#
The layout system is built on three key concepts:
- Field Extraction: The
extractFields()function intelligently extracts common fields (title, content, image, date, etc.) from any AT Protocol record, regardless of lexicon type. - Layout Functions: Layout functions take extracted fields and return HTML elements for rendering.
- Registration: Layouts are registered with a unique name and can be referenced in section configurations.
Architecture#
AT Protocol Record
↓
extractFields() → Extracted Fields Object
↓
Layout Function → HTMLElement
↓
Rendered on Page
Field Extractor#
The field extractor (src/records/field-extractor.ts) provides a unified interface for extracting common fields from records. It works in two modes:
- Schema-based extraction: For known lexicons, uses precise field mappings for optimal accuracy.
- Heuristic extraction: For unknown lexicons, intelligently guesses field names based on common patterns.
The extractor returns an object with standardized field names:
title- Record title/namecontent- Main text contentimage- Primary image URLimages- Array of image URLs (for galleries)url- Link URLdate- Date objectauthor- Author informationtags- Array of tagsitems- Array of items (for lists/collections)$type- Original lexicon type$raw- Access to raw record value
Creating a New Layout#
Step 1: Create the Layout File#
Create a new TypeScript file in src/layouts/ (e.g., src/layouts/my-layout.ts):
import { extractFields } from '../records/field-extractor';
/**
* My Custom Layout
*
* Brief description of what this layout does.
*
* @param fields - Extracted fields from the record
* @param record - Optional original record reference
*/
export function renderMyLayout(
fields: ReturnType<typeof extractFields>,
record?: any
): HTMLElement {
// Create the root element
const html = document.createElement('article');
html.className = 'layout-my-layout';
// Access extracted fields
const title = fields.title || 'Untitled';
const content = fields.content || '';
const image = fields.image;
const date = fields.date;
// Build the HTML structure
if (image) {
const img = document.createElement('img');
img.src = image;
img.alt = title;
img.loading = 'lazy';
html.appendChild(img);
}
if (title) {
const titleEl = document.createElement('h2');
titleEl.textContent = title;
html.appendChild(titleEl);
}
if (content) {
const contentEl = document.createElement('div');
contentEl.textContent = content;
html.appendChild(contentEl);
}
if (date) {
const dateEl = document.createElement('time');
dateEl.dateTime = date.toISOString();
dateEl.textContent = date.toLocaleDateString();
html.appendChild(dateEl);
}
// Return the element
return html;
}
Step 2: Register the Layout#
Add your layout to src/layouts/index.ts:
import { renderMyLayout } from './my-layout';
// Register the layout
registerLayout('my-layout', renderMyLayout);
Step 3: Use the Layout#
Users can now select your layout when creating sections in the UI, or it can be referenced in code:
import { renderRecord } from '../layouts/index';
const element = await renderRecord(record, 'my-layout');
Layout Function Signature#
All layout functions must follow this signature:
function renderLayoutName(
fields: ReturnType<typeof extractFields>,
record?: any
): HTMLElement
Parameters:
fields- Object containing extracted/common fields from the recordrecord- Optional original AT Protocol record (useful for accessing raw data)
Returns:
HTMLElement- The DOM element to render
Note: Layout functions can be synchronous or asynchronous. If returning a Promise<HTMLElement>, the layout system will await it.
Field Extractor Usage#
Basic Usage#
The field extractor automatically extracts common fields:
import { extractFields } from '../records/field-extractor';
export function renderMyLayout(fields: ReturnType<typeof extractFields>): HTMLElement {
// Standard fields are available
const title = fields.title;
const content = fields.content;
const image = fields.image;
const date = fields.date;
// ... use fields to build HTML
}
Accessing Raw Record Data#
For advanced use cases, you may need access to the original record:
export function renderMyLayout(
fields: ReturnType<typeof extractFields>,
record?: any
): HTMLElement {
// Use extracted fields for common data
const title = fields.title;
// Access raw record for lexicon-specific fields
const customField = record?.value?.myCustomField;
const rawData = fields.$raw; // Also available in fields.$raw
// ... build HTML
}
Adding Lexicon Schemas#
For better field extraction accuracy, you can add schema mappings for specific lexicons. Edit src/records/field-extractor.ts:
const LEXICON_SCHEMAS: Record<string, LexiconSchema> = {
// ... existing schemas ...
'your.lexicon.type': {
// Direct field name
title: 'exactFieldName',
// Multiple field names to try (in order)
content: ['content', 'body', 'text'],
// Custom extractor function
image: (record) => record.value.embeds?.[0]?.image?.url,
// Array of images
images: (record) => record.value.media?.map(m => m.url),
// Metadata
confidence: 'high' as const,
preferredLayout: 'my-layout'
}
};
Field Mapping Types:
string- Exact field name (e.g.,'title')string[]- Array of field names to try in order (e.g.,['title', 'name'])function- Custom extractor function:(record) => any
See src/records/field-extractor.ts for complete documentation and examples.
Examples#
Simple Card Layout#
import { extractFields } from '../records/field-extractor';
export function renderCard(fields: ReturnType<typeof extractFields>): HTMLElement {
const card = document.createElement('article');
card.className = 'layout-card';
if (fields.image) {
const img = document.createElement('img');
img.src = fields.image;
img.alt = fields.title || '';
img.loading = 'lazy';
card.appendChild(img);
}
if (fields.title) {
const title = document.createElement('h3');
title.textContent = fields.title;
card.appendChild(title);
}
if (fields.content) {
const content = document.createElement('p');
content.textContent = fields.content.slice(0, 200); // Truncate
card.appendChild(content);
}
return card;
}
Image Gallery Layout#
import { extractFields } from '../records/field-extractor';
export function renderGallery(fields: ReturnType<typeof extractFields>): HTMLElement {
const gallery = document.createElement('div');
gallery.className = 'layout-gallery';
const images = fields.images || (fields.image ? [fields.image] : []);
images.forEach((src, index) => {
const imgContainer = document.createElement('div');
imgContainer.className = 'gallery-item';
const img = document.createElement('img');
img.src = src;
img.alt = fields.title ? `${fields.title} - Image ${index + 1}` : `Image ${index + 1}`;
img.loading = 'lazy';
imgContainer.appendChild(img);
gallery.appendChild(imgContainer);
});
return gallery;
}
Async Layout with Data Fetching#
import { extractFields } from '../records/field-extractor';
import { getProfile } from '../at-client';
export async function renderProfile(fields: ReturnType<typeof extractFields>): Promise<HTMLElement> {
const profile = document.createElement('div');
profile.className = 'layout-profile';
// Fetch additional data asynchronously
let avatar = fields.image;
if (fields.author?.did) {
try {
const authorProfile = await getProfile(fields.author.did);
avatar = authorProfile.avatar || avatar;
} catch (error) {
console.warn('Failed to fetch profile:', error);
}
}
if (avatar) {
const img = document.createElement('img');
img.src = avatar;
img.className = 'profile-avatar';
profile.appendChild(img);
}
if (fields.title) {
const name = document.createElement('h2');
name.textContent = fields.title;
profile.appendChild(name);
}
return profile;
}
Best Practices#
Accessibility#
Always include proper ARIA attributes and semantic HTML:
const article = document.createElement('article');
article.setAttribute('aria-label', fields.title || 'Content');
article.className = 'layout-my-layout';
// Use semantic elements
const title = document.createElement('h2'); // Not div
const time = document.createElement('time');
time.dateTime = date.toISOString();
Error Handling#
Handle missing or invalid data gracefully:
if (fields.image) {
const img = document.createElement('img');
img.src = fields.image;
img.alt = fields.title || 'Image';
// Handle image load errors
img.addEventListener('error', () => {
img.style.display = 'none';
const errorMsg = document.createElement('p');
errorMsg.textContent = 'Failed to load image';
errorMsg.setAttribute('role', 'alert');
article.appendChild(errorMsg);
});
article.appendChild(img);
}
Performance#
- Use
loading="lazy"for images - Keep layouts lightweight and fast
- Avoid heavy computation in render functions
- Consider async rendering for data fetching
Styling#
- Use consistent class naming:
layout-{layout-name} - Follow existing CSS patterns in
src/themes/base.css - Keep styles scoped to avoid conflicts
Available Built-in Layouts#
The following layouts are available by default:
card- Compact card display for short contentpost- Full article display for long-form contentimage- Visual-first display for images (supports galleries with modal)link- Single link with previewlinks- Link tree style listlist- Generic list of itemsprofile- Profile/about section displayraw- Custom HTML content (sanitized)flower-bed- Displays planted flowerscollected-flowers- Displays collected flowerssmoke-signal- Displays Smoke Signal eventsleaflet- Displays Standard.site articles
Testing Your Layout#
- Create the layout file and register it
- Add a test section in the UI with your layout
- Load a record that matches your target lexicon
- Verify the layout renders correctly
- Test with various record types to ensure robustness
Troubleshooting#
Fields Not Extracted#
If expected fields are missing:
- Check the record structure in the browser console
- Add a lexicon schema to
field-extractor.tsif needed - Access raw data via
fields.$raworrecord.value
Layout Not Appearing#
- Ensure the layout is registered in
src/layouts/index.ts - Check for TypeScript compilation errors
- Verify the layout name matches exactly (case-sensitive)
- Clear browser cache and reload
Styling Issues#
- Check that your CSS classes are scoped properly
- Review
src/themes/base.cssfor existing patterns - Ensure no conflicting class names
Accessing Load Records in the Editor#
The Load Records option in the Add Section modal is the primary UI entry point for displaying custom AT Protocol record types on your spores.garden site.
Experimental feature: Load Records is intended for developers. Rendering quality varies across lexicons — some collections may not display as expected without a dedicated layout.
How it works#
- In the site editor, click Add Section and choose Load Records
- The editor lists AT Protocol collections available on your PDS
- Select a collection; spores.garden will fetch records and render them using the best matching layout
If no layout is registered for the collection's lexicon type, records fall back to heuristic field extraction and a generic layout.
Adding layout support for a new lexicon#
To improve rendering for a specific collection:
- Follow the steps in Creating a New Layout to build a layout function
- Register it in
src/layouts/index.ts(see that file for examples) - Optionally add a lexicon schema to
src/records/field-extractor.tsfor precise field extraction
Once registered, spores.garden will automatically use your layout for matching record types.
Further Reading#
src/layouts/index.ts- Layout registration and examplessrc/records/field-extractor.ts- Field extraction implementationsrc/components/section-block.ts- How layouts are used in sectionssrc/themes/base.css- Available CSS classes and patterns