Meridian handbook
Destination platform (engine-dmo)

Directory

The listing and area types engine-dmo adds, how to extend them, route and facet them, link a listing to the business behind it, and show places, maps, nearby results and enquiries in templates.

engine-dmo

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

The directory module gives a project two core types: a listing is a place a visitor looks for (a pub, a museum, a walk’s start, a hotel) and an area is a place that holds them (a town, a valley, a stretch of coast). They behave like the core page and article types: you extend them with your own fields, route them, give them templates and list them on index pages. What the module adds on top is the link from a listing to the business behind it, the place facets and landing pages that cross a place with a category, and the figures that flow back to that business as its member report.

A minimal directory

Extend the listing with the fields your sites need, extend the area, load the tag vocabulary, route both, and give each a template. The examples write the locale code en-GB: use your site’s own codes in every label, or labels fail check --strict with the label of field category has no translation for en.

config/schema/listing.yaml
extends: listing
label: { en-GB: Listing }
plural: { en-GB: Listings }
fields:
  - name: description
    type: portable_text
    label: { en-GB: Description }
  - name: heroImage
    type: asset
    label: { en-GB: Main photo }
  - name: address
    type: address
    index: true
    label: { en-GB: Address }
  - name: phone
    type: phone
    index: true
    label: { en-GB: Phone }
  - name: website
    type: link
    kinds: [{ of: url }]
    label: { en-GB: Website }
  - name: openingHours
    type: opening_hours
    holiday_calendar: uk-england-wales
    label: { en-GB: Opening hours }
  - name: area
    type: relation
    to: area
    index: true
    label: { en-GB: Area }
  - name: category
    type: select
    index: true
    label: { en-GB: Category }
    options:
      - { value: stay, label: { en-GB: Places to stay } }
      - { value: eat-drink, label: { en-GB: Eat and drink } }
config/schema/area.yaml
extends: area
label: { en-GB: Place }
plural: { en-GB: Places to go }
fields:
  - name: parent
    type: relation
    to: area
    label: { en-GB: Part of }
config/tags/vocabulary.yaml
tags:
  - key: stay
    kind: topic
    public: true
    label: { en-GB: Places to stay }
    children:
      - key: stay.hotel
        label: { en-GB: Hotels and inns }
  - key: eat-drink
    kind: topic
    public: true
    label: { en-GB: Eat and drink }
    children:
      - key: eat-drink.pub
        label: { en-GB: Pubs }
config/sites/main/routes.yaml
routes:
  - type: page
    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 }

Without the priority:, check refuses the two /{slug} routes: routes "/{slug}" (page@en-GB) and "/{slug}" (area@en-GB) are ambiguous: same shape and priority; give one a priority. The number settles that and hides nothing: a document’s address is stored when it is published and looked up by that, so /about reaches the page and /monmouth the area whichever route has the higher number. One address holds one document, so a page and an area with the same slug cannot both be published. To rule that out, route areas under a word of their own (/towns/{slug}, with an index at /towns), which needs no priority.

Then write templates/_shared/listing/entry.html, listing/index.html, area/entry.html and area/index.html (an index route needs an index template), and run:

engine-dmo check --fix --project .

check --fix writes the stable id of every new field, option and route (fld_, opt_, rte_); you never write ids by hand. Then engine-dmo schema plan and engine-dmo schema apply against your database, as for any schema change (see Schema changes).

The listing type

These fields are the core of every listing. A project’s extends: listing file adds fields after them; it cannot remove or change them.

FieldTypeRequiredNotes
titletextyesTranslated per locale, indexed
slugslugyesGenerated from title, per locale
summarytextyesMultiline, at most 280 characters, translated
locationgeoyesThe listing’s point; not translated
primaryTagrelation to tagyesLabelled “Category” in the editor; a second-level topic tag from the vocabulary. See The tag vocabulary

The tag vocabulary

