This is a summary of "Hypermedia Systems" by Carson Gross, Adam Stepinski, Deniz Akşimşek. Summary created by Jeremy Howard with AI help.
- Website for the book
- The revolutionary ideas that empowered the Web
- A simpler approach to building applications on the Web and beyond with htmx and Hyperview
- Enhancing web applications without using SPA frameworks
Hypermedia:
- Non-linear media with branching
- Hypermedia controls enable interactions, often with remote servers
- Example: Hyperlinks in HTML
Brief History:
- 1945: Vannevar Bush's Memex concept
- 1963: Ted Nelson coins "hypertext" and "hypermedia"
- 1968: Douglas Engelbart's "Mother of All Demos"
- 1990: Tim Berners-Lee creates first website
- 2000: Roy Fielding's REST dissertation
HTML as Hypermedia:
- Anchor tags:
<a href="https://hypermedia.systems/">Hypermedia Systems</a>
- Form tags:
<form action="/signup" method="post"> <input type="text" name="email" placeholder="Enter Email To Sign Up"> <button>Sign Up</button> </form>
Non-Hypermedia Approach:
- JSON Data APIs
- Example:
fetch('/api/v1/contacts/1') .then(response => response.json()) .then(data => updateUI(data))
Single Page Applications (SPAs):
- Use JavaScript for UI updates
- Communicate with server via JSON APIs
- Example frameworks: React, Angular, Vue.js
Hypermedia Advantages:
- Simplicity
- Tolerance to content and API changes
- Leverages browser features (e.g., caching)
Hypermedia-Oriented Libraries:
- Enhance HTML as hypermedia
- Example: htmx
<button hx-get="/contacts/1" hx-target="#contact-ui"> Fetch Contact </button>
When to Use Hypermedia:
- Websites with moderate interactivity needs
- Server-side focused applications
- CRUD operations
When Not to Use Hypermedia:
- Highly dynamic UIs (e.g., spreadsheet applications)
- Applications requiring frequent, complex updates
HTML Notes:
- Avoid
<div>
soup - Use semantic HTML elements
- Example of poor practice:
<div class="bg-accent padding-4 rounded-2" onclick="doStuff()"> Do stuff </div>
- Improved version:
<button class="bg-accent padding-4 rounded-2" onclick="doStuff()"> Do stuff </button>
Components of a Hypermedia System:
-
Hypermedia:
- HTML as primary example
- Hypermedia controls: anchors and forms
- URLs:
[scheme]://[userinfo]@[host]:[port][path]?[query]#[fragment]
Example:https://hypermedia.systems/book/contents/
-
Hypermedia Protocols:
- HTTP methods: GET, POST, PUT, PATCH, DELETE
- HTTP response codes with examples: 200 OK: Successful request 301 Moved Permanently: Resource has new permanent URL 404 Not Found: Resource doesn't exist 500 Internal Server Error: Server-side error
- Caching:
Cache-Control
andVary
headers
-
Hypermedia Servers:
- Can be built in any programming language
- Flexibility in choosing backend technology (HOWL stack)
-
Hypermedia Clients:
- Web browsers as primary example
- Importance of uniform interface
REST Constraints:
-
Client-Server Architecture
-
Statelessness:
- Every request contains all necessary information
- Session cookies as a common violation
-
Caching:
- Supported through HTTP headers
-
Uniform Interface: a. Identification of resources (URLs) b. Manipulation through representations c. Self-descriptive messages d. Hypermedia As The Engine of Application State (HATEOAS)
HATEOAS example showing adaptation to state changes:
<!-- Active contact --> <div> <h1>Joe Smith</h1> <div>Status: Active</div> <a href="/contacts/42/archive">Archive</a> </div> <!-- Archived contact --> <div> <h1>Joe Smith</h1> <div>Status: Archived</div> <a href="/contacts/42/unarchive">Unarchive</a> </div>
-
Layered System:
- Allows intermediaries like CDNs
-
Code-On-Demand (optional):
- Allows client-side scripting
Advantages of Hypermedia-Driven Applications:
- Reduced API versioning issues
- Improved system adaptability
- Self-documenting interfaces
Limitations:
- May not be ideal for applications requiring extensive client-side interactivity
HTML Notes:
- Use semantic elements carefully (e.g.,
<section>
,<article>
,<nav>
) - Refer to HTML specification for proper usage
- Sometimes
<div>
is appropriate
Practical implications of REST constraints:
- Statelessness: Improves scalability but can complicate user sessions.
- Caching: Reduces server load and improves response times.
- Uniform Interface: Simplifies client-server interaction but may increase payload size.
Hypermedia vs JSON-based APIs:
- Flexibility: Hypermedia adapts to changes without client updates; JSON APIs often require versioning.
- Discoverability: Hypermedia exposes available actions; JSON requires separate documentation.
- Payload size: Hypermedia typically larger due to including UI elements.
Examples where hypermedia might not be ideal:
- Real-time applications (e.g., chat systems) requiring frequent, small updates.
- Complex data visualization tools needing extensive client-side processing.
- Offline-first mobile apps that require local data manipulation.
To start our journey into Hypermedia-Driven Applications, we consider a simple contact management web application called Contact.app. Contact.app is a web 1.0-style contact management application built with Python, Flask, and Jinja2 templates. It implements CRUD operations for contacts using a RESTful architecture.
Key components:
-
Flask routes:
- GET /contacts: List all contacts or search
- GET /contacts/new: New contact form
- POST /contacts/new: Create new contact
- GET /contacts/: View contact details
- GET /contacts//edit: Edit contact form
- POST /contacts//edit: Update contact
- POST /contacts//delete: Delete contact
-
Jinja2 templates:
- index.html: Contact list and search form
- new.html: New contact form
- show.html: Contact details
- edit.html: Edit contact form
-
Contact model (implementation details omitted)
The application uses the Post/Redirect/Get pattern for form submissions to prevent duplicate submissions on page refresh.
Example route handler for creating a new contact:
@app.route("/contacts/new", methods=['POST'])
def contacts_new():
c = Contact(
None,
request.form['first_name'],
request.form['last_name'],
request.form['phone'],
request.form['email'])
if c.save():
flash("Created New Contact!")
return redirect("/contacts")
else:
return render_template("new.html", contact=c)
This handler creates a new Contact object, attempts to save it, and either redirects to the contact list with a success message or re-renders the form with error messages.
Example template snippet (edit.html):
<form action="/contacts/{{ contact.id }}/edit" method="post">
<fieldset>
<legend>Contact Values</legend>
<p>
<label for="email">Email</label>
<input name="email" id="email" type="text"
placeholder="Email" value="{{ contact.email }}">
<span class="error">{{ contact.errors['email'] }}</span>
</p>
<p>
<label for="first_name">First Name</label>
<input name="first_name" id="first_name" type="text"
placeholder="First Name" value="{{ contact.first }}">
<span class="error">{{ contact.errors['first'] }}</span>
</p>
<!-- Other fields omitted -->
<button>Save</button>
</fieldset>
</form>
<form action="/contacts/{{ contact.id }}/delete" method="post">
<button>Delete Contact</button>
</form>
This template renders a form for editing a contact, including error message display. It also includes a separate form for deleting the contact.
The application demonstrates RESTful principles and HATEOAS through hypermedia exchanges. Each response contains the necessary links and forms for the client to navigate and interact with the application, without requiring prior knowledge of the API structure.
While functional, the application lacks modern interactivity. Every action results in a full page reload, which can feel clunky to users accustomed to more dynamic interfaces. This sets the stage for improvement using htmx in subsequent chapters, which will enhance the user experience while maintaining the hypermedia-driven architecture.
Don't overuse of generic elements like <div>
and <span>
, which can lead to "div soup". Instead, the use of semantic HTML is encouraged for better accessibility, readability, and maintainability. For example, using <article>
, <nav>
, or <section>
tags where appropriate, rather than generic <div>
tags.
By using HTML as the primary representation, the application inherently follows REST principles without the need for complex API versioning or extensive client-side logic to interpret application state.
Htmx extends HTML as a hypermedia, addressing limitations of traditional HTML:
- Any element can make HTTP requests
- Any event can trigger requests
- All HTTP methods are available
- Content can be replaced anywhere on the page
Core htmx attributes:
hx-get
,hx-post
,hx-put
,hx-patch
,hx-delete
: Issue HTTP requestshx-target
: Specify where to place the responsehx-swap
: Define how to swap in new contenthx-trigger
: Specify events that trigger requests
Htmx expects HTML responses, not JSON. This allows for partial content updates without full page reloads.
Passing request parameters:
- Enclosing forms: Values from ancestor form elements are included
hx-include
: Specify inputs to include using CSS selectorshx-vals
: Include static or dynamic values
Example:
<button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML"
hx-trigger="click, keyup[ctrlKey && key == 'l'] from:body">
Get The Contacts
</button>
This button loads contacts when clicked or when Ctrl+L is pressed anywhere on the page.
Browser history support:
hx-push-url
: Create history entries for htmx requests
Example of including values:
<button hx-get="/contacts" hx-vals='{"state":"MT"}'>
Get The Contacts In Montana
</button>
Dynamic values can be included using the js:
prefix:
<button hx-get="/contacts"
hx-vals='js:{"state":getCurrentState()}'>
Get The Contacts In The Selected State
</button>
Htmx enhances HTML's capabilities while maintaining simplicity and declarative nature. It allows for more interactive web applications without abandoning the hypermedia model, bridging the gap between traditional web applications and Single Page Applications.
When using htmx with history support, handle both partial and full-page responses for refreshed pages. This can be done using HTTP headers, a topic covered later.
HTML quality is important. Avoid incorrect HTML and use semantic elements when possible. However, maintaining high-quality markup in large-scale, internationalized websites can be challenging due to the separation of content authors and developers, and differing stylistic conventions between languages.
Installing htmx: Download from unpkg.com and save to static/js/htmx.js, or use the CDN:
<script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
AJAX-ifying the application: Use hx-boost="true"
on the body tag to convert all links and forms to AJAX-powered controls:
<body hx-boost="true">
Boosted links: Convert normal links to AJAX requests, replacing only the body content. Example:
<a href="/contacts">Contacts</a>
This link now uses AJAX, avoiding full page reloads.
Boosted forms: Similar to boosted links, forms use AJAX requests instead of full page loads. No changes needed to HTML.
Attribute inheritance: Place hx-boost
on a parent element to apply to all children:
<div hx-boost="true">
<a href="/contacts">Contacts</a>
<a href="/settings">Settings</a>
</div>
Progressive enhancement: Boosted elements work without JavaScript, falling back to normal behavior. No additional code needed.
Deleting contacts with HTTP DELETE: Use hx-delete
attribute on a button to issue DELETE requests:
<button hx-delete="/contacts/{{ contact.id }}">Delete Contact</button>
Updating server-side code: Modify route to handle DELETE requests:
@app.route("/contacts/<contact_id>", methods=["DELETE"])
def contacts_delete(contact_id=0):
# Delete contact logic here
Response code gotcha: Use 303 redirect to ensure proper GET request after deletion:
return redirect("/contacts", 303)
Targeting the right element: Use hx-target
to specify where to place the response:
<button hx-delete="/contacts/{{ contact.id }}" hx-target="body">Delete Contact</button>
Updating location bar URL: Add hx-push-url="true"
to update browser history:
<button hx-delete="/contacts/{{ contact.id }}" hx-target="body" hx-push-url="true">Delete Contact</button>
Confirmation dialog: Use hx-confirm
for delete operations:
<button hx-delete="/contacts/{{ contact.id }}" hx-confirm="Are you sure?">Delete Contact</button>
Validating contact emails: Implement inline validation using htmx:
<input name="email" type="email"
hx-get="/contacts/{{ contact.id }}/email"
hx-target="next .error">
<span class="error"></span>
Updating input type: Change input type to "email" for basic browser validation:
<input name="email" type="email">
Inline validation: Use hx-get
to trigger server-side validation on input change (see example above).
Validating emails server-side: Create endpoint to check email uniqueness:
@app.route("/contacts/<contact_id>/email", methods=["GET"])
def contacts_email_get(contact_id=0):
# Email validation logic here
Improving user experience: Validate email as user types using keyup
event:
<input name="email" type="email"
hx-get="/contacts/{{ contact.id }}/email"
hx-target="next .error"
hx-trigger="change, keyup">
Debouncing validation requests: Add delay to avoid excessive requests during typing:
<input name="email" type="email"
hx-get="/contacts/{{ contact.id }}/email"
hx-target="next .error"
hx-trigger="change, keyup delay:200ms">
Ignoring non-mutating keys: Use changed
modifier to only trigger on value changes:
<input name="email" type="email"
hx-get="/contacts/{{ contact.id }}/email"
hx-target="next .error"
hx-trigger="change, keyup delay:200ms changed">
Paging: Implement basic paging for contact list:
<a href="/contacts?page={{ page + 1 }}">Next</a>
Click to load: Create a button to load more contacts inline:
<button hx-get="/contacts?page={{ page + 1 }}"
hx-target="closest tr"
hx-swap="outerHTML"
hx-select="tbody > tr">
Load More
</button>
Infinite scroll: Use hx-trigger="revealed"
to load more contacts automatically:
<span hx-get="/contacts?page={{ page + 1 }}"
hx-trigger="revealed"
hx-target="closest tr"
hx-swap="outerHTML"
hx-select="tbody > tr">
Loading More...
</span>
HTML notes on modals: Use modals cautiously, as they can complicate hypermedia approach. Consider inline editing or separate pages instead.
Caution with "display: none": Be aware of accessibility implications when hiding elements. It removes elements from the accessibility tree and keyboard focus.
Visually hidden class: Use a utility class to hide elements visually while maintaining accessibility:
.vh {
clip: rect(0 0 0 0);
clip-path: inset(50%);
block-size: 1px;
inline-size: 1px;
overflow: hidden;
white-space: nowrap;
}
Active Search:
- Implement search-as-you-type using
hx-get
,hx-trigger
, andhx-target
. - Use
hx-trigger="search, keyup delay:200ms changed"
for debouncing. - Target specific elements with
hx-target="tbody"
. - Use HTTP headers like
HX-Trigger
for server-side logic. - Factor templates for reusability (e.g.,
rows.html
). - Update URL with
hx-push-url="true"
. - Add request indicators using
hx-indicator
attribute.
Example:
<input id="search" type="search" name="q"
hx-get="/contacts"
hx-trigger="search, keyup delay:200ms changed"
hx-target="tbody"
hx-push-url="true"
hx-indicator="#spinner">
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg">
Lazy Loading:
- Defer expensive operations using
hx-get
andhx-trigger="load"
. - Example: Lazy loading contact count.
<span hx-get="/contacts/count" hx-trigger="load">
<img class="htmx-indicator" src="/static/img/spinning-circles.svg"/>
</span>
- Use internal indicators for one-time requests.
- Implement truly lazy loading with
hx-trigger="revealed"
.
Inline Delete:
- Add delete functionality to contact rows.
- Use
hx-delete
,hx-confirm
, andhx-target
attributes. - Differentiate between delete button and inline delete using
HX-Trigger
header. - Implement fade-out effect using CSS transitions and
htmx-swapping
class. - Adjust swap timing with
hx-swap="outerHTML swap:1s"
.
Example:
<a href="#" hx-delete="/contacts/{{ contact.id }}"
hx-swap="outerHTML swap:1s"
hx-confirm="Are you sure you want to delete this contact?"
hx-target="closest tr">Delete</a>
CSS for fade-out:
tr.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-out;
}
Bulk Delete:
- Add checkboxes to rows for selection.
- Create a "Delete Selected Contacts" button with
hx-delete="/contacts"
. - Enclose table in a form to include selected contact IDs.
- Update server-side code to handle multiple deletions.
Example:
<form>
<table>
<!-- Table content -->
</table>
<button
hx-delete="/contacts"
hx-confirm="Are you sure you want to delete these contacts?"
hx-target="body">
Delete Selected Contacts
</button>
</form>
Server-side code:
@app.route("/contacts/", methods=["DELETE"])
def contacts_delete_all():
contact_ids = [int(id) for id in request.form.getlist("selected_contact_ids")]
for contact_id in contact_ids:
contact = Contact.find(contact_id)
contact.delete()
flash("Deleted Contacts!")
contacts_set = Contact.all()
return render_template("index.html", contacts=contacts_set)
(This chapter is not included)
Htmx Attributes:
hx-swap
: Controls content swapping. Options includeinnerHTML
,outerHTML
,beforebegin
,afterend
.- Modifiers:
settle
,show
,scroll
,focus-scroll
. Example:
<button hx-get="/contacts" hx-target="#content-div"
hx-swap="innerHTML show:body:top">
Get Contacts
</button>
hx-trigger
: Specifies events triggering requests. Modifiers:delay
,changed
,once
,throttle
,from
,target
,consume
,queue
.- Trigger filters: Use JavaScript expressions in square brackets. Example:
<button hx-get="/contacts"
hx-trigger="click[contactRetrievalEnabled()]">
Get Contacts
</button>
- Synthetic events:
load
,revealed
,intersect
.
Other Attributes:
hx-push-url
: Updates navigation bar URL.hx-preserve
: Keeps original content between requests.hx-sync
: Synchronizes requests between elements.hx-disable
: Disables htmx behavior.
Events:
- Htmx-generated events:
htmx:load
,htmx:configRequest
,htmx:afterRequest
,htmx:abort
. - Using
htmx:configRequest
:
document.body.addEventListener("htmx:configRequest", configEvent => {
configEvent.detail.headers['X-SPECIAL-TOKEN'] = localStorage['special-token'];
})
- Canceling requests with
htmx:abort
:
<button id="contacts-btn" hx-get="/contacts" hx-target="body">
Get Contacts
</button>
<button onclick="document.getElementById('contacts-btn')
.dispatchEvent(new Event('htmx:abort'))">
Cancel
</button>
- Server-generated events using
HX-Trigger
header.
HTTP Requests & Responses:
- Headers:
HX-Location
,HX-Push-Url
,HX-Refresh
,HX-Retarget
. - Special response codes: 204 (No Content), 286 (Stop Polling).
- Customizing response handling:
document.body.addEventListener('htmx:beforeSwap', evt => {
if (evt.detail.xhr.status === 404) {
showNotFoundError();
}
});
Updating Other Content:
- Expanding selection
- Out of Band Swaps using
hx-swap-oob
Example:
<table id="contacts-table" hx-swap-oob="true">
<!-- Updated table content -->
</table>
- Events (server-triggered)
Debugging:
htmx.logAll()
: Logs all internal htmx events.- Chrome's
monitorEvents()
: Monitors all events on an element.
monitorEvents(document.getElementById("some-element"));
Security Considerations:
- Use
hx-disable
to ignore htmx attributes in user-generated content. - Content Security Policies (CSP): Htmx works with
eval()
disabled, except for event filters.
Configuring:
- Use
meta
tag for configuration:
<meta name="htmx-config" content='{"defaultSwapStyle":"outerHTML"}'>
HTML Notes: Semantic HTML:
- Focus on writing conformant HTML rather than guessing at "semantic" meanings.
- Use elements as outlined in the HTML specification.
- Consider the needs of browsers, assistive technologies, and search engines.
Scripting in Hypermedia-Driven Applications:
-
Is Scripting Allowed?
- Scripting enhances HTML websites and creates full-fledged client-side applications.
- Goal: Less code, more readable and hypermedia-friendly code.
-
Scripting for Hypermedia:
- Constraints: a. Main data format must be hypermedia. b. Minimize client-side state outside the DOM.
- Focus on interaction design, not business or presentation logic.
-
Scripting Tools:
- VanillaJS: Plain JavaScript without frameworks.
- Alpine.js: JavaScript library for adding behavior directly in HTML.
- _hyperscript: Non-JavaScript scripting language created alongside htmx.
-
Vanilla JavaScript:
- Simple counter example:
<section class="counter"> <output id="my-output">0</output> <button onclick="document.querySelector('#my-output').textContent++"> Increment </button> </section>
- RSJS (Reasonable System for JavaScript Structure):
<section class="counter" data-counter> <output data-counter-output>0</output> <button data-counter-increment>Increment</button> </section>
document.querySelectorAll("[data-counter]").forEach(el => { const output = el.querySelector("[data-counter-output]"), increment = el.querySelector("[data-counter-increment]"); increment.addEventListener("click", e => output.textContent++); });
- Simple counter example:
-
Alpine.js:
- Counter example:
<div x-data="{ count: 0 }"> <output x-text="count"></output> <button x-on:click="count++">Increment</button> </div>
- Bulk action toolbar:
<form x-data="{ selected: [] }"> <template x-if="selected.length > 0"> <div class="tool-bar"> <span x-text="selected.length"></span> contacts selected <button @click="selected = []">Cancel</button> <button @click="confirm(`Delete ${selected.length} contacts?`) && htmx.ajax('DELETE', '/contacts', { source: $root, target: document.body })"> Delete </button> </div> </template> </form>
- Counter example:
-
_hyperscript:
- Counter example:
<div class="counter"> <output>0</output> <button _="on click increment the textContent of the previous <output/>"> Increment </button> </div>
- Keyboard shortcut:
<input id="search" name="q" type="search" placeholder="Search Contacts" _="on keydown[altKey and code is 'KeyS'] from the window focus() me">
- Counter example:
-
Off-the-Shelf Components:
- Integration options: a. Callbacks:
<button @click="Swal.fire({
title: 'Delete these contacts?',
showCancelButton: true,
confirmButtonText: 'Delete'
}).then((result) => {
if (result.isConfirmed) htmx.ajax('DELETE', '/contacts',
{ source: $root, target: document.body })
});">Delete</button>
b. Events:
function sweetConfirm(elt, config) {
Swal.fire(config)
.then((result) => {
if (result.isConfirmed) {
elt.dispatchEvent(new Event('confirmed'));
}
});
}
<button hx-delete="/contacts" hx-target="body" hx-trigger="confirmed"
@click="sweetConfirm($el, {
title: 'Delete these contacts?',
showCancelButton: true,
confirmButtonText: 'Delete'
})">Delete</button>
-
Pragmatic Scripting:
- Choose the right tool for the job.
- Avoid JSON data APIs for server communication.
- Minimize state outside the DOM.
- Favor events over hard-coded callbacks.
-
HTML Notes:
- HTML is suitable for both documents and applications.
- Hypermedia allows for interactive controls within documents.
- HTML's interactive capabilities need further development.
Hypermedia APIs & JSON Data APIs:
-
Differences:
- Hypermedia API: Flexible, no versioning needed, specific to application needs.
- JSON Data API: Stable, versioned, rate-limited, general-purpose.
-
Adding JSON Data API to Contact.app:
- Root URL:
/api/v1/
- Listing contacts:
@app.route("/api/v1/contacts", methods=["GET"]) def json_contacts(): contacts_set = Contact.all() contacts_dicts = [c.__dict__ for c in contacts_set] return {"contacts": contacts_dicts}
- Root URL:
-
Adding contacts:
@app.route("/api/v1/contacts", methods=["POST"]) def json_contacts_new(): c = Contact(None, request.form.get('first_name'), request.form.get('last_name'), request.form.get('phone'), request.form.get('email')) if c.save(): return c.__dict__ else: return {"errors": c.errors}, 400
-
Viewing contact details:
@app.route("/api/v1/contacts/<contact_id>", methods=["GET"]) def json_contacts_view(contact_id=0): contact = Contact.find(contact_id) return contact.__dict__
-
Updating contacts:
@app.route("/api/v1/contacts/<contact_id>", methods=["PUT"]) def json_contacts_edit(contact_id): c = Contact.find(contact_id) c.update( request.form['first_name'], request.form['last_name'], request.form['phone'], request.form['email']) if c.save(): return c.__dict__ else: return {"errors": c.errors}, 400
-
Deleting contacts:
@app.route("/api/v1/contacts/<contact_id>", methods=["DELETE"]) def json_contacts_delete(contact_id=0): contact = Contact.find(contact_id) contact.delete() return jsonify({"success": True})
-
Additional considerations:
- Rate limiting
- Authentication (token-based for JSON API, session cookies for web app)
- Pagination
- Proper error handling (e.g., 404 responses)
-
API "Shape" differences:
- JSON API: Fewer endpoints, no need for UI-specific routes
- Hypermedia API: More flexible, can change without breaking clients
-
Model View Controller (MVC) Paradigm:
- Model: Domain logic
- View: HTML representation
- Controller: Thin layer connecting HTTP requests to model
-
HTML Notes - Microformats:
- Embed machine-readable data in HTML using classes
- Example:
<a class="h-card" href="https://john.example"> <img src="john.jpg" alt=""> John Doe </a>
- Parsed result:
{ "type": ["h-card"], "properties": { "name": ["John Doe"], "photo": ["john.jpg"], "url": ["https://john.example"] } }
- Useful for cross-website systems like IndieWeb