# Schema.org structured data — implementation report

Branch `feature/implement-schema`, 2026-08-28. Implements `SEO/Schema/task.md` (spec PDF:
`Foord.co.za _ Site Scheme.pdf`). All schema is emitted by Metatag defaults + tokens through
`drupal/schema_metatag` (already in composer at ^3.0; metatag 2.2.0), with the custom module
`web/modules/custom/foord_schema` supplying what contrib can't express. No twig JSON-LD, no
per-node values, no composer changes.

Every public page emits **exactly one** `<script type="application/ld+json">` holding a single
`@graph`; all pre-existing OG/meta tags verified byte-identical (parsed-tag diff across 17
representative URLs, before vs after); the only head additions are the four `twitter:*` tags.

## What shipped

### Commits
1. `3f46c3df` foord_schema module (groups, tags, emission rules)
2. `91c083dc` enable modules (`core.extension.yml`) + `foord_schema.settings.yml`
3. `abafaf0c` Tier 1 — `metatag.metatag_defaults.{front,global,node}.yml`
4. `618bafea` Tier 2 — `metatag.metatag_defaults.node__{news,fund,team_member,text_page}.yml`

### Tier 1 (site-wide)
- **Organization** (front page): `@type FinancialService` (selected from the module's own type
  tree), `@id https://foord.co.za/#organization`, canonical name/url/description, logo
  ImageObject 783×193 (measured from the live binary), telephone/email, foundingDate 1981,
  knowsAbout ×5, PostalAddress `addressCountry ZA` (street TBD), sameAs ×6, and **two
  ContactPoints** (customer service +27-21-532-6988/info@; sales +27-21-532-6969/unittrusts@).
- **WebSite + SearchAction** (front page): `@id https://foord.co.za/#website`, publisher →
  `#organization` (bare `@id` ref), SearchAction target
  `https://foord.co.za/search/results?keys={search_term_string}` — confirmed against the live
  header form (`action="/search/results"`, input `name="keys"`).
- **WebPage + BreadcrumbList** (global): name `[current-page:title]`, url `[current-page:url]`
  (front: `[site:url]`), isPartOf → `#website`, breadcrumb auto-built from the Drupal
  breadcrumb (easy_breadcrumb; its own JSON-LD stays off). Suppressed on the front page and on
  403/404 renders. Subtypes via `foord_schema.settings`: ContactPage (/contact, /contact-us),
  AboutPage (/about-foord, /about-foord/directorate-and-team), CollectionPage (/insights,
  /insights-sa, /insights/videos, /insights-sa/videos, /insights/newsletter, /podcasts).
- **Twitter cards**: `summary_large_image` + title/description/image mirroring the audited OG
  tokens. *Deviation:* placed in the `node` default, not Global — the OG tags they mirror only
  exist there; a Global placement would emit empty cards on non-node pages.

### Tier 2 (per content type)
- **Article** — `news` (types news/newsletter/audio-webinar) and `text_page` under `/insights*`
  (newsletter child articles): headline/description/dates from tokens
  (`[node:created|changed:html_datetime]` — there is no editorial date field), publisher AND
  author → `#organization` (*deviation, agreed 2026-08-28: no byline field exists and node
  owners are back-office accounts*), image ImageObject from `field_image` when present,
  mainEntityOfPage `[node:url]`.
- **PodcastEpisode** (custom group) — `news` with `field_article_type=podcast`, no video:
  name/url/description/datePublished + partOfSeries → PodcastSeries "Foord Asset Management"
  (Apple Podcasts URL).
- **VideoObject** — `news` with `field_video_=1` and a populated embed: name/description,
  thumbnailUrl from the oEmbed thumbnail, uploadDate, embedUrl = the stored YouTube watch URL
  (raw field value; the page renders it as an /embed/ iframe).
- **InvestmentFund** (custom group) — `fund` bundle: name/description/url tokens, provider →
  `#organization`, category **Unit Trust** (bundle+domain-derived; becomes **Global Fund** on
  foord.com, whose fund nodes are the global funds). **No performance figures, returns, fees or
  rates anywhere in the group — the tags do not exist.**