A listing cannot be published without a primaryTag, and that must name a tag in config/tags/vocabulary.yaml, so every directory needs this file. engine-dmo seed apply loads it, and engine-dmo tags import loads it without seeding. Existing keys are left alone, and after that staff own the vocabulary in the admin.

KeyRule
A child’s keyThe full dotted key: stay.hotel, never hotel. A key not under its parent stops the import
kindtopic, audience, operational or internal. Default topic at the top level; a child takes its parent’s
publicWhether the tag is listed to the public, in the content API’s tag list. Default true at the top level; a child takes its parent’s
LevelsWrite two. The editor’s picker for primaryTag offers only topic tags at the second level, so a flat vocabulary leaves editors nothing to choose. A seed file is not held to this: it is refused only for a key not in the vocabulary

primaryTag and a category select are different things. The select is a short flat list in your schema, changed by a schema change: it is what a route’s facet and a landing page use, and a landing’s category may not be a tag. primaryTag is what the listing is, as fine as “Pubs”, changed by staff without a deployment: a member report compares a business with others sharing it. A seed file gives both: category: eat-drink and primaryTag: { id: eat-drink.pub, type: tag }.

The area type

FieldTypeRequiredNotes
titletextyesTranslated per locale
slugslugyesGenerated from title
summarytextnoMultiline, at most 280 characters
boundarygeo_shapeyesA GeoJSON Polygon or MultiPolygon in WGS 84
centregeonoWhen empty, templates read a centre the engine derives from the boundary (centre.derived is true). A derived centre has no staticMap(); see Maps

An area is the place type a landing page hangs from. To put one place inside another (a village inside a valley), give the area a parent relation to area and route it with nest: true, so /wynd-valley/wyndcombe sits under its parent’s address. Nesting is the core routing feature; see Routes.

Both types take every option a core type takes (sites:, structured_data:, embed:, field groups, per-site schema files). See Content types and Fields for the field grammar, including address, phone, opening_hours, geo and geo_shape.

Linking a listing to its business

A document of any type can be linked to one CRM organisation, which is then the document’s owner. Staff with crm.organisation.view see the Organisation panel of the document editor, which shows the organisation and its memberships; staff who may edit the document choose the organisation there. A seed document sets it with a top-level organisation: naming the seed organisation’s file name (organisation: pebble-porthwynd); Organisations and people has that file’s format.

The link is what the rest of the platform reads:

  • the organisation’s people can edit the listing in the portal, within the tier rules of member_editable;
  • enquiries about the listing reach the organisation’s contacts;
  • impressions, views and clicks on the listing count towards the organisation’s member report;
  • the listing can be promoted by the organisation, and claimed by a business with no portal account yet.

The organisation must be in the same network as the document’s owner site.

Template surface

The directory and CRM modules add these to every page’s template context.

NameKindWhat it gives
entry.organisationentry property (crm)The organisation behind the entry: id, name, legalName, website, email, phone, address, status. Null when there is none or it is in the trash. Resolved for the page’s own entry only, never for each item of a list.
organisationIdproperty of each list itemThe id of the organisation that owns the item, on items of an index page and of promoted()
entry.landingsentry property (directory)On a place: its live landing pages. On a listed document: the landing pages it appears on, the most specific place first. Each item has title, uri, url, count, landing, category (value, label, slug) and place (id, title, uri, url). Empty when the site declares no landings.
memberTier(entry)global (memberships)The key of the tier the entry’s organisation holds on this site, or an empty string when it holds none or the document has no organisation
nearby(entry, km, type)global (core)Published documents of a type within km of the entry’s point, nearest first, the entry left out. type defaults to the entry’s own type, km to 10
value.distanceTo(other)geo helper (core)The distance in kilometres to another point or place
value.staticMap(...)geo helper (core)A map image drawn by the server from your tile provider; see Maps

A listing page using them, with its address, hours, phone and website. The layout must print {{ jsonld() }} beside {{ head() }}, or the type’s structured_data is never printed (SEO and structured data):

