Meridian handbook
For agentsRecipes

DMO directory

How an agent builds a destination directory on engine-dmo, extending the core listing and area types, loading the tag vocabulary, routing areas beside pages at /{slug}, filtering by place facets, printing structured data, seeding complete listings with hours, address and phone, and verifying it all.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

A directory is the core listing type (a business or place with a title, summary, point and primary tag) extended with your fields, and the area type (a place with a boundary) that listings belong to. A listing filtered by area matches listings related to the area, to any area within it, and listings with no area whose point falls inside its boundary.

Every example here uses the locale code en, the one init writes. Replace en with your project’s source locale in every label, slug, string and seed file. If you moved a UK site to en-GB in Set the basics, that means en-GB here too. Pasted with the wrong code, labels fail check --strict with the label of field priceRange has no translation for en, and landing slugs only warn and are then not used.

Set the country

In config/sites/main/site.yaml, give the site its country:

site:
  id: site_0s14b7ndg9            # keep the id init wrote
  name: { en: "Visit the Wye Valley" }
  domains: [localhost, "localhost:8080"]
  default: true
  country: GB

country is what the admin’s editors start from: the region a phone number typed without + is read in, and the country an address form offers first. seed apply and content create do not read it, so a bare national number in a seed file is refused with Enter the number with its country code, starting with +. Give the region in the value (below), or region: GB on the field.

Extend the types

Write the files by hand, without ids, then let check --fix add them:

# config/schema/listing.yaml
extends: listing
label: { en: Listing }
plural: { en: Places to stay and visit }
fields:
  - name: description
    type: portable_text
    label: { en: Description }
  - name: heroImage
    type: asset
    label: { en: Main photo }
  - name: address
    type: address
    label: { en: Address }
  - name: phone
    type: phone
    label: { en: Phone }
  - name: website
    type: link
    kinds: [{ of: url }]
    label: { en: Website }
  - name: openingHours
    type: opening_hours
    holiday_calendar: uk-england-wales
    label: { en: Opening hours }
  - name: priceRange
    type: text
    max: 8
    label: { en: Price range }
  - name: area
    type: relation
    to: area
    index: true
    label: { en: Area }
  - name: category
    type: select
    index: true
    label: { en: Category }
    options:
      - { value: stay, label: { en: Places to stay } }
      - { value: eat-drink, label: { en: Eat and drink } }
      - { value: see-do, label: { en: Things to do } }
structured_data:
  type: LocalBusiness
  subtype:
    field: category
    values: { stay: LodgingBusiness, eat-drink: Restaurant }
  map:
    description: summary
    image: heroImage
    geo: location
    address: address
    telephone: phone
    sameAs: website
    openingHoursSpecification: openingHours
    priceRange: priceRange
# config/schema/area.yaml
extends: area
label: { en: Place }
plural: { en: Places to go }
fields:
  - name: parent
    type: relation
    to: area
    label: { en: Part of }
# config/images.yaml
transforms:
  card:
    aspect: "3:2"
    widths: { min: 320, max: 1200 }
  hero:
    aspect: "16:9"
    widths: { min: 640, max: 2560 }

The core listing fields (title, slug, summary, location, primaryTag) and area fields (title, slug, summary, boundary, centre) cannot be removed or changed. Give every label a value in every locale the site enables.

The structured_data block is what turns a listing into a LodgingBusiness or Restaurant in the page’s JSON-LD; jsonld() in the layout prints it (Print the structured data). Leave a listing’s priceRange out of the mapping and check warns, and check --strict fails: see Troubleshooting for how to choose.

Load the tag vocabulary

primaryTag is required on every listing, and must name a tag in the vocabulary, so a listing cannot be published without this file:

# config/tags/vocabulary.yaml
tags:
  - key: stay
    kind: topic
    public: true
    label: { en: Places to stay }
    children:
      - key: stay.hotel
        label: { en: Hotels and inns }
      - key: stay.campsite
        label: { en: Campsites }
  - key: eat-drink
    kind: topic
    public: true
    label: { en: Eat and drink }
    children:
      - key: eat-drink.pub
        label: { en: Pubs }
      - key: eat-drink.cafe
        label: { en: Cafes }
