Some payloads are too big or too nested to live in an attribute — a gallery's list
of rendered image items, a map's markers plus their info-window HTML. For those a
Module emits a Data island: a <script type="application/json"> child element
whose text content is the JSON. The host reads it once, parses it, and builds DOM
from it.
The PHP side
Emit the island as a direct child of the module element and json_encode a
collection into it. Here LocationMap writes its markers and info windows:
<section class="module location-map animate ...">
<script type="application/json"><?= json_encode(['markers' => $markers, 'infoWindows' => $info_windows]); ?></script>
<div class="location-map__markers"></div>
<div class="location-map__info-windows"></div>
The payload is a structured object of two collections — exactly what an attribute
can't hold cleanly. Gallery does the same with a list, pre-rendering each item's
rich-media markup into the island so the JS only has to materialize strings into
nodes:
<script type="application/json">
<?php
// ...builds $media, each entry { element, category, key, id }
echo json_encode($media);
?>
</script>
The JS side
The host reads its own island with a :scope > script selector and
JSON.parses the text content. LocationMap scopes to the direct child so it can't
accidentally read an island belonging to a nested module:
const island = this.element.querySelector(':scope > script[type="application/json"]')
if ( ! island ) {
return
}
const { markers = [], infoWindows = [] } = JSON.parse(island.textContent)
MasonryGallery uses the same scoped read, decoding the list straight into wrapped
DOM nodes:
const parsed = JSON.parse(this.element.querySelector(':scope > script[type="application/json"]').textContent)
Always scope the read with :scope > script. A Module can contain nested Modules,
each with its own island — an unscoped querySelector('script') would grab the
first one in the subtree, not necessarily yours.
Once parsed, the collection becomes DOM. LocationMap turns each marker into an
element and hands it back through the data-* bridge — the island seeds the markup,
each generated node then carries its own scalar data-map-marker options:
markers.forEach(data => {
const el = document.createElement('div')
el.className = 'map-marker'
el.dataset.mapMarker = JSON.stringify(data)
markerContainer.appendChild(el)
})
Why a script tag and not a giant attribute. type="application/json" is inert
— the browser never executes or renders it, and it imposes no length or escaping
limits the way an attribute value does. It's the right home for a payload whose
shape is a collection or nested structure rather than flat scalar config.
The dividing line
| Bridge | Carries | Read by |
|---|---|---|
data-* option |
scalar config, flags, library options | parseOptions → this.options |
| Data island | a collection or structured payload | :scope > script → JSON.parse |
Scalar config → attribute. A collection or deeply nested payload → island. When in doubt, ask whether you'd be comfortable reading the value inline in the HTML; if it's a list, it's an island.
Related
data-*options — the sibling bridge for scalar config.- Fields → CSS — how editor style settings cross to SCSS.
- The lifecycle — where island hydration slots into
boot*/init*.