- **Person** — `team_member` bundle default (name, jobTitle, worksFor → `#organization`, url,
  image). Because every live `/team/*` URL 301s to `/about-foord/directorate-and-team`,
  foord_schema ALSO appends one Person per published, domain-visible team member (name +
  job title only — the data the page shows) to that page's `@graph` (34 Persons on foord.co.za
  at verification time).

### Two-domain handling (agreed 2026-08-28: adapted schema on both, nothing invented)
One canonical Organization anchored at `https://foord.co.za/#organization` is emitted on both
front pages and referenced by every publisher/provider/author/worksFor on both domains. On
foord.com the hook rewrites only the host-bound values: WebSite becomes
`https://foord.com/#website` named "Foord International" (per domain config), SearchAction
targets foord.com, WebPage isPartOf follows, fund category becomes "Global Fund". Verified on
foord-global.test.

## Live URL pattern → bundle → schema (sitemap census: 818 URLs, /foord-local/sitemap.xml)

| Live pattern (count) | Bundle / condition | Schema emitted |
|---|---|---|
| `/` | front | Organization + WebSite(SearchAction) + WebPage |
| `/investments/unit-trusts/*` (11) | fund | InvestmentFund (Unit Trust) + WebPage/Breadcrumb |
| `/investments/institutional-investors/*` (13) | performance_portfolio | WebPage only (open question) |
| `/insights-and-commentary/*`, `/insights/*` flat (~380) | news type=news; text_page children | Article |
| `/insights/newsletter*`, `/newsletter-foreword/*` (~130) | news type=newsletter + text_page | Article |
| `/insights/podcasts/*`, `/podcasts/*`, part of `/videos-and-podcasts/*` (~65) | news podcast, audio | PodcastEpisode |
| `/insights/videos/*`, `/videos/*` (~104) | news podcast/webinar + video | VideoObject |
| `/webinars/*` (14) | news webinar | VideoObject (video) / Article (audio) |
| `/team/*` (24 — all 301 to directorate) | team_member | Person (bundle default; plus Person list on directorate page) |
| `/about-foord/directorate-and-team` | page nid 18 | AboutPage + 34 × Person |
| `/contact` | page nid 56 | ContactPage |
| `/about-foord/*`, listings, root-level pages (~70) | page / text_page | WebPage (+ subtype map) |
| stray root-level slugs (~15) | news/text_page manual aliases | Article (classified by field, never path) |

Every sitemap pattern maps to a bundle; none uncovered. Classification keys off
`field_article_type` + `field_video_` because news aliases are heavily manual.

## Validation performed (local, foord.test / foord-global.test)
- 17-URL before/after capture: pre-existing meta/OG/canonical tags byte-identical everywhere;
  delta = the four twitter tags only.
- Exactly one ld+json script on every 200 page; **zero** on 404; valid JSON (parsed), `@graph`
  array; every bare `@id` ref resolves to `#organization` / the active domain's `#website`;
  both anchors defined on each front page.
- Pick-one verified: podcast page has no Article/VideoObject, video page has no
  Article/PodcastEpisode, legal text_page has no Article, etc.