RuleDetail
Loading itseed apply loads the file; engine-dmo tags import loads it into a database without seeding. Existing keys are left alone, and from then on staff own the vocabulary in the admin
A child’s keyThe full dotted key: stay.hotel, never hotel. A key not under its parent stops seed apply with the vocabulary has 1 problems
kindtopic (what a place is), audience, operational or internal. It defaults to topic at the top level, and a child takes its parent’s. primaryTag is for topic tags
publicWhether the tag is listed to the public, in the content API’s tag list. It defaults to true at the top level, and a child takes its parent’s. Set public: false on staff working tags
Two levelsWrite two. The admin’s picker for primaryTag offers only topic tags at the second level, so a flat vocabulary leaves editors nothing to choose. seed apply does not hold you to this: it refuses only a key not in the vocabulary (nope is not a vocabulary tag)

primaryTag and the category select look alike and do different jobs:

category selectprimaryTag
Lives inconfig/schema/listing.yaml, changed by a schema changeThe vocabulary, changed by staff in the admin
ShapeA short flat list you chooseA tree, as fine as “Pubs”
Used forRoute facets and landing pages. A landing’s category may be a select, and may not be a tagWhat the listing is: its label in the editor, and the group a member report compares a business with
NeededOnly if you want itAlways: it is a core field

So a pub has category: eat-drink for the visitor’s filter and primaryTag: { id: eat-drink.pub, type: tag } for what it is.

Route listings and areas

init routes page at /{slug}, and an area wants the same shape. Two routes of one shape with one priority are refused: routes "/{slug}" (page@en) and "/{slug}" (area@en) are ambiguous: same shape and priority; give one a priority. Keep the page route and add priority::

# config/sites/main/routes.yaml
routes:
  - type: page
    id: rte_rabyc471nc            # keep the id init wrote
    uri: "/{slug}"
    priority: 10
  - type: listing
    uri: "/places/{slug}"
    index: "/places"
    paginate: 24
    facets: [area, category]
  - type: area
    uri: "/{slug}"
    nest: true
    index: "/areas"

home: { type: page, slug: home }
engine-dmo check --fix
engine-dmo schema plan
engine-dmo schema apply

check --fix writes the fld_, opt_ and rte_ ids. With nest: true and a parent relation, a village sits under its valley (/wye-valley/monmouth). check keeps failing with no template found for type "listing" until the templates below exist; schema plan and schema apply work meanwhile.

priority settles the shape check, and it does not make one type hide the other: a document’s address is stored when it is published and the engine looks the path up by that, so /about reaches the page and /monmouth the area whichever route has the higher number. The price is that one address holds one document. Publishing a page and an area with the same slug is refused, and seed apply stops with duplicate key value violates unique constraint "document_site_uri_path_idx": keep their slugs apart, or route areas under a word of their own (uri: "/towns/{slug}", index: "/towns"), which needs no priority and cannot collide.

templates/_shared/base.html must print {{ jsonld() }} straight after {{ head() }}, as the workflow’s layout does. Without it the mapping above is never printed, and only a warning: line that the summary does not count says so.

Write the templates

