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
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.
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 } } extends: area
label: { en-GB: Place }
plural: { en-GB: Places to go }
fields:
- name: parent
type: relation
to: area
label: { en-GB: Part of } 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 } 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.
| Field | Type | Required | Notes |
|---|---|---|---|
title | text | yes | Translated per locale, indexed |
slug | slug | yes | Generated from title, per locale |
summary | text | yes | Multiline, at most 280 characters, translated |
location | geo | yes | The listing’s point; not translated |
primaryTag | relation to tag | yes | Labelled “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.
| Key | Rule |
|---|---|
A child’s key | The full dotted key: stay.hotel, never hotel. A key not under its parent stops the import |
kind | topic, audience, operational or internal. Default topic at the top level; a child takes its parent’s |
public | Whether 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 |
| Levels | Write 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
| Field | Type | Required | Notes |
|---|---|---|---|
title | text | yes | Translated per locale |
slug | slug | yes | Generated from title |
summary | text | no | Multiline, at most 280 characters |
boundary | geo_shape | yes | A GeoJSON Polygon or MultiPolygon in WGS 84 |
centre | geo | no | When 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.
| Name | Kind | What it gives |
|---|---|---|
entry.organisation | entry 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. |
organisationId | property of each list item | The id of the organisation that owns the item, on items of an index page and of promoted() |
entry.landings | entry 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):
{% 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.
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 | Key | Default | Meaning |
|---|---|---|
maps.tiles | none | The tile URL template with {z}, {x}, {y}, and {key} where the provider takes the key in the address. Without it staticMap() prints nothing. |
maps.key | none | The provider’s key, always a ${VAR} interpolation, written into {key} |
maps.attribution | none | The credit the provider’s licence requires, shown beneath every map |
maps.max_zoom | 18 | The highest zoom a template may ask for |
maps.tile_size | 256 | The provider’s tile edge in pixels |
maps.timeout | 10s | How long fetching the tiles of one map may take |
staticMap() options:
| Option | Default | Meaning |
|---|---|---|
width | 600 | Width in pixels, 64 to 1280 |
height | two thirds of width | Height in pixels, 64 to 1280 |
zoom | 15 | 1 to maps.max_zoom |
alt | the map.alt string, “Map showing where {name} is” | The text alternative |
name | the document’s title | The place’s name in the text alternative |
marker | true | false 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 template | Records |
|---|---|
data-mrd-impression="{{ item.id }}" on each card, and {{ impressions() }} once on the page | listing_impression for each card a visitor saw |
| Rendering the listing’s own page | listing_view |
{{ track(entry, 'website', entry.website.url)|url }} as a link’s href | website_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 } step | enquiry_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.
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.
What engine-dmo adds
The modules the DMO bundle compiles on top of the core content platform, what each one is for, how they depend on each other and which configuration files belong to them.
Landing pages
How to declare landing pages (a place crossed with a category, a category alone, or a calendar month) on a route, the threshold that makes one live, the generated and editor-written copy, the template and its landing context, and the redirects the engine keeps.