- Per-node `field_metatags` overrides (e.g. nid 92's custom title) coexist: title honoured,
  schema intact.
- Repeated anonymous fetches: head + JSON-LD identical (page_cache safe).
- Config round-trip: fresh `cex` matches the nine committed config files byte-for-byte.
- Note: local URL tokens resolve to local hostnames (foord.test etc.); on production the same
  tokens resolve to foord.co.za/foord.com. The Organization/WebSite blocks are literals and
  already production-correct.

## URLs to test after production deploy
Google Rich Results Test (expect Breadcrumb everywhere; Article on 3–4; Video on video page):
1. https://foord.co.za/
2. https://foord.co.za/investments/unit-trusts/foord-equity-fund
3. https://foord.co.za/insights-and-commentary/markets-nutshell-ai-mania-inflation-and-real-test-investors
4. https://foord.co.za/insights/podcasts/dave_foord_on_politics_debt_and_the_future_of_markets
5. one https://foord.co.za/insights/videos/* page
6. https://foord.co.za/about-foord/directorate-and-team
7. https://foord.co.za/contact
Also run 1, 2 and 4 through https://validator.schema.org — InvestmentFund, PodcastEpisode,
FinancialService and Person don't surface in Rich Results but must validate.

## Deploy runbook
1. Merge; production `composer install` brings schema_metatag to lock 3.0.4 (local dev ran
   3.0.3 — plugin-compatible).
2. **Run `drush config:status` first.** Locally the DB carries ~150 files of unrelated
   DB↔repo drift; if production shows similar drift a blanket `drush cim -y` is destructive —
   stop and resolve before importing.
3. `drush cim -y` (installs the 9 modules, imports the defaults), `drush updb -y`, `drush cr`.
4. Purge Varnish fully (every HTML head changes) and spot-check the URLs above; twitter: tags
   should appear and OG tags be unchanged.

## Out-of-scope findings (investigated, per spec)
- **FAQPage**: no FAQ/Q&A/accordion content exists — 0 nodes with `field_article_type=faq`,
  and the live "Invest with Foord" page has no Q&A markup. Nothing to mark up.
- **JobPosting**: live careers page lists exactly one opening ("Securities Trader"), with no
  posting date; the vacancy bundle stores no dates. JobPosting requires datePosted +
  validThrough → correctly skipped.
- **Review/AggregateRating**: skipped entirely per spec.

## Open questions for the client
1. **Street address** for the Organization PostalAddress (currently country-only `ZA`).
2. **foord.com canonical data**: the international site now reuses the group Organization
   (anchored at foord.co.za). Confirm whether it should carry its own contacts/address/legal
   name (Singapore/Guernsey) — currently nothing int-specific is emitted because none was
   supplied.
3. **Author bylines**: articles credit the Organization. A real byline field would enable
   per-author Person markup (authors currently appear only in prose).
4. **Podcast/video durations**: not stored in Drupal (`field_read_watch_time` has 5 values
   site-wide) → `duration` omitted. Worth capturing at upload if desired.
5. **performance_portfolio** (13 institutional pages): plain WebPage now; model as
   Service/InvestmentFund later if wanted.
6. **robots.txt disallows `/search/` and `/*?*`** — the SearchAction target is valid but
   uncrawlable; consider allowing `/search/results` if the sitelinks searchbox matters.
7. **Breadcrumbs are not visibly rendered** anywhere on the site (theme template commented
   out); BreadcrumbList is emitted from the computed trail per spec, but Google prefers markup
   mirroring visible content — restoring the visual breadcrumb would align them.
8. **`/insights/videos` page bug** (pre-existing): it embeds the webinar+video view block
   (4 nodes) instead of podcast+video (125). Schema classification does not inherit this, but
   the listing itself looks wrong.
9. **Editor forms**: the new schema tag groups appear (empty) on node edit forms for all
   bundles except `fund` (`metatag.settings` only whitelists groups there). Harmless — defaults
   drive all output — but `entity_type_groups` can hide them if editors object.
10. **Speakable**: skipped — the theme has no `.article-summary`/`.article-intro` (the spec's
    suggested selectors); the lead paragraph has no CSS hook of its own.

## Local-environment repairs made along the way (not part of the change)
- `web/sites/default/settings.php` (git-ignored): guarded `ini_set('zend.assertions', '0')` —
  homebrew PHP ships assertions on, turning benign template asserts into 500s prod never sees.
- Recreated the `foord_test` / `foord_global_test` domain_alias records (wiped by the last
  live-DB import) from the untracked config files, and created `field_extra_documents`
  (storage + fund instance) from `config/sync` — the live-dump DB predates it and fund pages
  fatal without it.