{# templates/_shared/area/entry.html #}
{% extends "base.html" %}
{% block body %}
<article>
  <h1>{{ entry.title }}</h1>
  {% if entry.summary %}<p>{{ entry.summary }}</p>{% endif %}
  {{ entry.boundary.centroid.staticMap(width=640, zoom=12) }}
  {% set places = query('listing').where('area', entry).orderBy('title').limit(24).all() %}
  {% if places %}
  <h2>{{ t('area.places') }}</h2>
  <ul>{% for p in places %}<li><a href="{{ p.uri|url }}">{{ p.title }}</a></li>{% endfor %}</ul>
  {% endif %}
</article>
{% endblock %}
{# templates/_shared/area/index.html #}
{% extends "base.html" %}
{% block body %}
<h1>{{ t('areas.title') }}</h1>
<ul>{% for area in items %}<li><a href="{{ area.uri|url }}">{{ area.title }}</a></li>{% endfor %}</ul>
{% endblock %}
{# templates/_shared/listing/index.html #}
{% extends "base.html" %}
{% block body %}
<h1>{% if page.landing %}{{ landing.heading }}{% else %}{{ t('places.title') }}{% endif %}</h1>
{% if facets %}
<nav aria-label="{{ t('facets.label') }}">
  {% for facet in facets %}
  <h2>{{ facet.label }}</h2>
  <ul>
    {% for v in facet.values %}<li><a href="{{ v.url|url }}"{% if v.selected %} aria-current="true"{% endif %}>{{ v.label }} ({{ v.count }})</a></li>{% endfor %}
  </ul>
  {% if facet.selected %}<p><a href="{{ facet.clearUrl|url }}">{{ t('facets.clear') }}</a></p>{% endif %}
  {% endfor %}
</nav>
{% endif %}
<ul class="cards">
  {% for listing in items %}
  <li data-mrd-impression="{{ listing.id }}">
    {% if listing.heroImage %}{{ listing.heroImage.img("card", alt="") }}{% if listing.heroImage.credit %}<small>{{ listing.heroImage.credit }}</small>{% endif %}{% endif %}
    <h2><a href="{{ listing.uri|url }}">{{ listing.title }}</a></h2>
    <p>{{ listing.summary }}</p>
  </li>
  {% endfor %}
</ul>
{{ impressions() }}
{% endblock %}

A complete listing page, with its photograph, address, phone, website and hours. reveal() and track() count the clicks towards the business’s member report, and both go through |url like any variable in an href:

{# templates/_shared/listing/entry.html #}
{% extends "base.html" %}
{% block body %}
<article>
  <h1>{{ entry.title }}</h1>
  {% if entry.heroImage %}{{ entry.heroImage.figure("hero", priority=true) }}{% endif %}
  <p>{{ entry.summary }}</p>
  {% if entry.description %}{{ entry.description|ptext }}{% endif %}

  <h2>{{ t('listing.contact') }}</h2>
  {% if entry.address %}
  <address>{% for line in entry.address.formatted %}{{ line }}{% if not forloop.Last %}<br>{% endif %}{% endfor %}</address>
  {% endif %}
  {% if entry.phone %}<p><a href="{{ reveal(entry, 'phone', entry.phone)|url }}">{{ entry.phone.display }}</a></p>{% endif %}
  {% if entry.website.url %}<p><a href="{{ track(entry, 'website', entry.website.url)|url }}" rel="noopener">{{ t('listing.website') }}</a></p>{% endif %}

  {% set hours = entry.openingHours %}
  {% if hours and not hours.isEmpty %}
  <h2>{{ t('listing.hours') }}</h2>
  <p>{% if hours.isOpenAt(now) %}{{ t('listing.open_now') }}{% else %}{{ t('listing.closed_now') }}{% endif %}</p>
  <table>
    <caption>{{ t('listing.hours_caption') }}</caption>
    <thead><tr><th scope="col">{{ t('listing.day') }}</th><th scope="col">{{ t('listing.times') }}</th></tr></thead>
    <tbody>
      {% for day in hours.week(now) %}
      <tr{% if day.isToday %} aria-current="date"{% endif %}><th scope="row">{{ day.label }}</th><td>{{ day.periodsLabel }}</td></tr>
      {% endfor %}
    </tbody>
  </table>
  {% if hours.note %}<p>{{ hours.note }}</p>{% endif %}
  {% endif %}
</article>
{% endblock %}
# config/strings/en.yaml (add to the file's strings:)
strings:
  a11y.skip: Skip to content
  area.places: Places here
  areas.title: Towns and villages
  places.title: Places to stay and visit
  facets.label: Filter these places
  facets.clear: Clear
  listing.contact: Find and contact
  listing.website: Visit the website
  listing.hours: Opening hours
  listing.hours_caption: Opening hours this week
  listing.day: Day
  listing.times: Times
  listing.open_now: Open now
  listing.closed_now: Closed now

To add a form that asks the business a question, follow Enquiry form; for a “this is my business” link, Claims.

Maps

staticMap() draws a map image on the server from your tile provider and serves it from the site’s own origin, with the provider’s credit, so the visitor’s browser never contacts the provider. Configure the provider; the key is a secret:

# config/engine.yaml
maps:
  tiles: https://tiles.example.org/{z}/{x}/{y}.png?key={key}
  key: ${MAP_TILES_KEY}
  attribution: "© Example Maps © OpenStreetMap contributors"
ValueMap call
A listing’s point{{ listing.location.staticMap(width=480, zoom=15) }}
An address’s point{{ listing.address.geo.staticMap() }}
An area{{ entry.boundary.centroid.staticMap(zoom=12) }}

The map’s alt text is “Map showing where <title> is” unless you pass alt=. Without maps.tiles it prints nothing. An area’s derived centre (when the editor left it empty) prints no map: use the boundary’s centroid.

Seed areas and listings

# seed/documents/area/monmouth.yaml
locales:
  en:
    title: Monmouth
    slug: monmouth
    summary: The market town where the Monnow meets the Wye.
    boundary:
      type: Polygon
      coordinates: [[[-2.75, 51.80], [-2.68, 51.80], [-2.68, 51.84], [-2.75, 51.84], [-2.75, 51.80]]]
publish: [en]

A complete listing. Each file holds every locale’s values under locales:; organisation: is top-level, and names the file below:

# seed/documents/listing/old-bridge-inn.yaml
organisation: old-bridge-inn
locales:
  en:
    title: The Old Bridge Inn
    slug: old-bridge-inn
    summary: A riverside inn with six rooms and a kitchen open every day.
    location: { lat: 51.8112, lon: -2.7163, precision: rooftop }
    primaryTag: { id: stay.hotel, type: tag }
    category: stay
    area: { id: monmouth, type: area }
    heroImage: { asset: old-bridge }
    priceRange: "££"
    address:
      country: GB
      components:
        line1: 4 Wye Bridge Street
        locality: Monmouth
        postal_code: NP25 3DY
      geo: { lat: 51.8112, lon: -2.7163 }
    phone: { raw: "01600 712345", region: GB }
    website: { of: url, url: "https://oldbridgeinn.example" }
    openingHours:
      timezone: Europe/London
      regular:
        - { day: mon, periods: [{ open: "11:00", close: "23:00" }] }
        - { day: tue, periods: [{ open: "11:00", close: "23:00" }] }
        - { day: wed, periods: [{ open: "11:00", close: "23:00" }] }
        - { day: thu, periods: [{ open: "11:00", close: "23:00" }] }
        - { day: fri, periods: [{ open: "11:00", close: "23:30" }] }
        - { day: sat, periods: [{ open: "10:00", close: "23:30" }] }
        - { day: sun, periods: [{ open: "12:00", close: "17:00" }] }
      holiday_calendar: uk-england-wales
      holiday_behaviour: normal
      notes: { en: Food is served until 21:00. }
publish: [en]

The photograph is a file with a YAML file of the same name beside it. Use the person’s own photographs; seed/assets/old-bridge.jpg goes with:

# seed/assets/old-bridge.yaml
title: The Old Bridge Inn from the river
alt: { en: A stone inn beside an arched bridge over a wide river }
credit: Photograph by A. Walker

The organisation is the business behind the listing, with its contact and membership (every key). Skip this file and organisation: above if the brief has no members or claims:

# seed/organisations/old-bridge-inn.yaml
name: The Old Bridge Inn
status: active
email: stay@oldbridgeinn.example
website: https://oldbridgeinn.example
contacts:
  - name: Sam Owner
    email: sam@oldbridgeinn.example
    role: General manager
    partner_role: owner
    is_primary: true

Verify

engine-dmo seed verify
engine-dmo seed reset --confirm && engine-dmo seed apply
engine-dmo check
engine-dmo check --strict
engine-dmo render /monmouth --json
engine-dmo render /places/old-bridge-inn --json
engine-dmo render '/places?category=stay' --json
engine-dmo render /areas --json
engine-dmo render /places/old-bridge-inn | grep -o 'application/ld+json'

Each --json answers 200 with empty errors and a11y; the last prints application/ld+json. The area page lists its listings and, with maps.tiles set, a <figure class="mrd-map">. See Directory for the rest of the template surface and Landing pages for clean place-and-category pages.

One gap to know: the JSON-LD PostalAddress carries the locality, postal code and country of a seeded address but not streetAddress, because components.line1 does not reach it in this version. The printed <address> has every line.