templates/_shared/listing/entry.html
{% extends "base.html" %}
{% block body %}
<h1>{{ listing.title }}</h1>
<p>{{ listing.summary }}</p>

{% if entry.address %}<address>{{ entry.address.oneLine }}</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 %}
<table>
  <caption>{{ t("listing.hours") }}</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>
{% endif %}

<section aria-labelledby="enquire-heading">
  <h2 id="enquire-heading">{{ t("listing.enquire") }}</h2>
  {{ enquiryForm('contact-listing', entry) }}
</section>

{% if entry.organisation %}
<p>{{ entry.organisation.name }}{% if memberTier(entry) == "gold" %} <span class="badge">{{ t("listing.gold") }}</span>{% endif %}</p>
{% endif %}

{% if entry.landings %}
<h2>{{ t("listing.more_like_this") }}</h2>
<ul>{% for l in entry.landings %}<li><a href="{{ l.uri|url }}">{{ l.title }}</a> ({{ l.count }})</li>{% endfor %}</ul>
{% endif %}

{% set close = nearby(entry, 2, "listing") %}
{% if close %}
<h2>{{ t("listing.nearby") }}</h2>
<ul>{% for n in close %}<li><a href="{{ n.url|url }}">{{ n.title }}</a></li>{% endfor %}</ul>
{% endif %}

{{ entry.location.staticMap(width=600, zoom=15) }}
{% endblock %}

Every t() key the template uses belongs in config/strings/<locale>.yaml; check warns about a key the catalogue does not have.

memberTier() exists so a page can treat a paying member differently, for example to leave other businesses’ links off its page. It never varies a page by who is signed in: it reads the listing’s organisation, not the visitor.

Index pages and facets

A listing index is the core index page: items, pagination and, for each facet the route declares, facets (one per facet with field, label, selected, clearUrl and values). Draw the facets as lists of links in one navigation landmark, and take the h1 from t(), since page.title is the type’s plural label. See An index page.

A facet on a single relation to a place type (one whose route nests, or which has a boundary, as area does) is a place facet. Choosing a place matches documents related to that place, documents related to any place within it, and documents with no place of their own whose point falls inside one of those places’ boundaries. Place facets are core and also work in engine-cms; landing pages are what the directory adds to them.

When a route declares landing pages, a filter set that matches a live landing page is written as that page’s clean path rather than a query string, so facet links lead to the real pages search engines index.

Maps

staticMap() on a geo value draws the map on the server from the tiles of the provider in engine.yaml and serves it from your own site, so a visitor’s browser asks no mapping provider for anything. It is a core feature, also in engine-cms: the maps: keys are in the engine.yaml content reference and the field values in Places. They are repeated here because a directory is where most maps are drawn.

config/engine.yaml
maps:
  tiles: https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key={key}
  key: ${MAP_TILES_KEY}
  attribution: "© MapTiler © OpenStreetMap contributors"
  max_zoom: 18
KeyDefaultMeaning
maps.tilesnoneThe tile URL template with {z}, {x}, {y}, and {key} where the provider takes the key in the address. Without it staticMap() prints nothing.
maps.keynoneThe provider’s key, always a ${VAR} interpolation, written into {key}
maps.attributionnoneThe credit the provider’s licence requires, shown beneath every map
maps.max_zoom18The highest zoom a template may ask for
maps.tile_size256The provider’s tile edge in pixels
maps.timeout10sHow long fetching the tiles of one map may take

staticMap() options:

OptionDefaultMeaning
width600Width in pixels, 64 to 1280
heighttwo thirds of widthHeight in pixels, 64 to 1280
zoom151 to maps.max_zoom
altthe map.alt string, “Map showing where {name} is”The text alternative
namethe document’s titleThe place’s name in the text alternative
markertruefalse leaves the point unmarked

staticMap() works on a listing’s location, on an address’s geo and on a geo_shape’s centroid. It does not work on an area’s derived centre: when centre is empty, entry.centre.staticMap(...) prints nothing, and neither check nor render reports it. Draw an area’s map from whichever exists:

