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
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 } | Rule | Detail |
|---|---|
| Loading it | seed 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 key | The full dotted key: stay.hotel, never hotel. A key not under its parent stops seed apply with the vocabulary has 1 problems |
kind | topic (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 |
public | Whether 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 levels | Write 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 select | primaryTag | |
|---|---|---|
| Lives in | config/schema/listing.yaml, changed by a schema change | The vocabulary, changed by staff in the admin |
| Shape | A short flat list you choose | A tree, as fine as “Pubs” |
| Used for | Route facets and landing pages. A landing’s category may be a select, and may not be a tag | What the listing is: its label in the editor, and the group a member report compares a business with |
| Needed | Only if you want it | Always: 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.
Print the structured data
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" | Value | Map 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.
Search page
How an agent adds a search page to a Meridian site with the special search route and search.html, what browse mode returns without a search service, how to connect one, and how to verify the page renders and is accessible.
Partner portal
How an agent sets up the member portal on engine-dmo for partner businesses, declaring membership tiers, the member_editable rules that say what each tier may change, an extra portal screen, an organisation with a membership and an invitation, and how to verify each part.