{% if entry.centre and not entry.centre.derived %}{{ entry.centre.staticMap(width=480) }}{% elif entry.boundary %}{{ entry.boundary.centroid.staticMap(width=480, zoom=12, marker=false) }}{% endif %}

Counting what visitors do

The member report is built from events the public pages record. Use the core tracking helpers on listing pages and cards so a business’s figures are complete:

In the templateRecords
data-mrd-impression="{{ item.id }}" on each card, and {{ impressions() }} once on the pagelisting_impression for each card a visitor saw
Rendering the listing’s own pagelisting_view
{{ track(entry, 'website', entry.website.url)|url }} as a link’s hrefwebsite_click (also booking, email, directions, social). check’s warning about an untracked link suggests this without the |url, which then fails with a variable inside href must go through |url
reveal(entry, 'phone', number)phone_reveal (email gives email_click)
An enquiry endpoint with an analytics: { event: enquiry_submitted, subject: $listing } stepenquiry_submitted

See Analytics for the helpers in full.

Enquiries to the business

A declared endpoint can send a visitor’s message to the business behind a listing. $listing.organisation.enquiryContacts resolves to the organisation’s current contacts whose partner role is contact; when there are none, its primary contact; when there is none, the organisation’s own email address. fallback_to is used when that is empty too.

config/sites/main/endpoints.yaml
endpoints:
  - key: contact-listing
    path: /enquire/{listing}
    methods: [post]
    access: public
    rate_limit: 5/hour/ip
    spam: { proof_of_work: true, honeypot: true, min_fill_time: 3s }
    input:
      listing: { type: relation, to: [listing], from: path, required: true }
      name:    { type: text, max: 120, required: true, label: { en-GB: Your name } }
      email:   { type: text, format: email, required: true, label: { en-GB: Your email address } }
      message: { type: text, multiline: true, max: 2000, label: { en-GB: Your message } }
    steps:
      - store: { as: enquiry, retention: 90d, subject: $listing, share_with_organisation: true }
      - mail:  { template: enquiry-to-member, to: $listing.organisation.enquiryContacts,
                 reply_to: $email, fallback_to: settings.enquiries_inbox }
      - analytics: { event: enquiry_submitted, subject: $listing }
    respond:
      fragment: partials/enquiry-sent.html
      on_error: partials/enquiry-form.html

Print the form on the listing’s page with {{ enquiryForm('contact-listing', entry) }}. A form field cannot draw an endpoint that takes a value from its path. Keep on_error: without it a failed post answers with the success fragment. partials/enquiry-form.html holds {{ enquiryForm(endpoint.key) }}, which posts to the same listing again, and the root of partials/enquiry-sent.html has the id form-{{ endpoint.key }} (Forms in templates). A scripted test needs a challenge first (Enquiry form).

The store step with subject: $listing and share_with_organisation: true is what puts the enquiry in the organisation’s inbox in the portal; without share_with_organisation the enquiry is stored for staff only and the business never sees it there, where its people can mark it read, replied or archived (see Enquiries). Endpoints themselves are covered in Forms and submissions; engine-dmo dev endpoint add <key> --subject listing (the recipe:enquiry recipe) writes the start of one, with the input, the store step and a receipt to the visitor; add share_with_organisation: true, the mail step to the business and the analytics step yourself.

reply_to: $email reaches the template as replyTo but does not set the message’s Reply-To header, which is always the site’s mail.reply_to; print the visitor’s address in enquiry-to-member so the business can answer (see Forms and submissions).

Widgets on another website

A directory can be shown on a customer’s own website through the embed widgets, as HTML fragments inlined at build time or as a script. Widgets are a core feature, switched on per site with embed.enabled and the other embed.* keys; see Site configuration. Their impressions count as widget_impression in the member report.

Agents and the content MCP

Listings and areas are ordinary documents to engine-dmo content and to the content MCP. The directory module adds landing page tools to the MCP; see The content MCP.